Главная / Статьи / Нестабильные зависимости useEffect: диагностика истощения заряда батареи в React Native

Нестабильные зависимости useEffect: диагностика истощения заряда батареи в React Native

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

2423 слов

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

Зачем существует useEffect

До React 16.8 компоненты классов реализовывали побочные эффекты, такие как вызовы API, подписки и таймеры, в трех отдельных методах жизненного цикла: componentDidMount, componentDidUpdate и componentWillUnmount. Например, задача вроде «слушать этот сокет пока экран отображается» обычно реализовывалась во всех трех методах, что приводило к разбросу связанного кода и упрощало забывание какого-либо шага.

В React 16.8 появились хуки, и useEffect был создан для объединения этой логики. Вместо того чтобы размышлять о стадиях жизненного цикла, вы описываете, как компонент поддерживает синхронизацию с внешней системой — будь то запрос, нативный слушатель, подписка, таймер или кадр анимации. Эффект выполняется после отрисовки, может возвращать функцию для очистки и снова выполняется каждый раз, когда меняется значение в массиве зависимостей:

useEffect(() => {
  // side effect code
return () => {
    // cleanup code
  };
}, [dependencies]);

В React Native глухарь используется постоянно, поскольку практически все операции, не связанные с отрисовкой, считаются побочными эффектами: слушатели AppState и NetInfo, события Keyboard, мониторы местоположения, соединения WebSocket, а также нативные SDK для камеры или Bluetooth.

Почему это стоит усилий

Побочным эффектам нужно место для выполнения после завершения отрисовки и место для очистки перед демонтированием компонента или до повторного выполнения эффекта. Без такой структуры возникают утечки слушателей, дублирование подписок и устаревшие замыкания, причем эти проблемы стоят гораздо дороже, чем использование useEffect правильно.

Когда использовать useEffect, а когда нет

Хорошие примеры использования в приложении React Native включают:

  • прослушивание встроенных источников событий, таких как AppState, NetInfo, Keyboard или Dimensions
  • запуск таймеров, интервалов или циклов анимации только тогда, когда экран виден
  • синхронизация локального состояния с параметрами или глобальным хранилищем после завершения отрисовки
  • загрузка данных при инициализации компонента или снова, когда меняется какой-либо ввод от пользователя, например ID текущего пользователя
  • императивное управление встроенным модулем, например включение/выключение отслеживания местоположения, сканирования по технологии BLE или потока с камеры

