Главная / Статьи

Утечки памяти в React Native: отслеживание владельцев памяти JS-хранилища и нативной памяти

Узнайте, почему механизм сбора мусора не может спасти приложение React Native от утечек в нативном коде, и как выяснить, что препятствует завершению работы обратных вызовов, объектов JSI и декодированных изображений.

Издатель: REACTAPP.TOP11 мин чтения2614 слов

Источники: 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 ничего не говорит о общем объёме памяти приложения, а снятие компонента само по себе не освобождает никаких нативных ресурсов, с которыми он работал. Учитывание этого разделения является основой для любой диагностики ниже.

Как работают стек вызовов, куча и сбор мусора

Три понятия стоит чётко определить.

  • Стек вызовов отслеживает функции, которые выполняются в данный момент. Каждый вызов добавляет новую запись в стек; возврат из функции удаляет её.
  • Heap — это место, где JavaScript обычно хранит объекты, массивы, функции и строки, срок жизни которых может превышать время одного вызова.
  • Сборщик мусора (GC) ищет данные в heap, к которым больше нет живых ссылок, и освобождает эту память для повторного использования.
  • Небольшой пример поможет проиллюстрировать эту связь:

    function showName(user) {
      return user.name.toUpperCase();
    }
    
    showName({ name: "Ada" });
    

    Во время выполнения метода toUpperCase() на стеке хранятся несколько фреймов: вызов функции showName и метод строки, который она запускает. Сам объект user находится в 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. Кто в данный момент владеет этим ресурсом?
    2. Что поддерживает его существование?
    3. Какой именно вызов освобождает его?

    Если ответ на третий вопрос — «этим займется сборщик мусора», рассматривайте это как предупреждающий сигнал. В случае нативных ресурсов это часто не является безопасным подходом.

    Утечка 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 также позволяет избежать повторной подписки при каждой отрисовке, когда меняется идентичность объекта, но сам эпизод остается прежним.

    Проведение реального анализа использования памяти

    Косвенные замечания вроде «приложение потребляет много памяти» не помогают в действиях. Вместо этого определите один повторяемый сценарий, например: откройте ленту новостей, пролистайте её, откройте десять фотографий, вернитесь назад и повторите всю последовательность пять раз.

    Затем посмотрите, какой закономерности показывают полученные данные:

    • Куча JavaScript увеличивается после каждой итерации цикла: скорее всего, утечка происходит в стороне JavaScript, поэтому начните с создания снимков кучи.
    • Куча JavaScript остается примерно неизменной, в то время как общая память приложения растет: проверьте нативную память с помощью инструментов платформы.

    Диагностика роста кучи JavaScript

    Используйте инструмент Hermes inspector и создавайте снимки кучи там, где это поддерживается вашей версией React Native и настройками сборки. Выберите подозрительный объект в снимке и проследите его путь удержания обратно к корню. Типичные виновники имеют названия вроде listeners, timers, stores, caches, callbacks или модульных синглтонов.

    Будьте осторожны, чтобы не путать его с инструментом профилирования Hermes Sampling, который предназначен в первую очередь для CPU. Он показывает, какой JavaScript был активен, но не объясняет, почему растёт память natивного изображения. Руководство React Native по профилированию с помощью Hermes описывает, как записывать и открывать профиль CPU.

    Диагностика памяти natивного кода в iOS

    Запустите приложение из Xcode на физическом устройстве. Начните с индикатора объема памяти, чтобы быстро оценить тенденции, затем откройте график использования памяти для анализа объектов и связей между ними. Инструменты, в частности шаблон Allocations, позволяют отслеживать распределение памяти в процессе повторных запусков. Документация Apple по памяти в Xcode описывает, как график памяти отображает объекты, их распределение и связи между ними, а также как инструмент Allocations отслеживает использование кучи и виртуальной памяти.

    Если инструменты показывают постоянный рост использования памяти при обработке большого количества фотографий, в то время как объем памяти Hermes практически не меняется, прекратите поиски проблемы в JavaScript-замыканиях — проблема находится в нативном коде.

    Диагностика использования памяти в нативном коде на Android

    Используйте инструмент анализа памяти Android Studio на реальном устройстве, чтобы отслеживать использование памяти приложением со временем. Его встроенный механизм анализа памяти, появившийся с Android Studio 4.1, фиксирует операции выделения и освобождения памяти на уровне нативного кода, а также обобщает информацию о соответствующих объектах.

    Интерфейсы инструмента анализа памяти меняются в зависимости от версии Android Studio, но сам метод остается прежним: он фиксирует последовательность действий, отмечает начало каждой петли и выявляет ситуации, когда использование памяти растёт без возвращения к стабильному уровню.

    Чек-лист для очистки кода на каждом экране

    Что бы ни начиналось на экране, его очистка должна происходить в конце. Быстрый аудит включает в себя:

    • Очистку таймера, который был запущен.
    • Удаление добавленных слушателей событий.
    • Отмену подписки на нативные сервисы.
    • Остановку и закрытие камеры, плеера, работы с файлами или курсора базы данных.
    • Установку лимита размера или правил удаления данных из кэша.
  • Большие изображения приводят к уменьшению размера исходных файлов, а также ограничивают количество отображаемых версий.
  • Это не ручное управление памятью в смысле языка C. Вы не освобождаете произвольные байты; вы просто завершаете работу с ресурсами в тот момент, когда ваше приложение перестаёт ими владеть.

    Заключение

    Сборка мусора никогда не обещала, что всё, с чем взаимодействовал компонент, исчезнет при его демонтаже. Когда объём памяти увеличивается, задайте два вопроса в следующем порядке: происходит ли рост в JavaScript-хеепе или в нативном процессе, и кто всё ещё владеет тем объектом, объём которого растёт?

    Ответ напрямую указывает на решение проблемы. Постоянно выполняющийся обратный вызов означает наличие подписки, которая так и не была удалена. Нативная сессия свидетельствует об отсутствии функции close(). Битмап размером 48 МБ говорит о необходимости использования более мелкого изображения или оптимизации кэша. Ошибки памяти в React Native перестают быть загадкой, как только вы перестаете ждать работы механизма сбора мусора и начинаете находить источник проблемы, а затем прекращаете его использование.

    На протяжении всего процесса анализируйте приложение на реальных устройствах. Симулятор подходит для воспроизведения определенных сценариев, но он может скрывать проблемы с памятью, из-за которых приложение заканчивает работу на телефоне пользователя.