Strona główna / Artykuły / Niestabilne zależności useEffect: diagnozowanie wyczerpywania baterii w React Native

Niestabilne zależności useEffect: diagnozowanie wyczerpywania baterii w React Native

Zobacz, jak zależność od obiektu oraz brakujący array zależności spowodowały szybsze zużycie baterii i problemy z renderowaniem w React Native, jak to skontrolować oraz trzy rozwiązania, które zadziałały.

2423 słów

Ekran w czasie rzeczywistym, który wygląda doskonale w symulatorze, może nadal zawierać błąd powodujący przegrzewanie telefonów i zatrzymania animacji, przy czym nie jest rejestrowany żaden błąd. Zwykłym winowajcą jest funkcja useEffect, której zależności zmieniają się znacznie częściej niż dane, którymi się zajmuje. Ten przypadek badawczy pokazuje takiego błąd na żywym ekranie śledzenia dostaw: dlaczego istnieje useEffect, kiedy jest odpowiednim narzędziem, jak dwa małe błędy w zależnościach doprowadziły do problemów w pętli renderowania, jak problem został zlokalizowany za pomocą narzędzi do profilowania zarówno w JavaScript, jak i w kodzie natywnym, oraz trzy zmiany, które go naprawiły.

Dlaczego istnieje useEffect

Przed React 16.8 komponenty klasowe rozpraszaly efekty uboczne, takie jak wywołania API, subskrypcje i timerzy, między trzema oddzielnymi metodami cyklu życia: componentDidMount, componentDidUpdate oraz componentWillUnmount. Jedna logiczna kwestia, na przykład „nasłuchiwać tego połączenia, dopóki ekran jest wyświetlany”, była zazwyczaj rozdzielana pomiędzy wszystkie te metody, co powodowało rozproszenie powiązanego kodu i ułatwiało zapomnienie o jakimś kroku.

Hooks pojawiły się w React 16.8, a useEffect został stworzony po to, aby zjednoczyć tę logikę. Zamiast myśleć o etapach cyklu życia, opisuje się w nim, jak komponent pozostaje zsynchronizowany z zewnętrznym systemem – czy to żądanie, natywny słuchacz, subskrypcja, timer czy klatka animacji. Efekt ten jest wykonywany po renderowaniu, może zwracać funkcję do czyszczenia i jest ponownie uruchamiany za każdym razem, gdy zmienia się wartość w jego tablicy zależności:

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

W React Native ten hook pojawia się ciągle, ponieważ niemal cała praca wykonywana poza procesem renderowania jest uważana za efekt uboczny: słuchacze AppState i NetInfo, zdarzenia Keyboard, nadzorcy lokalizacji, połączenia WebSocket oraz natywne SDK do obsługi kamer czy Bluetooth.

Dlaczego warto przestrzegać określonych zasad

Efekty uboczne wymagają miejsca na wykonywanie się po zakończeniu renderowania oraz miejsca na ich usunięcie przed rozmontowaniem komponentu lub przed ponownym uruchomieniem efektu. Bez takiej struktury dochodzi do wycieków słuchaczy, duplikacji subskrypcji oraz użycia przestarzałych zamykaczy, a te problemy kosztują znacznie więcej niż useEffect, gdy jest używany prawidłowo.

Kiedy stosować useEffect, a kiedy nie

Dobrymi przykładami użycia w aplikacji React Native są:

  • słuchanie lokalnych źródeł zdarzeń, takich jak AppState, NetInfo, Keyboard lub Dimensions
  • uruchamianie timerów, interwałów lub pętli animacji tylko wtedy, gdy ekran jest widoczny
  • synchronizowanie lokalnego stanu z właściwościami lub globalnym magazynem po zakończeniu renderowania
  • ładowanie danych przy uruchomieniu aplikacji lub ponownie, gdy zmienia się dane wejściowe, np. identyfikator aktualnego użytkownika
  • kierowanie działaniem modułu natywnego w sposób imperatywny, na przykład włączanie/wyłączanie śledzenia lokalizacji, skanowania BLE lub strumienia z kamery

