Витоки пам’яті в 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 демонтується, і ви логічно очікуєте, що його пам’ять також зникне. Проте після десяти разів відкриття та закриття цього екрана додаток починає працювати повільно, телефон з обмеженою пам’яттю просто завершує його роботу, а 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 взаємодіють у обох напрямках із TurboModules та даними на 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 мають бути свідомо звільнені, а не залишатися на розсуд механізму збору відходів. Ви не можете запланувати процес збору відходів, тому він не може слугувати вашим планом звільнення ресурсів, а функції завершення можуть виконуватися з запізненням або взагалі не виконуватися.
Якщо ви підтримуєте бібліотеку JSI чи нативну бібліотеку, надайте кожному об’єкту, який володіє ресурсами, чіткий метод звільнення та зробіть його ідемпотентним, щоб двократний виклик не мав наслідків. У коді додатку викликайте цей метод з відповідної точки очищення. Команда Hermes надає таку саму пораду у обговоренні щодо GC: надайте метод у стилі close() для нативних ресурсів замість того, щоб покладатися на функції завершення.
Проблема витоку 3: компонент зображення зник, але пікселі залишилися
Саме ця проблема є причиною великої кількості збоїв через нестачу пам’яті. Фото, яке здається невеликим на диску, стає величезним після декодування, оскільки компресовані формати на кшталт JPEG мають бути розпаковані у сирі пікселі для відображення. Орієнтовний розмір після декодування становить:
width × height × 4 bytes
Якщо кожен піксель займає чотири байти, то фото розміром 4 000 на 3 000 потребує приблизно 48 МБ, і це ще до додаткових копій для масштабування, кешування чи завантаження на GPU. Тепер уявімо мініатюру, яка відображає таке фото:
<Image
source={{ uri: fullSizePhotoUrl }}
style={{ width: 160, height: 160 }}
/>
Розмір вікна становить лише 160 на 160 пунктів, але пристрій все одно може завантажувати та декодувати файл у повній роздільній здатності. У потоці з багатьма великими зображеннями обсяг користувацької пам’яті може поступово зростати, тоді як обсяг пам’яті JavaScript залишається незмінним, що саме і спричиняє плутанину у розробників, які дивляться лише на пам’ять JavaScript.
Не кожне збільшення об’єму пам’яті є ознакою витоку. Кеш зображень, який зберігає нещодавно переглянуті картинки, виконує свою функцію. Проблема виникає тоді, коли об’єм пам’яті постійно зростає під час кожного проходження через той самий потік даних, не досягаючи стабільного рівня, що призводить до зупинки процесу.
Почніть із виправлення того, що ви подаєте у систему обробки зображень:
- Запитуйте зображення розміром, близьким до того, у якому вони будуть відображатися.
- Використовуйте мініатюри у списках та завантажуйте зображення повної роздільної здатності лише на сторінці деталей.
- Обмежте кількість одночасно відображуваних зображень, наприклад, за допомогою віртуалізації списків.
- Дізнайтеся про правила кешування у вашій бібліотеці зображень та налаштуйте її обмеження.
- Тестуйте на справному пристрої з обмеженою кількістю пам’яті, а не лише на симуляторі.
Звинувачувати Hermes у проблемах з 48-МБ бітмапом — це не доцільно. Ядро 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 Sampling, який є переважно інструментом для CPU. Він показує, який JavaScript був активний, але не пояснює, чому зростає пам’ять нативного коду. Посібник React Native з профілювання за допомогою Hermes описує, як записувати та відкривати профіль CPU.
Діагностика пам’яті нативного коду в iOS
Запустіть додаток з Xcode на фізичному пристрої. Почніть з індикатора пам’яті, щоб швидко оцінити тенденцію, а потім відкрийте графік пам’яті для налагодження, щоб побачити об’єкти та зв’язки між ними. Інструменти, особливо шаблон „Allocations“, дозволяють стежити за розподілом пам’яті під час повторних виконань. Документація Apple щодо пам’яті в Xcode описує, як графік пам’яті відображає об’єкти, розподіл пам’яті та їхні зв’язки, а також як інструмент „Allocations“ відстежує як кеш-пам’ять, так і віртуальну пам’ять.
Якщо інструменти показують постійне зростання пам’яті під час роботи з великою кількістю фото, а кеш-пам’ять Hermes майже не змінюється, припиніть шукати проблему у JavaScript-функціях. Проблема лежить у нативному коді.
Діагностика нативної пам’яті в Android
Використовуйте інструмент аналізу пам’яті Android Studio на справжньому пристрої, щоб спостерігати за використанням пам’яті додатком з часом. Його вбудований механізм аналізу пам’яті, який з’явився у Android Studio 4.1, фіксує виділення та звільнення пам’яті на рівні нативного коду та узагальнює інформацію про об’єкти цього рівня.
Інтерфейси інструменту аналізу змінюються залежно від версії Android Studio, але сам метод залишається незмінним: він фіксує послідовність дій, позначає початок кожного циклу та виявляє ситуації, коли використання пам’яті зростає без повернення до стабільного рівня.
Чек-лист для очищення кожного екрана
Що б не починалося на екрані, його очищення має завершуватися. Швидкий огляд включає:
- Скасування таймера, який було запущено.
- Видалення доданого слухача подій.
- Виходження з підписки на нативні сервіси.
- Зупинка та закриття камери, плеєра, файлового чи базового даних об’єкта.
- Встановлення ліміту розміру чи політики видалення даних з кешу.
Це не є ручним керуванням пам’яттю у сенсі мови C. Ви не звільняєте довільні байти; ви завершуєте роботу та повертаєте ресурси у момент, коли ваш додаток більше не є їхнім власником.
Підсумок
Система збирання сміття ніколи не обіцяла, що все, чим користувався компонент, зникне після його видалення. Коли обсяг пам’яті зростає, поставте два запитання у такому порядку: чи це зростання відбувається у кеші JavaScript чи у нативному процесі, та хто все ще є власником об’єкта, який зростає?
Відповідь вказує прямо на спосіб виправлення. Постійні виклики функції означають підписку, яка так і не була скасована. Natивна сесія означає відсутність функції close(). Бітмап розміром 48 MB означає, що вам потрібне менше зображення або більш ефективний кеш. Проблеми з пам’яттю у React Native перестають бути загадковими, як тільки ви перестаєте чекати на очищення пам’яті та починаєте знаходити власника проблеми, а потім припиняєте його право володіння.
Усюди аналізуйте роботу додатку на справжніх пристроях. Симулятор підходить для відтворення певних процесів, але він може приховувати тиск на пам’ять, який призводить до завершення роботи додатку на телефоні користувача.