Головна / Статті / Нестабільні залежності 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: Вимірювання реального впливу на акумулятор та процесор

    Нарешті, Profiler в 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. Оголошувати залежності від кожного ефекту

    Виключайте елемент з масиву лише тоді, коли ви справді хочете, щоб ефект виконувався після кожного оновлення, що трапляється рідко. Тут значення ETA слід перераховувати, коли змінюється місцезнаходження водія:

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

    Строго кажучи, ефект також читає значення order, тому правило лінтингу react-hooks/exhaustive-deps вимагатиме його наявності в масиві. Як тільки order буде збережений у пам’яті (у наступному виправленні), його додавання буде безпечним та збереже точність ефекту, адже він буде виконуватися знову лише тоді, коли порядок справді зміниться. Виключення значення, яке читає ефект, створює ризик обчислення ETA на основі застарілих даних.

    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 для вирішення проблеми.