Instabile useEffect-Abhängigkeiten: Diagnose von Akkulaufzeitproblemen in React Native
Sehen Sie, wie eine Objektabhängigkeit sowie ein fehlendes Abhängigkeitsarray zu Akkulaufzeitverlust und Ruckeln in React Native führten, wie man dies profilieren kann, sowie die drei funktionierenden Lösungen.
Ein Echtzeit-Bildschirm, der im Simulator perfekt aussieht, kann dennoch mit einem Fehler ausgeliefert werden, der Handys überhitzt und Animationen zum Stocken bringt – ohne dass dabei ein einziger Fehler protokolliert wird. Der übliche Übeltäter ist ein useEffect, dessen Abhängigkeiten weitaus häufiger ändern als die Daten, um die es geht. Diese Fallstudie zeigt einen solchen Fehler auf einem Live-Tracking-Bildschirm: Warum useEffect existiert, wann es das richtige Werkzeug ist, wie zwei kleine Fehler bei den Abhängigkeiten zu einem Render-Loop führten, wie das Problem mithilfe von Profilverfahren auf der JavaScript- sowie der nativen Seite aufgespürt wurde, und die drei Änderungen, die es behoben haben.
Warum useEffect existiert
Vor React 16.8 verteilten Klassenkomponenten Nebeneffekte wie API-Aufrufe, Abonnements und Timer auf drei separate Lebenszyklusmethoden: componentDidMount, componentDidUpdate und componentWillUnmount. Ein logisches Problem, beispielsweise „auf diesen Socket hören, solange der Bildschirm angezeigt wird“, wurde in der Regel auf alle drei Methoden aufgeteilt, was den zusammenhängenden Code zerstreute und dazu führte, dass leicht ein Schritt vergessen werden konnte.
Hooks kamen mit React 16.8 auf den Markt, und useEffect wurde entwickelt, um diese Logik zu vereinheitlichen. Anstatt über Lebenszyklusphasen nachzudenken, beschreibt man, wie die Komponente mit einem externen System synchron bleibt – egal ob es sich um eine Anfrage, einen nativen Zuhörer, ein Abonnement, einen Timer oder einen Animationsschritt handelt. Der Effekt wird nach dem Render ausgeführt, kann eine Aufräumfunktion zurückgeben und jedes Mal erneut ausgeführt werden, wenn sich ein Wert in seinem Abhängigkeitsarray ändert:
useEffect(() => {
// side effect code
return () => {
// cleanup code
};
}, [dependencies]);
In React Native taucht der Hook ständig auf, da fast alle Aufgaben außerhalb der Renderung als Nebeneffekte gelten: AppState- und NetInfo-Listener, Keyboard-Ereignisse, Standortüberwacher, WebSocket-Verbindungen sowie native SDKs für Funktionen wie die Kamera oder Bluetooth.
Warum Disziplin lohnenswert ist
Nebeneffekte benötigen einen Ort, an dem sie nach Abschluss der Renderung ausgeführt werden können, sowie einen Ort, an dem sie vor dem Entfernen des Components oder vor erneuter Ausführung des Effekts wieder deaktiviert werden. Ohne diese Struktur entstehen leckende Listener, doppelte Abonnements und veraltete Schließfunktionen – Probleme, die weitaus teurer sind als die Nutzung von useEffect, wenn es richtig angewendet wird.
Wann useEffect verwendet werden sollte und wann nicht
Gute Anwendungsfälle in einer React Native-App sind unter anderem:
- Das Anhören nativer Ereignisquellen wie
AppState,NetInfo,KeyboardoderDimensions - Das Ausführen von Timern, Intervallen oder Animationsschleifen nur dann, wenn ein Bildschirm sichtbar ist
- Das Abstimmen des lokalen Zustands auf eine Eigenschaft oder einen globalen Speicher, sobald die Darstellung abgeschlossen ist
- Das Laden von Daten beim Initialisieren oder erneut, wenn eine Eingabe wie die ID des aktuellen Benutzers sich ändert
- Das imperative Steuern eines nativen Moduls, beispielsweise das Ein- und Ausschalten der Standortverfolgung, von BLE-Scans oder eines Kamerastrums
Situationen, in denen ein Effect das falsche Werkzeug ist:
- Das Ableiten eines Wertes aus Eigenschaften oder Zuständen. Berechnen Sie ihn stattdessen während der Darstellung.
- Die Reaktion auf eine Benutzeraktion wie einen Buttonklick. Behandeln Sie dies im Ereignishandler, nicht in einem Effect, der auf Zustandsänderungen wartet.
Eine ausführlichere Behandlung dieses Anti-Patterns zur Synchronisierung finden Sie in unserem Artikel warum die Synchronisierung des Zustands mit useEffect riskant ist.
Der Fehler: Ein Tracking-Interface, das die Batterie leerfraß
Stellen Sie sich ein Live-Tracking-Interface für Lieferungen vor: Eine Karte, auf der die Position des Fahrers in Echtzeit aktualisiert wird – ähnlich wie bei einer Essenslieferungs-App. Es funktionierte im Simulator, bestand die Qualitätskontrolle auf zwei oder drei Geräten und wurde veröffentlicht. Etwa zwei Wochen später trafen erste Support-Anfragen ein:
- Auf Android berichteten Nutzer, dass das Telefon heiß wurde und die Akkuleistung innerhalb von 20 Minuten nach dem Anzeigen des Tracking-Bildschirms um etwa 15 % abnahm.
- Auf iOS berichteten Nutzer, dass die Karte ruckelte: Der Fahrermarker sprang zwischen Positionen hin und her, anstatt sich gleichmäßig zu bewegen, und das Scrollen war träge.
Zwei verschiedene Symptome hatten letztendlich denselben Ursprung.
Die Komponente, vereinfacht
Hier ist eine vereinfachte Version des Bildschirms. Sie speichert die Position des Fahrers sowie den Auftrag im Zustand, erstellt eine Socket-Verbindung, abonniert in einem Effekt Standortaktualisierungen und berechnet in einem anderen Effekt die voraussichtliche Ankunftszeit neu. Die beiden markierten Zeilen zeigen an, wo es zu Problemen kam:
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} />;
}
Zwei unabhängige Probleme lagen übereinander.
ordererhielt jedes Mal eine neue Objektidentität, wenn der Elternteil gerendert wurde. In der echten Anwendung stammte diese von einem Hook weiter oben im Baum, der jedes Mal Eigenschaften in ein neues Objekt übertrug. (Der vereinfachte Auszug zeigt, dass sie vonuseStatestammt, was eigentlich eine stabile Referenz beibehalten würde; betrachten Sie diese Zeile als Ersatz für den upstream Hook.) Da der Subscription-Effektorderals Abhängigkeit aufgelistet hatte, führte React nach jedem Render die Aufräumarbeiten durch und abonnierte sich erneut beim Location-Socket, nicht nur dann, wenn sich der Auftrag tatsächlich änderte.
setDriverLocation innerhalb des ersten Effekts ausgelösten Renderungen. In dieser App führte die ETA-Berechnung außerdem zu einer Zustandsaktualisierung an anderer Stelle, was einen Kreislauf schloss: Standortaktualisierung, erneute Renderung, ETA-Effekt, weitere Zustandsaktualisierung, weitere Renderung und so weiter.Derselbe Code verursachte je nach Plattform unterschiedliche Symptome. Auf Android wurde die Verbindung ständig schnell wiederhergestellt und getrennt, wodurch Radio und CPU fast kontinuierlich beschäftigt blieben; das war die eigentliche Ursache für den Akkustromverbrauch. Auf iOS wurde die Radioaktivität anders begrenzt, doch der ständige Abonnements- und Renderzyklus belastete weiterhin den JavaScript-Thread und ließ die Karte ihren Markerlayer weitaus häufiger neu laden, als nötig wäre – was die Nutzer als Stockungen wahrnahmen.
Eine Anmerkung zum Auszug: useState(fetchOrderSync(orderId)) ruft fetchOrderSync bei jeder Neuzeichnung auf, obwohl React das Ergebnis nur beim ersten Mal verwendet. Wenn der Anfangswert aufwendig zu berechnen ist, sollte stattdessen eine Funktion übergeben werden, wie in useState(() => fetchOrderSync(orderId)), damit sie nur einmal ausgeführt wird.
Wie das Problem diagnostiziert wurde
Schritt 1: Überprüfen, ob es zu Neuzeichnungen kommt und nicht zur Problematik der Karte
Der erste Verdächtige war natürlich das Karten-SDK. Der React DevTools Profiler, der über dieselbe Metro-Verbindung wie bei der Entwicklung mit einer React Native-Anwendung verbunden wird, schloss dies schnell aus. Das Team erstellte ein 10-sekündiges Profil, während der Tracking-Bildschirm ohne jegliche Interaktion angezeigt wurde.
Die Aufzeichnung zeigte, dass der Komponentenbaum Dutzende von Renderungen pro Sekunde ausführte, während die Backend-Plattform nur alle etwa 3 bis 5 Sekunden eine neue Fahrzeugposition sendete. Dieser Unterschied war der erste echte Hinweis. Als Faustregel sollte die Renderfrequenz bedeutungsvollen Datenänderungen folgen; wenn die Anzahl der Renderungen um ein Vielfaches höher ist als die der Updates, wird etwas sie künstlich auslösen.
Schritt 2: Ermitteln, warum er neu gerendert wird
Die sortierte Ansicht des Profilers listete TrackingScreen und Map auf, die nacheinander immer wieder gerendert wurden. Um den genauen Auslöser zu erkennen, wurde vorübergehend die kleine Debugging-Bibliothek why-did-you-render hinzugefügt. Sie protokolliert, welche Eigenschafts- oder Zustandsänderung zu jeder Renderung führte, und berichtete Folgendes:
TrackingScreen re-rendered because of changed props: order
order: Object !== Object (deep equal: true)
Der Teil „deep equal: true“ war der entscheidende Beweis. Der Inhalt von order hatte sich auf keine bedeutungsvolle Weise geändert; nur seine Referenz hatte sich geändert, weil das Objekt bei jedem Durchlauf weiter oben neu erstellt wurde. React vergleicht Abhängigkeiten mit Object.is, wodurch ein strukturell identisches, aber neu erstelltes Objekt stets als Änderung gilt.
Schritt 3: Die native Seite beobachten
JavaScript-Profiling erklärt die Darstellungen, nicht jedoch, was die Netzwerkhardware des Geräts tut. Auf der nativen Seite wurde Flipper mit seinem Network-Plugin sowie einem benutzerdefinierten Logging-Plugin verwendet, um den Lebenszyklus von WebSocket zu überwachen. Die Protokolle zeigten wiederholte connect- und close-Ereignisse, die nur wenige Sekunden voneinander entfernt auftraten, anstatt eine dauerhafte Verbindung solange zu bestehen, wie der Bildschirm geöffnet war. Das bestätigte, dass die Verbindung jedes Mal abgebrochen wurde, wenn der Effekt erneut ausgeführt wurde.
Flipper’s Hermes-Debugger lieferte eine weitere Bestätigung: Ein Breakpoint, der in der Cleanup-Funktion des Abonnement-Effekts platziert wurde, wurde weitaus häufiger ausgelöst, als es durch eine echte Entfernung oder Änderung der Bestellung erklärbar gewesen wäre.
Eine zeitbezogene Einschränkung: Neuere React Native-Versionen haben Flipper als Standard-Debugging-Tool zugunsten der React Native DevTools aufgegeben, daher sollten Sie die aktuelle React Native-Dokumentation prüfen, um die für Ihre Version empfohlene Einrichtung zu finden. Der Ansatz, das Verbindungslebenzyklus zu beobachten und in Cleanup-Funktionen Breakpoints einzufügen, gilt unabhängig von den verwendeten Tools.
Schritt 4: Den tatsächlichen Einfluss auf Akku und CPU messen
Schließlich quantifizierte der Profiler in Android Studio mithilfe seiner CPU- und Energieansichten zusammen mit Flipper den Schaden:
- Vor der Korrektur: eine anhaltende CPU-Nutzung von etwa 35 bis 40 Prozent, obwohl der Überwachungsbildschirm inaktiv war, und der Energieprofiler klassifizierte die App aufgrund ständiger Funkaktivität als starken Akkuverbraucher.
- Nach der Korrektur: die CPU-Nutzung im Inaktivzustand sank auf etwa 4 bis 6 Prozent, und der Energieprofiler meldete keine anhaltende Funknutzung mehr. Stattdessen wurden kurze, periodische Ausbrüche angezeigt, die mit dem tatsächlichen Aktualisierungsintervall übereinstimmten.
Die Korrektur: drei gezielte Änderungen
Jede Änderung behebt ein Problem in der Kette.
1. Verwendung einer Primitivvariablen statt eines Objekts
Die Abonnementfunktion muss nur dann neu gestartet werden, wenn sich die Bestellung selbst ändert – und der orderId-String identifiziert dies genau. Da Primitivvariablen nach ihrem Wert verglichen werden, bleibt dieser bei jeder Darstellung gleich:
useEffect(() => {
const subscription = LocationSocket.on('update', (loc) => {
setDriverLocation(loc);
});
return () => subscription.remove();
}, [orderId]); // orderId is a primitive string — stable across re-renders
2. Abhängigkeiten für jeden Effekt deklarieren
Verzichten Sie auf das Array nur dann, wenn Sie tatsächlich möchten, dass die Auswirkung nach jeder Renderung ausgeführt wird – was selten der Fall ist. Hier sollte die ETA neu berechnet werden, wenn sich die Position des Fahrers ändert:
useEffect(() => {
calculateETA(order, driverLocation);
}, [driverLocation]); // only recalculate when location actually changes
Streng genommen liest die Funktion auch order ein, weshalb die Lint-Regel von react-hooks/exhaustive-deps nach diesem Wert im Array fragt. Sobald order gememorisert wird (im nächsten Fix), ist es sicher, ihn hinzuzufügen – dadurch bleibt die Funktion zuverlässig, da sie nur dann erneut ausgeführt wird, wenn sich die Reihenfolge tatsächlich ändert. Das Auslassen eines Werts, den die Funktion liest, birgt das Risiko, die ETA auf veralteten Daten zu berechnen.
3. Das Order-Objekt weiter oben gememorisieren
Zum Schluss sollten Sie das Objekt dort stabilisieren, wo es erstellt wird, sodass sich seine Referenz nur dann ändert, wenn sich die relevanten Felder ändern:
const order = useMemo(() => buildOrder(rawOrderData), [rawOrderData.id, rawOrderData.status]);
Seien Sie bei dieser Abhängigkeitsliste sorgfältig: Da darin nur id und status enthalten sind, führt eine Änderung eines anderen Feldes von rawOrderData, wie beispielsweise der Lieferadresse, nicht zur Erstellung einer neuen order. Das gilt nur dann, wenn nichts weiter davon abhängt.
Nach Durchführung aller drei Änderungen – wobei die Verbindung über das Socket pro Besuch der Seite nur einmal hergestellt wurde, die ETA nur dann neu berechnet wurde, wenn sich der Standort tatsächlich bewegte, und die Renderrate von Dutzenden pro Sekunde auf etwa eins alle paar Sekunden sank, was dem tatsächlichen Datenfluss entspricht –
Lektionen für React Native-Teams
- Gehen Sie davon aus, dass Abhängigkeiten zu Objekten und Arrays instabil sind. Sofern Sie sie nicht mit
useMemooderuseCallbackerstellt haben, sollten Sie bei jeder Renderung mit einer neuen Referenz rechnen. Verwenden Sie lieber primitive Abhängigkeiten wie IDs, sofern diese den gewünschten Zweck erfüllen. - Schreiben Sie immer die Abhängigkeitsarray und deaktivieren Sie nicht die Lint-Regel.
react-hooks/exhaustive-depsdient genau dazu, solche Fehler zu erkennen. Das Deaktivieren dieser Regel ohne Verständnis der Warnung führt dazu, dass solche Probleme in die Produktion gelangen; beheben Sie stattdessen die Instabilität. - Profilieren Sie auch inaktive Bildschirme, nicht nur Interaktionen. Dieser Fehler trat nur auf, wenn niemand den Bildschirm berührte – genau diesen Zustand übersieht man bei manuellem Testen oft.
Falls Sie sich weiter mit dem Rendering beschäftigen möchten, behandelt unsere Übersicht zu gängigen Mustern, die unnötige React-Re-Renderungen auslösen entsprechende Fallstricke.
Zusammenfassung
Nur wenige Hooks lassen sich so schnell wie useEffect schreiben, und nur wenige sind so anfällig dafür, heimlich falsch verwendet zu werden. Auf Echtzeitbildschirmen führt ein Fehler bei den Abhängigkeiten nicht nur zu einer zusätzlichen Logzeile – er kann außerdem den JavaScript-Thread überlasten und dazu führen, dass die App ohne sichtbaren Fehler unbrauchbar erscheint. Stabile Abhängigkeiten, explizite Arrays sowie die Analyse von inaktiven Zuständen sind kostengünstige Gewohnheiten, die die teuersten Folgen dieses Fehlers verhindern.
Verwandte Artikel
- React Native Memory Leaks: Tracing JS Heap and Native Memory Owners – Erfahren Sie, warum die Garbage Collection eine React Native-App nicht vor Abflussproblemen im nativen Bereich retten kann, und wie Sie herausfinden können, was Callbacks, JSI-Objekte sowie decodierte Bilder am Leben hält.