Sytuacje, w których efekt nie jest odpowiednim narzędziem:

  • Pobieranie wartości z właściwości lub stanu. Lepiej obliczyć ją podczas renderowania.
  • Reagowanie na działanie użytkownika, np. naciśnięcie przycisku. Obsłużyć to w obsłudze zdarzeń, a nie w efekcie, który monitoruje zmiany stanu.
  • „Czekanie” na aktualizację stanu. Zazwyczaj oznacza to, że dwa oddzielne wartości stanu powinny zostać połączone.
  • Pominięcie tablicy zależności lub poleganie na obiekcie lub tablicy tworzonej na nowo przy każdym renderowaniu. To dokładnie pułapka opisana w dalszej części tego artykułu.
  • Aby dowiedzieć się więcej na temat tego antypatrónu synchronizacji, przeczytaj nasz artykuł o dlaczego synchronizacja stanu z useEffect jest ryzykowna.

    Błąd: ekran śledzenia dostawy, który wyczerpał baterię

    Wyobraź sobie ekran w czasie rzeczywistym do śledzenia dostawy: mapę z aktualizowaną w czasie rzeczywistym lokalizacją kierowcy, podobnie jak w aplikacji do dostawy jedzenia. Działało to w symulatorze, przeszło testy jakościowe na dwóch lub trzech urządzeniach i zostało wypuszczone. Około dwa tygodnie później zaczęły napływać zgłoszenia obsługi klienta:

    • W systemie Android użytkownicy donosili, że telefon nagrzewał się, a poziom baterii spadał o około 15% w ciągu 20 minut od utrzymywania ekranu śledzenia włączonego.
    • W systemie iOS użytkownicy zauważyli problemy z mapą: punkt odnoszący się do kierowcy przeskakiwał między pozycjami zamiast poruszać się płynnie, a przewijanie było powolne.

    Okało się, że te dwa różne objawy mają wspólną przyczynę.

    Komponent w uproszczeniu

    Oto uproszczona wersja tego ekranu. Przechowuje on lokalizację kierowcy oraz status zamówienia, nawiązuje połączenie typu socket, subskrybuje się na aktualizacje lokalizacji w jednym efekcie i ponownie oblicza czas przybycia w innym. To właśnie te dwie linie wskazują na miejsce błędu:

    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} />;
    }
    

    Dwa niezależne problemy zostały nałożone jeden na drugi.

    1. order otrzymywał nową tożsamość obiektu za każdym razem, gdy renderowano rodzica. W rzeczywistym aplikacji pochodziła ona od hooka znajdującego się wyżej w drzewie, który za każdym razem przekazywał właściwości do nowego obiektu. (Uproszczony fragment pokazuje, że pochodzi ona od useState, który w rzeczywistości zachowuje stabilny referencję; traktuj tę linię jako zamiennik hooka znajdującego się wyżej w łańcuchu.) Ponieważ efekt subskrypcji wymienił order jako zależność, React wykonywał operację czyszczenia i ponownie subskrybował się na sokiet lokalizacji po każdym renderze, a nie tylko wtedy, gdy rzeczywiście zmienił się stan zamówienia.
  • Efekt ETA w ogóle nie miał tablicy zależności. Efekt bez takiej tablicy jest wykonywany po każdym renderowaniu, włączając te wywołane przez setDriverLocation w ramach pierwszego efektu. W tej aplikacji obliczanie ETA powodowało również aktualizację stanu w innych miejscach, co tworzyło pętlę: aktualizacja lokalizacji, ponowne renderowanie, efekt ETA, kolejna aktualizacja stanu, kolejne renderowanie i tak dalej.
  • Ten sam kod wywoływał różne objawy na poszczególnych platformach. Na Androidzie socket był szybko odłączany i ponownie łączony, co sprawiało, że radio i CPU były zajęte niemal bez przerwy; to było prawdziwe źródło szybkiego rozładowania baterii. Na iOS aktywność radia była regulowana inaczej, ale ciągłe ponowne subskrybowanie i cykl renderowania nadal obciążał wątek JavaScript i zmuszał mapę do częstszego niż to konieczne ładowania warstwy znaczników, co użytkownicy odczuwali jako problemy z działaniem aplikacji.

    Dodatkowa uwaga dotycząca tego fragmentu: useState(fetchOrderSync(orderId)) wywołuje funkcję fetchOrderSync przy każdym renderowaniu, mimo że React używa jej wyniku tylko raz. Jeśli wartość początkowa jest obciążająca, należy zamiast tego przekazać funkcję, jak w useState(() => fetchOrderSync(orderId)), aby została ona wywołana tylko raz.

    Jak zdiagnozowano problem

    Krok 1: Potwierdzenie, że dochodzi do ponownego renderowania, a nie błędu w bibliotece map

    Pierwszym naturalnym podejrzanym był SDK do map. Profiler w React DevTools, który łączy się z aplikacją React Native przez tę samą sieć Metro używaną podczas rozwoju, szybko wykluczył tę możliwość. Zespół nagrał profil na 10 sekund, gdy na ekranie wyświetlano stronę śledzenia bez żadnej interakcji.

    Nagranie pokazało, że drzewo komponentów wykonywało dziesiątki renderów na sekundę, podczas gdy serwer wysyłał nową lokalizację sterownika mniej więcej co 3 do 5 sekund. Ta niespójność była pierwszym prawdziwym wskazówką. Zasadniczo częstotliwość renderowania powinna odpowiadać istotnym zmianom danych; gdy liczba renderów znacznie przewyższa liczbę aktualizacji, coś sztucznie je wyzwaluje.

    Krok 2: Dowiedz się, dlaczego renderuje ponownie

    W uporządkowanej widoku Profilera wymienione zostały TrackingScreen i Map, które kolejno, raz po drugim, wykonywały renderowanie. Aby zobaczyć dokładny powód, tymczasowo dodano małą bibliotekę do debugowania why-did-you-render. Rejestruje ona, która zmiana właściwości lub stanu spowodowała dany render, i poinformowała o następującym:

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

    Część „deep equal: true” była decydującym dowodem. Treść zmiennej order nie uległa żadnej znaczącej zmianie; zmieniła się jedynie jej referencja, ponieważ obiekt był odbudowywany na każdym kroku procesu. React porównuje zależności za pomocą funkcji Object.is, więc obiekt o identycznej strukturze, ale nowo utworzony, zawsze jest uznawany za zmianę.

    Krok 3: Obserwacja strony natywnej

    Profilowanie w JavaScript wyjaśnia proces renderowania, ale nie pokazuje, co robi sprzęt sieciowy urządzenia. Po stronie natywnej użyto Flipper wraz z pluginem Network oraz własnym pluginem do logowania, aby zaobserwować cykl życia WebSocket. Log pokazywał powtarzające się zdarzenia connect i close zaledwie kilka sekund od siebie, zamiast jednego stabilnego połączenia przez cały czas, gdy ekran był otwarty. To potwierdziło, że połączenie było rozrywane za każdym razem, gdy efekt był ponownie uruchamiany.

    Dziurkacz Hermes od Flippera dostarczył kolejne potwierdzenie: punkt przerwy umieszczony w funkcji czyszczenia efektu subskrypcji uruchamiał się znacznie częściej, niż mogłoby to tłumaczyć rzeczywiste odłączenie lub zmiana zamówienia.

    Uwaga dotycząca czasu: nowsze wersje React Native porzuciły Flippera jako narzędzie debugowania domyślne na rzecz React Native DevTools, dlatego sprawdź aktualną dokumentację React Native w celu poznania zalecanej konfiguracji dla Twojej wersji. Ten podejście, polegające na obserwowaniu cyklu życia połączenia i wstawianiu punktów przerwy w funkcjach czyszczenia, jest stosowane niezależnie od wybranych narzędzi.

    Krok 4: Pomiar rzeczywistego wpływu na baterię i CPU

    Na koniec Profiler w Android Studio, wykorzystując widoki CPU i Energia w połączeniu z Flipperem, określił zakres szkód:

    • Przed naprawą: ciągłe zużycie CPU na poziomie około 35–40%, mimo że ekran śledzenia był nieaktywny, a narzędzie do analizy zużycia energii sklasyfikowało aplikację jako silnego konsumenta baterii z powodu ciągłej aktywności radiowej.
    • Po naprawie: zużycie CPU w stanie spoczynku spadło do około 4–6%, a narzędzie do analizy zużycia energii już nie odnotowywało ciągłej aktywności radiowej. Pokazywało jedynie krótkie, okresowe impulsy odpowiadające rzeczywistym interwałom aktualizacji.

    Naprawa: trzy precyzyjne zmiany

    Każda z tych zmian dotyczy jednego ogniwka w tym łańcuchu.

    1. Używanie prymitywów zamiast obiektów

    Subskrypcja musi być restartowana tylko wtedy, gdy zmienia się sam zamówienie, a ciąg znaków orderId dokładnie to identyfikuje. Ponieważ prymitywy porównują się według wartości, pozostają takie same przy każdej renderizacji:

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

    2. Deklarowanie zależności od każdego efektu

    Wyłącz tablicę tylko wtedy, gdy naprawdę chcesz, aby efekt był wykonywany po każdym renderowaniu, co zdarza się rzadko. W tym przypadku czas dotarcia powinien być przeliczany, gdy zmienia się lokalizacja kierowcy:

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

    Ściśle mówiąc, efekt odczytuje również wartość order, więc reguła lintingu react-hooks/exhaustive-deps zażąda jej w tablicy. Gdy order zostanie zmemorizowany (w następnym poprawkowaniu), dodanie go jest bezpieczne i zapewnia wierność efektu, ponieważ będzie on wykonywany ponownie tylko wtedy, gdy rzeczywiście zmieni się kolejność. Pominięcie wartości, którą efekt odczytuje, niesie ryzyko obliczania czasu dotarcia na podstawie przestarzałych danych.

    3. Zmemorizuj obiekt order na wcześniejszym etapie

    Na koniec ustabilizuj obiekt w miejscu jego tworzenia, tak aby jego referencja zmieniała się tylko wtedy, gdy zmienią się istotne pola:

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

    Należy starannie przemyśleć tę listę zależności: skoro zawiera ona jedynie id i status, zmiana jakiegokolwiek innego pola w rawOrderData, np. adresu dostawy, nie spowoduje utworzenia nowego order. Jest to poprawne tylko wtedy, gdy nic poniżej nie zależy od tych innych pól.

    Gdy wprowadzono wszystkie trzy zmiany – połączenie sokietowe utrzymywane było tylko raz na wizytę na ekranie, czas dostawy przeliczany był wyłącznie wtedy, gdy lokalizacja faktycznie się zmieniła, a szybkość renderowania spadła z kilkudziesięciu razy na sekundę do około jednego razu co kilka sekund, odpowiadając rzeczywistemu przepływowi danych.

    Lekcje dla zespołów React Native

    • Zakładaj, że zależności od obiektów i tablic są niestabilne. Chyba że stworzyłeś je za pomocą useMemo lub useCallback, oczekuj nowego odniesienia przy każdym renderowaniu. Wolij zależności prymitywne, takie jak ID, gdy dobrze oddają one zamierzenie.
    • Zawsze zapisuj tablicę zależności i nie wyłączaj reguły lintingu. Funkcja react-hooks/exhaustive-deps została stworzona właśnie po to, by wykrywać tego typu błędy. Wyłączanie jej bez zrozumienia ostrzeżenia powoduje, że takie problemy trafiają do wersji produkcyjnej; lepiej naprawić niestabilność.
    • Analizuj ekrany w stanie bezczynności, a nie tylko interakcje. Ten błąd pojawił się tylko wtedy, gdy nikt nie dotykał ekranu, co jest dokładnie tym stanem, który przy testowaniu ręcznym często pomija się.
  • Wyczerpywanie baterii i problemy z działaniem aplikacji mogą wynikać z tego samego błędu. Zachowanie radia i procesora w Androidzie sprawia, że staje się to problemem z baterią, natomiast mechanizm renderowania w iOS przekształca tę samą przyczynę w problemy z działaniem aplikacji.
  • Należy sprawdzić obie części aplikacji. Profiler JavaScript pokazuje procesy renderowania oraz ich przyczyny; narzędzia typu native pokazują połączenia, wykorzystanie radia oraz zużycie energii. Taki błąd zazwyczaj wymaga sprawdzenia obu tych aspektów, aby móc go z pewnością zdiagnozować.
  • Jeśli chcesz zgłębić temat renderowania, naszy przegląd powszechnych wzorców powodujących niepotrzebne ponowne renderowania w React omawia powiązane z tym problemy.

    Podsumowanie

    Niewiele hooków jest tak łatwych do napisania jak useEffect, a niewiele z nich jest tak podatnych na błędy. Na ekranach w czasie rzeczywistym błąd zależności nie powoduje tylko dodatkowej linii w logu – może utrzymywać proces w aktywnym stanie, przeciążyć wątek JavaScript i sprawić, że aplikacja będzie działać niewłaściwie bez widocznego błędu. Stabilne zależności, wyraźne tablice oraz analiza stanów bezczynnych to proste nawyki, które zapobiegają najdroższym konsekwencjom tego błędu.

    Literatura pokrewna

  • Budowanie czytelnika PDF opornego na błędy dla dużych dokumentów w React Native — Poznaj architekturę do renderowania ogromnych PDF-ów składających się z tysięcy stron w React Native, wraz z systemem odłożonego renderowania zakładek, funkcją wyszukiwania oraz stabilną nawigacją na urządzeniach o niskich parametrach.
  • Flutter vs React Native: Decyzje, które pojawiają się tylko w produkcji — Realistyczne porównanie Flutter i React Native, które pomija walki o wyniki testów i koncentruje się na renderowaniu, języku programowania, stanie aplikacji, listach oraz ekosystemie w miarę jej rozwoju.
  • Planowanie aktualizacji do Expo SDK 58: iOS 27, React Native 0.88 i nowe narzędzia — Praktyczny przegląd wersji beta Expo SDK 58: jakie zmiany dotyczą iOS 27 i React Native 0.88, które funkcje są eksperymentalne oraz jak bezpiecznie przetestować tę aktualizację.
  • Wybór odpowiedniego narzędzia React: Derive, Handle, Fetch, Defer czy Effect — Przewodnik decyzyjny dotyczący zastępowania refleksywnych wywołań useEffect wartościami pochodnymi, obsługą zdarzeń, warstwą danych, funkcją useTransition, zaimplementowanym useMemo oraz API React 19.
  • Analiza ścieżek wydajności React-u w Chrome DevTools w celu znalezienia wolnego renderowania — Dowiedz się, jak ścieżki Scheduler, Components i Server dodane w React 19.2 pokazują, gdzie traci czas wolna interakcja, oraz jak wykorzystać proces record-fix-record do podjęcia działań w tej sprawie.