Утрата памяці ў React Native: аналіз владальнікаў памяці JS Heap і натывной памяці
Дазвольце дазнацься, чаму процес збору сметлі не можа захаваць дапрыг React Native ад выліваў ресурсаў у натыўным кодзе, і як выявіць, што спрабоўвае заставіць калбэкі, об’екты JSI і декодаваныя зображэння працаваць.
Крыніцы: The Stack, the Heap, and Garbage Collection: Why Your React Native App Still Runs Out of Memory
Экран у React Native адмонтаваецца, і вы логічнаа спадзяваецеся, што яго памэц таксама зникне. Аднак пасля таго, як гэты экран быў ачынут і закрыт дзесяць разоў, прыстанок стае медленным, тэлфон на Android з малай колькасцю памяці проста завершвае яго роботу, а Xcode паведамляе, што прыстанок перакрыў свой ліміт памяці. У JavaScript є калектар мусора, тады як гэта можа выйсці?
Адказ такі: прыстанок у React Native функцыонуе ў двух «светах» памяці, і калектар мусора керуе толькі адним з іх. У гэтым кяле пояснюецца, як на самай працоўваюць стак, хіп і калектар мусора, практычна паказаны тры типы вытэкання памяці, характерныя для React Native, а таксама распашчаны паследнія міфы пра хукі, і показана, як выкарыстоўваць інструменты Hermes, Xcode і Android Studio для выявлення таго, што ўсё яшчэ утримвае памэц, якая продовжае расту.
Два «светы» памяці, якія падтрымваюць адна другую
Адзінай сторонай ёсць свет JavaScript: аб’екты, масівы, функцыі, стан і пропсы React, а таксама замкнутыя функцыі, якія захопляюць данні з калебаў. Адной сторонай — натыўны свет: відворы платформы, дэкодаваныя пікселі зображэння, буферы камеры і відэа, об’екты кантролю файлоў і данні, якія зберагаюцца ў натыўных модулях.
Этыя два светы з’ѐднаны, і кожны можа падтрымваць жыццё другога. Адналежны структура кешу JavaScript нічога не сказвае пра аб’ём памяці всьго дапытку, а адключэнне компонента сама па сабе не звалівае жадных натыўных рэсурсаў, якія ён выкарыстоўваў. Урахоўванне гэтага разліку ёсць аднай з базовых прычын для кожнага далейшага аналізу.
Як працуюць стак, кеш і збір сметлів
Тры панявы варта чытка адзначыць.
- Стак вызываў фіксуе функцыі, якія зараз выкананы. Кожны вызыв дадае новую елементацыйную структуру; вярненне з метаданнымі выкарыстоўвае яе для выцягвання даных.
Невялікі прыклад парадкавае гэтыя адносіны:
function showName(user) {
return user.name.toUpperCase();
}
showName({ name: "Ada" });
Калі выканана функцыя toUpperCase(), стак храніць калькі фрэмоў: вызов showName і метод строкі, які ён запускае. Сам об’ект пользователя знаходзіцца ў heap. Калі showName вяртаецца, яго фрэм зникае, але об’ект становіцца прызначальным для збору толькі тады, калі больш нічога не аб’являеся да яго. У гэтым фрагменте нічога такога няма, таму ён падходзіць для збору; якбы кэш, аслухальнік чыя-небудзь зменная на рэвэлі мелі аб’явленне да яго, ён застаўся бы.
Это дае правілу, якое кантролюе ўсё іншае: GC абзяўляе недоступныя даны. Компонент, які выключаецца, або локальная зменная, якая выходзіць за межы сваёй сферы даследжэння, сама па сабе нічога не звалюе; яна толькі усувае адну з можлівых дораг да даных.
Часавік таксама мае значэнне. Система кашалёвання памяці не дае ніяких абавязкаў ўжо па часе свайго запуску. Hermes выкарыстоўвае канкурэнтны колектар пад назвай Hades, створаны для таго, каб періяды пауз былі короткімі і ўтримваць плавнае працаванне інтерфейсу (дакладнее — выпускі Hermes). Адным з наследкав яго ўжыцця є тое, што тое, што памяць не зменшаецца негайна пасля переходу, не ўсёлкі раз яўляецца доказам вытэчкі. Як пояснююць інжынеры Hermes у дыскусіях пра повадкі кашалёвання памяці, недоступныя об’екты могу застацца до таго часу, пакуль колектар не вырашыць, што тэснае напруга памяці робіць кашалёванне значным. Вытэчкай є памяць, якая продовжае растаць праз паўтарныя запускі той самай задачы, а не памяць, якая спадае медленна.
Дзе памяць застаецца жывой у дапрацоўке React Native
Сёння большая частка дапрыг React Native работае на Hermes, хоць конкрэтны двыгун залежыць ад самай дапрыгі і ўсаблікаванняў яе. Двыгун вплывае на параметры кашы і перыяды пауз у процесе адчысцэння кашы, але не на першы вопыт падчас дэбагавання: чы гэта зростанне памяці JavaScript чы натыўнай памяці?
Нижэйшыя дыяграмы паказваюць типовыя зв’язкі между разнымі элементамі:
JavaScript runtime Native runtime
------------------ --------------------
state and props ─────► views and view layers
arrays and objects ─────► decoded image pixels
hooks and callbacks ─────► camera/video buffers
JSI values ◄─────► TurboModules / C++ data
Стан і пропсы керуюць натыўнымі відображэннямі, масівы і об’екты ўказываюць на дэкодаваныя зображэння, хукі і калбэкі зберагаюць буферы камеры чы відэа, а значэння JSI взаімаюцься як у напрамку JavaScript, так і з дадзеннямі на C++. Спрыяйце розумець гэтыя межы як лінію власнасці. Калі любы ресурс пераходзіць через яе, вы должны магчымае адпаведзець на тры вопыты:
- Хто зараз ўладае гэтым ресурсам?
- Што падтрымае яго жыццё?
- Які самэўказаны вызов яго звалівае?
Якщо адказ на трэція запытанне — "за гэта будзе адпрацоўваць збіршчы сметлі", спрыймайце гэта як апавешчальны знак. Для натыўных рэсурсаў гэта часта не ёсць безпечна.
Утэк 1: натыўны модуль, які зберагае ваш калбэк у JavaScript
У JavaScript для прыгледачаў няма рэальнага адпаведніка гэтаму патэрану. Натыўны модуль можа зберагаць калбэк, які он прымеў з JavaScript, і гэты калбэк падтрымвае жывым усю сваю структуру: стан, параметры, большы об’ект-эскіз, нават модель экрана, якога больш не існуе.
Ёсць ситуацыя, калі праграма падпісваецца на абмен рэзультатамі завершання загрузкі і вяртае функцыю для чысткі, якая адменавляе гэты падпіс:
useEffect(() => {
const sub = UploadManager.onProgress((done) => {
reportProgress(draft.id, done);
});
return () => sub.remove();
}, [draft.id]);
Гэта будзе безпечна толькі якщо функцыя remove() дасловна паведамляе натыўную частку прыстрою аб неабходнасці звалічэння калбэка. Якщо функцыя чысткі відсутня, або ў бібліятэцы є баг, утвараецца ланцюг зберагачоў, падобны гэтаму:
native UploadManager → JS callback → closure → draft → image list
Натыўны UploadManager зберагае калбэк, калбэк зберагае свой клозуру, клазура зберагае draft, а draft зберагае цэлы спіс аўтарактываў. Зніманне экрана нічога не робіць для перарыву гэтага ланца. Толькі чыстка падпісоў гэта рабіць можа, і самэ гэтая прычына выклікае патрэбу пераканацца, што падпісы трохцявых сторон дэйсна ад’юнкуюцца на натыўной стороне, а не толькі ў JavaScript.
Атрыбут выцягвання 2: маленькі об’ект JSI, який выступае адносам да вялікага натыўнага ресурсу
JSI дазваляе JavaScriptу взаімадзейваць з абороднымі об’ектамі значная прымоўней, чым стары мост, што ўзначна паспрыява выдатным парадкам роботы і робіць кантроль над ресурсамі ўжо болей важлівай. Уявіце бібліятэку, якая ачынае абородны декодер відэа, курсар базы дадзеных або сесію камеры і дае JavaScriptу лёгкі об’ект, який яго представляе. Об’ект JavaScript можа быць дужа маленькім, і алгоритм збірання вільных ресурсоў не мае прычын спешыць з яго збіраннем, тады калі абородны ресурс, які за ўсём стоіць, спрацоўвае вельмі большу кантэнцыю памяці.
Наведзены выклік ачынае сесію камеры, запускае яе, а пад час закрыцьця адразу ж зупіняе і ўсуне яе:
useEffect(() => {
const session = CameraSession.open();
session.start();
return () => {
session.stop();
session.close();
};
}, []);
Сама функцыя close() ўсё значыць. Сесіі апаратнага забезпечэння, декодеры, ачыненыя файлы, курсары і рэзерваваныя ресурсы GPU павінны быць вызволены яшчэ до таго, калі іх знайдзе механізм збору. Вы не можете запланаваць процес збору, таму ён не можа служыць вашым планам вызвалення ресурсаў, а функцыі finalizer могу запрацаваць пазней або вовсе не запрацаваць.
Якщо вы підтрымляеце бібліятэку JSI або натыўную бібліятэку, даце кожнам об’екту, які володзіць ресурсамі, чыстаючы метод і зробіце яго ідэмпатным, так што два вызовы не будуць небяспечны. У коде прыкладнага програміста яго трэба вызваць з відпаведнай точкі чысткі. Команда Hermes дае тое ж парадзенне ў дыскусіях пра GC: неабходна выклікваць метод у стыле close() для натыўных ресурсоў, а не пакладацца на функцыі finalization.
Утрата ресурса 3: компонент з адпрацоўкай зображэння ўтратзеў, а пікселі — нята
Гэты прынцып стоіць за вялікай часткай збоёў, вызваных нехапакой памяці. Фота, якое ў меры маленькае на дыску, стае вельмі вялікім пасля декодавання, адтакуль як компрэсаваныя форматы, такія як JPEG, павінны быць распакованы ў чыстыя пікселі для адображэння. Прыблізныя парадыксы декодаванага размеру:
width × height × 4 bytes
Пры чатырох байтах на піксель, фота размерам 4 000 на 3 000 патрабуе або-ма 48 MB, і гэта яшчэ до дадатковых копій для масштабавання, кэшавання чырага заваносу на GPU. Тепер рассмотрзім мініатюру, якая адображае такое фота:
<Image
source={{ uri: fullSizePhotoUrl }}
style={{ width: 160, height: 160 }}
/>
Размер відображэння становіць толькі 160 на 160 пунктав, але прыстрой можа ўсё равно заваносіць і декодаваць файл у повным размере. У строе з многімі вялікімі зображэннямі кантэйнерная памяць можа поступова зрасці, тады калі каша JavaScript застаецца незменнай, і гэта самэ тая ситуацыя, якая плутае разработчыкаў, якія адзірванае сакладаюцца толькі на памяці JS.
Не кожны падынкі памяці ўскладнююць работу. Кэш зображэнняў, які зберагае неякшыя зображанні, якія недзеўна пераглядваліся, выполняе свою функцыю. Проблема вылучаецца тады, калі памяць продовжвае растаць з кожным разам, калі праходзіце праз той самы поток, яна ніколі не стабілізуецца, і ў падсумку процес зупіняецца.
Пачніце з выправлення таго, што падаеўаеце ў ланцюг обробкі зображэнняў:
- Запрашайце зображанні практычна таго размеру, якім яны будуць адображаны.
- Ў спісах вядзіце мініатюры, а повныя размеры завантажайце толькі на экране деталей.
- Обмежыце колькість зображэнняў, якія можна пераглядваць адразу, напрыклад, за дапамогою віртуалізацыі спісаў.
- Выявіце правілы кэшавання вашай бібліятэкі зображэнняў і налаштавьце ўсе яе ліміты.
- Тэставаць трэба на рэальным прыстрое з малай памяцю, а не толькі на сімулятары.
Звалаччаць віну за 48 MB бітмапа на Hermes — глыбока памылка. Ядро JavaScript не можа вернуць сабе натыўныя пікселі, якія ўсё ўсё належаць зображэнню, ланцугу обробкі зображэнняў, кэшу чы тэкстуре GPU.
Застарэлыя закрыцція ў хукі: ведаеце, які баг у вас є
Паўзульна тэза заключаецца ў тым, што некоректны массив залежнасцяў ведае памэц. Гэта можа быць так, але часта спачатку выйшвае баг з некоректнымі значэннямі, і гэта разлік вплывае на спосаб ўсунення проблемы.
Закрыцце зберагае значэнні, на якія ўсёлякае. Якщо натыўны аберанац или таймер зберагае такое закрыцце дыяўшчэ, чым планавалося, закрыцце продовжае зберагаць старыя данні. У такім случае массив залежнасцяў застаецца порожнім, таму калбэк прогрэсу ствараецца толькі адна раз:
useEffect(() => {
const sub = player.onProgress(() => {
savePosition(episode.id);
});
return () => sub.remove();
}, []); // episode may change, but this callback will not
Калбэк захопляе episode з першага атрыбутавання. Калі корыстнік меняе эпізоды, прогрэс все ўсё захоўваецца па первіснам ID. Гэта і є застарэлыя закрыцція. Якщо падпіска будзе анульвана пасля зняцья, у вас будзе баг правільнасці, а не стойкі вытэк памяці. Якщо натыўны плеер захоўвае калбэк пасля зняцья, у вас будуць і тое, і другое.
Рашэнь не ў тым, каб класці кожны об’ект у масэвую залежнасцяў. Ён у тым, каб эфект точна апісваў ресурс, які ён утримвае, па ключу, які яго визначае:
useEffect(() => {
const sub = player.onProgress(() => {
savePosition(episode.id);
});
return () => sub.remove();
}, [episode.id]);
Тепер, калі змянюецца episode.id, React запускае процедуру чысткі, каб адмахнуцца старага падпісу пры стварэнні новага, а калебак ўзявае толькі неабходную маленькую значэння. Апоўзанне на прымітную ID замест на весь об’ект episode таксама запобегае падпісуванню занова ў кожны раз, калі змянюецца ідэнтычнасць об’екта, але сам эпізод — няў.
Адбыць рэальную перапрацоўку памяці
Нечыткія заазначэння на кшталт "програма выкарыстоўвае многа памяці" не є корыстнымі. Узамест таго задаць адну павтаральную сэрію дзеяння, напрыклад: ачысці ленту, праскроліць яе, ачысці дзесять фота, вернуцца назад і павтарыць усю сэрію пяць разоў.
Потым паглядзіце, якія законамернасці паказваюць вимеры:
- Хребтак JS расте пасля кожнага ціклу: найбольшая верасць наявна ў частыні JavaScript, таму пачніце з фотакапаў хребтака.
- Хребтак JS застаецца прыблізна стабільным, пакуль аб’ём памяці всього дапрацоўніка расте: пераканайцеся ў стане натыўнае памяці за дапамогою інструментаў платформы.
Діагностика росту хребтака JavaScript
Іспользуйце інспектар Hermes і берыце фотакапы хребтака там, дзе ваша версія React Native і настройкі кампілявання гэта дазваляюць. Выберыце падозрельны об’ект у фотакапе і праследавайце шлях його зберагання аж да корневага элемента. Тыповыя прычыны маюць назвы на кшталт listeners, timers, stores, caches, callbacks або module-level singletons.
Будзьце абярожны, каб не плутаць гэта з інструментам Hermes для аналізу, які ў першую чаргу є інструментам для CPU. Ён паказвае, які фрагменты JavaScript былі актыўныя, а не прычыну зростання памяці натыўных апляваў. У нарадзе React Native пра аналіз з аднойменным інструментам Hermes пасвячана інструкцыя ўсуперш ці як запісваць та ачынаць прафіль CPU.
Діагностика памяці натыўных апляваў на iOS
Запустіце прыграму з Xcode на фізычным прыстрое. Пачніце з показчика памяці, каб адразу бачыць тэндэнцыі, а потым ачыніце графік памяці для дэбагування, каб адзірнуць об’екты і звязкі межаў іх. Інструменты, у чыяму числе шаблон Allocations, дазваляюць стежыць за распадам памяці праз парадоклады запуску прыграмы. Дакументацыя Apple па памяці Xcode апісвае, як граф памяці адкрывае інформацыю пра об’екты, распад памяці і ўзаемныя звязкі, а таксама як Allocations стежыць за памяцю типу heap і віртуальную память.
Калі Інструменты паказваюць стабільны рост спагавання памяці пад час працы з великай колькасцю фота, але память типу Hermes практычна не змінюецца, ня трэба шукати прычыну ў JavaScript-клозуре. Проблема знаходзіцца на натыўнай стороне.
Діагностика натыўнай памяці на Android
Ўзайце апранат Memory Profiler з Android Studio на рэальным прыстрое, каб стаціонарна адзначаць выкарыстоўванне памяці аплікэйшна ў часе. Яго вялікі апранат памяці, які з’явіўся ў Android Studio 4.1, фіксуе выдзел і звярненне памяці на рэгламентны адзеў і стварае падсумкі пра об’екты на рэгламентны адзеў.
Экраны апранатара меняюцца залежна ад версій Android Studio, але сам метод застаецца тым жа: фіксуець прагрэсы, адзначае пачатак кожнага цыклу і шукае выкарыстоўванне памяці, якое расте без вярнення да стабільнага базовага рывня.
Спіс пераканалення для кожнага экрана
Што бы ні пачалося на экране, яго пераканаленне павінна завершыцца. Короткая перагледка включае:
- Адмахненне таймера, які быў запусцаны.
- Адключэнне даданага слухальніка.
- Адзначэнне аб анулюванні падпіску на рэгламентны адзеў.
- Зупінэнне і закрыцце камеры, плеяра, файла чыста базы дадзеных.
- Установка ліміту розмеру чыста правіл адмахнення для кэшу, які вы стварылі.
Это не ўправлінне памяцюю у ручным режыме ў сэнсе C. Вы не зваліваеце будзь-якія байты; вы прыпыняеце роботу і вертаеце рэсурсы тады, калі ваша прыкладна програма больш не ўтримвае іх.
Заключэнне
Сбор шматкаў ніколі не обяцваў, что все, чым касалася компанента, знікне, калі яна будзе ад’ўязана. Калі памяць зрастае, задайце два запытанні ў такой 순서: чы рост відбуваецца ў кеш-памяці JavaScript чы ў натыўнам процэсе, і хто ще утримвае тое, што расте?
Адказ ведае прыму па рашэнні. Адкрытыя каллбэкі означаюць падпіску, якая ніколі не была адмянутая. Натыўная сесія значыць відсутнае close(). Бітмап па габаратах 48 MB значыць, што вам трэба меншая картіна або кращы кеш. Блакіберды памяці у React Native перестаюць быть таёмнічымі, калі вы перестанете чакаць на GC і пачнёте шукаць власніка, а потым завершыце його права на власнасць.
У всім часе аналізавайце працэс на рэальных прыстроях. Сімулятар падходзіць для відтворэння алгорытму, але ён можа сховаць тычыну памяці, якая завершвае работу вашага дапрынтку на тэле корыстніка.