Нестабильные зависимости useEffect: диагностика истощения заряда батареи в React Native
Узнайте, как зависимость от объекта и отсутствие массива зависимостей привели к быстрому разряду батареи и тормозам при отображении карты в React Native, как провести профилирование, а также о трёх решениях, которые сработали.
Экран в реальном времени, который выглядит идеально в симуляторе, может всё равно поступать с багом, вызывающим перегрев телефонов и сбои в анимациях, причём при этом не фиксируется ни одной ошибки. Обычной причиной является функция 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} />;
}
Два независимых проблемных участка находились один поверх другого.
orderполучал новый идентификатор объекта каждый раз при отрисовке родительского элемента. В реальном приложении этот идентификатор поступал от хука, расположенного выше в иерархии, который каждый раз создавал новый объект с необходимыми свойствами. (В упрощенном примере показано, что идентификатор поступает отuseState, который на самом деле сохраняет стабильную ссылку; рассматривайте эту строку как заменитель вышеупомянутого хука.) Поскольку эффект подписки указывалorderкак зависимость, React выполнял операцию очистки и снова подписывался на сокет местоположения после каждой отрисовки, а не только тогда, когда значениеorderдействительно менялось.
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предназначена именно для выявления подобных ошибок. Отключение этой функции без понимания предупреждения приводит к тому, что такие проблемы попадают в продакшн-версию; лучше устранить нестабильность. - Анализируйте состояние экранов в режиме ожидания, а не только при взаимодействии с ними. Эта ошибка возникала только тогда, когда никто не взаимодействовал с экраном, что именно то состояние, которое при ручном тестировании часто упускается из виду.
Если вы хотите углубиться в аспекты отрисовки, наш обзор распространённых паттернов, вызывающих ненужные повторные отрисовки в React рассматривает связанные с этим проблемы.
Заключение
Немногие хуки так легко писать, как useEffect, и немногие из них так легко приводят к скрытым ошибкам. На экранах в реальном времени ошибка в зависимостях приводит не только к появлению дополнительной строки в логах: она может удерживать процесс в активном состоянии, перегружать поток JavaScript и заставлять приложение работать некорректно без видимых ошибок. Стабильные зависимости, явные массивы и анализ состояния бездействия — это простые привычки, которые предотвращают самую серьезную форму этой ошибки.
Связанные статьи
- Утечки памяти в React Native: отслеживание стека JS и владельцев нативной памяти — Узнайте, почему сбор мусора не может спасти приложение React Native от утечек нативной памяти, и как выяснить, что удерживает в живом состоянии функции-обратные вызовы, объекты JSI и декодированные изображения.