Ситуации, в которых использование эффектов является неправильным решением:

  • Вычисление значения на основе параметров или состояния. Лучше выполнять это во время отрисовки.
  • Реагирование на действие пользователя, такое как нажатие кнопки. Обрабатывайте это в обработчике событий, а не в эффекте, который отслеживает изменения состояния.
  • «Ожидание» обновления состояния. Обычно это означает, что два отдельных значения состояния должны быть объединены.
  • Игнорирование массива зависимостей или использование объекта/массива, создаваемого заново при каждом отрисовывании. Именно это и является ловушкой, описанной далее в этой статье.
  • Для более подробного рассмотрения антипаттерна синхронизации ознакомьтесь с нашей статьей почему синхронизация состояния с useEffect опасна.

    Ошибка: экран отслеживания, исчерпавший батарею

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

    • На Android пользователи отмечали, что телефон нагревался, а заряд батареи снижался примерно на 15% в течение 20 минут после активации экрана отслеживания.
    • На iOS пользователи говорили, что карта работала с перебоями: метка водителя прыгала между позициями вместо того, чтобы плавно перемещаться, а прокрутка была медленной.

    Оказалось, что у этих двух разных симптомов есть одна общая причина.

    Компонент в упрощенном виде

    Вот упрощенная версия экрана. Он сохраняет в состоянии местоположение водителя и информацию о заказе, устанавливает соединение через сокет, подписывается на обновления местоположения в одном эффекте и пересчитывает прогнозируемое время прибытия в другом. Проблемы возникали именно на этих двух участках кода:

    function TrackingScreen({ orderId }) {
      const [driverLocation, setDriverLocation] = useState(null);
      const [order, setOrder] = useState(fetchOrderSync(orderId)); // returns a new object reference
      const socket = useMemo(() => connectSocket(), []); // looked memoized, wasn't the issue
    
      useEffect(() => {
        const subscription = LocationSocket.on('update', (loc) => {
          setDriverLocation(loc);
        });
        return () => subscription.remove();
      }, [order]); // 🚩 the bug
      useEffect(() => {
        console.log('Recalculating ETA...');
        calculateETA(order, driverLocation);
      }); // 🚩 no dependency array at all
      return <Map driverLocation={driverLocation} order={order} />;
    }
    

    Два независимых проблемных участка находились один поверх другого.

    1. order получал новый идентификатор объекта каждый раз при отрисовке родительского элемента. В реальном приложении этот идентификатор поступал от хука, расположенного выше в иерархии, который каждый раз создавал новый объект с необходимыми свойствами. (В упрощенном примере показано, что идентификатор поступает от useState, который на самом деле сохраняет стабильную ссылку; рассматривайте эту строку как заменитель вышеупомянутого хука.) Поскольку эффект подписки указывал order как зависимость, React выполнял операцию очистки и снова подписывался на сокет местоположения после каждой отрисовки, а не только тогда, когда значение order действительно менялось.
  • Эффект ETA совсем не имел массива зависимостей. Эффект без такого массива выполняется после каждой перерисовки, включая те, которые запускаются функцией setDriverLocation внутри первого эффекта. В этом приложении расчёт ETA также приводил к обновлению состояния в других частях кода, что создавало замкнутый цикл: обновление местоположения, перерисовка, действие эффекта ETA, ещё одно обновление состояния, ещё одна перерисовка и так далее.
  • Тот же код проявлял разные симптомы в зависимости от платформы. На Android сокет постоянно разрывался и восстанавливался с высокой частотой, из-за чего радио и процессор оставались загруженными практически непрерывно; именно это было основной причиной быстрого разряда батареи. На iOS активность радио регулировалась иначе, но постоянный цикл повторной подписки и перерисовки всё равно сильно нагружал поток JavaScript, из-за чего карта гораздо чаще, чем это необходимо, перезагружала свой слой маркеров, что пользователи воспринимали как сбои в работе приложения.

    Примечание к приведённому фрагменту: useState(fetchOrderSync(orderId)) вызывает функцию fetchOrderSync при каждой перерисовке, хотя React использует её результат только в первый раз. Если получение начального значения занимает много времени, следует передать функцию вместо этого значения, как в useState(() => fetchOrderSync(orderId)), чтобы она выполнялась лишь один раз.

    Как была диагностирована проблема

    Шаг 1: Проверка на перерисовки, а не проблемы с библиотекой map

    Первым подозреваемым, как правило, является SDK для работы с картами. Однако профилировщик React DevTools, соединяющийся с приложением React Native через ту же сеть Metro, что и во время разработки, быстро исключил эту возможность. Команда сделала профиль за 10 секунд, пока на экране отображалась страница отслеживания без какой-либо интеракции.

    В записи было видно, что дерево компонентов выполняло десятки рендеров в секунду, тогда как бэкенд отправлял новое местоположение драйвера примерно каждые 3–5 секунд. Именно это несоответствие стало первой настоящей зацепкой. Как правило, частота рендеров должна соответствовать значимым изменениям данных; когда количество рендеров значительно превышает количество обновлений, что-то искусственно их запускает.

    Шаг 2: Выяснить, почему происходит повторный рендер

    В отсортированном виде профиля было указано, что TrackingScreen и Map постоянно выполняются один за другим. Чтобы увидеть точную причину, была временно добавлена небольшая библиотека для отладки why-did-you-render. Она фиксирует, какое изменение свойства или состояния вызвало каждый рендер, и показала следующее:

    TrackingScreen re-rendered because of changed props: order
    order: Object !== Object (deep equal: true)
    

    Факт «deep equal: true» стал решающим доказательством. Содержимое поля order не менялось никаким значимым образом; изменялась лишь его ссылка, поскольку объект пересоздавался на предыдущих этапах обработки. React сравнивает зависимости с помощью функции Object.is, поэтому структурно идентичный, но новый объект всегда считается изменением.

    Шаг 3: Наблюдение за нативной частью

    Профилирование JavaScript объясняет процесс отрисовки, но не показывает, что делается с сетевым оборудованием устройства. Для наблюдения за жизненным циклом WebSocket использовался инструмент Flipper вместе с плагином Network и пользовательским плагином для логирования. В логах отображались повторяющиеся события connect и close с интервалом в несколько секунд, а не одно стабильное соединение на протяжении всего времени открытия экрана. Это подтвердило, что сокет разрывался каждый раз при повторном выполнении эффекта.

    Отладчик Hermes от Flipper предоставил ещё одно подтверждение: точка останова, размещённая в функции очистки эффекта подписки, срабатывала гораздо чаще, чем это можно было объяснить реальным отключением компонента или изменением заказа.

    Важное замечание, связанное с временем: в более новых версиях React Native в качестве стандартного инструмента отладки был заменён Flipper на React Native DevTools, поэтому обратитесь к актуальной документации React Native для получения рекомендаций по настройкам для вашей версии. Подход, заключающийся в отслеживании жизненного цикла подключения и остановке выполнения в функциях очистки, применим к любым инструментам, которые вы используете.

    Шаг 4: Оценка реального влияния на аккумулятор и процессор

    Наконец, инструмент профилирования Android Studio с его вкладками CPU и Energy, используемый вместе с Flipper, позволил количественно оценить ущерб:

    • До исправления: уровень использования CPU оставался на уровне примерно 35–40%, хотя экран отслеживания не выполнял никаких действий, а инструмент анализа энергопотребления относил приложение к категории с высоким расходом заряда из-за постоянной радиочастотной активности.
    • После исправления: уровень использования CPU в режиме ожидания снизился примерно до 4–6%, и инструмент анализа энергопотребления больше не фиксировал постоянную радиочастотную активность. Теперь он отображал кратковременные периодические импульсы, совпадающие с реальным интервалом обновлений.

    Исправление: три целенаправленных изменения

    Каждое изменение направлено на устранение одной проблемы в цепочке.

    1. Использование примитивов вместо объектов

    Подписку необходимо перезапускать только тогда, когда меняется сам заказ, и строка orderId точно указывает на это. Поскольку примитивы сравниваются по значению, оно остается неизменным при каждом отрисовывании:

    useEffect(() => {
      const subscription = LocationSocket.on('update', (loc) => {
        setDriverLocation(loc);
      });
      return () => subscription.remove();
    }, [orderId]); // orderId is a primitive string — stable across re-renders
    

    2. Объявление зависимостей для каждого эффекта

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

    useEffect(() => {
      calculateETA(order, driverLocation);
    }, [driverLocation]); // only recalculate when location actually changes
    

    Строго говоря, эффект также читает значение order, поэтому правило проверки кода react-hooks/exhaustive-deps потребует его наличия в массиве. После того как order будет мемоизирован (в следующем исправлении), его добавление будет безопасным и сохранит точность работы эффекта, поскольку он будет выполняться заново только тогда, когда порядок действительно изменится. Игнорирование значения, которое читает эффект, может привести к расчёту времени прибытия на основе устаревших данных.

    3. Мемоизируйте объект order на этапе создания

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

    const order = useMemo(() => buildOrder(rawOrderData), [rawOrderData.id, rawOrderData.status]);
    

    Тщательно подумайте над списком зависимостей: если в нем указаны только id и status, изменение любого другого поля объекта rawOrderData, такого как адрес доставки, не приведет к созданию нового order. Это верно лишь в том случае, если ничто дальше не зависит от этих полей.

    После внесения всех трех изменений — при подключении сокета один раз на каждый визит к экрану, пересчете времени доставки только тогда, когда местоположение действительно менялось, — частота отрисовки снизилась с десятков раз в секунду до примерно одной раз в несколько секунд, что соответствует реальному потоку данных.

    Уроки для команд React Native

    • Считайте, что зависимости от объектов и массивов являются нестабильными. Если вы не создавали их с помощью useMemo или useCallback, ожидайте появления нового ссылочного объекта при каждой отрисовке. Предпочитайте примитивные зависимости, такие как ID, когда это соответствует цели использования.
    • Всегда указывайте массив зависимостей и не игнорируйте правила проверки кода. Функция react-hooks/exhaustive-deps предназначена именно для выявления подобных ошибок. Отключение этой функции без понимания предупреждения приводит к тому, что такие проблемы попадают в продакшн-версию; лучше устранить нестабильность.
    • Анализируйте состояние экранов в режиме ожидания, а не только при взаимодействии с ними. Эта ошибка возникала только тогда, когда никто не взаимодействовал с экраном, что именно то состояние, которое при ручном тестировании часто упускается из виду.
  • Истощение батареи и тормоза могут быть проявлением одной и той же проблемы. Благодаря поведению радиочипа и процессора в Android это приводит к проблемам с зарядом батареи, тогда как в iOS система отрисовки превращает ту же коренную причину в тормоза.
  • Необходимо анализировать обе части приложения. Инструменты профилирования на JavaScript показывают процессы отрисовки и их причины; нативные инструменты отображают соединения, использование радиочипа и расход энергии. Для точной диагностики подобных проблем обычно требуется анализ обоих аспектов.
  • Если вы хотите углубиться в аспекты отрисовки, наш обзор распространённых паттернов, вызывающих ненужные повторные отрисовки в React рассматривает связанные с этим проблемы.

    Заключение

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

    Связанные статьи

  • Создание надежного читера PDF для больших документов в React Native — Изучите архитектуру для отображения огромных PDF с тысячами страниц в React Native, включая механизмы отложенной загрузки закладок, поиск и стабильную навигацию на устройствах с ограниченными ресурсами.
  • Flutter против React Native: решения, которые проявляются только в производственных условиях — Практическое сравнение Flutter и React Native, в котором не рассматриваются тесты на производительность, а акцент делается на отображении контента, языке программирования, управлении состоянием, списках и экосистеме при развитии приложения.
  • Планирование обновления Expo SDK 58: iOS 27, React Native 0.88 и новые инструменты — Практический обзор бета-версии Expo SDK 58: какие изменения принесены для iOS 27 и React Native 0.88, какие функции являются экспериментальными и как безопасно протестировать обновление.
  • Выбор подходящего инструмента для React: Derive, Handle, Fetch, Defer или Effect — Руководство по принятию решений при замене рефлексивных вызовов useEffect на производные значения, обработчики событий, слой данных, useTransition, measured useMemo и API React 19.
  • Анализ трекеров производительности React в Chrome DevTools для выявления медленной отрисовки — Узнайте, как трекеры Scheduler, Components и Server, добавленные в React 19.2, показывают, где тратится время на медленные операции, а также как использовать работу в режиме record-fix-record для принятия мер по устранению проблем.