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.
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,KeyboardlubDimensions - 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.
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.
orderotrzymywał 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 oduseState, 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łorderjako 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.
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ą
useMemolubuseCallback, 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-depszostał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ę.
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
- Wycieki pamięci w React Native: śledzenie stosu JS i właścicieli pamięci natywnej — Dowiedz się, dlaczego zbieranie śmieci nie może uratować aplikacji React Native przed wyciekami pamięci natywnej, oraz jak odkryć, co utrzymuje w życiu funkcje zwrotne, obiekty JSI i odszyfrowane obrazy.