Startseite / Artikel

React Native Speicherverluste: Nachverfolgung der JS-Heap- und Native-Speicherinhaber

Erfahren Sie, warum die Abfallsammlung eine React Native-App nicht vor Problemen im nativen Code schützen kann, und wie Sie herausfinden können, was Callbacks, JSI-Objekte sowie decodierte Bilder am Leben erhält.

Verlag: REACTAPP.TOP11 Min. Lesezeit2614 Wörter

Quellen: The Stack, the Heap, and Garbage Collection: Why Your React Native App Still Runs Out of Memory

Ein React Native-Bildschirm wird deinstalliert, und man erwartet zu Recht, dass auch sein Speicher freigegeben wird. Doch nachdem dieser Bildschirm zehnmal geöffnet und geschlossen wurde, wirkt die App träge, ein Android-Telefon mit geringem Speicher lässt sie komplett abstürzen, und Xcode meldet, dass die App ihre Speichergrenze überschritten hat. JavaScript verfügt über einen Garbage Collector – wie kann das also passieren?

Die Antwort ist, dass eine React Native-App in zwei Speicherkontexten existiert, und der Garbage Collector regelt nur einen davon. Diese Anleitung erläutert, wie Stack, Heap und Garbage Collection tatsächlich funktionieren, geht auf drei spezifische Speicherverlustmuster in React Native sowie ein häufiges Missverständnis bezüglich Hooks ein und zeigt, wie man die Tools von Hermes, Xcode und Android Studio nutzt, um herauszufinden, was weiterhin den stetig wachsenden Speicher belegt.

Zwei Speicherkontexte, die einander am Leben erhalten

Auf der einen Seite befindet sich die JavaScript-Welt: Objekte, Arrays, Funktionen, React-State und -Props sowie die Schließfunktionen, die von den Callbacks erfasst werden. Auf der anderen Seite liegt die native Welt: Plattformansichten, decodierte Bildpixel, Kamera- und Videopuffer, Dateihandlungen sowie Daten, die von nativen Modulen gehalten werden.

Beide sind miteinander verbunden, und jede Seite kann die andere am Leben erhalten. Ein ordentlicher JavaScript-Heap sagt nichts über den Gesamtmemoryverbrauch der Anwendung aus, und das Entfernen einer Komponente setzt allein keine freigegebenen nativen Ressourcen frei, die sie berührt hat. Die Berücksichtigung dieser Trennung bildet die Grundlage für jede weitere Diagnose.

Wie Stack, Heap und Garbage Collection funktionieren

Drei Konzepte sind besonders wichtig, um sie genau zu definieren.

  • Der Call Stack verfolgt die derzeit ausgeführten Funktionen. Jeder Aufruf fügt ein kleines Frame hinzu; beim Zurückkehren wird dieses wieder entfernt.
  • Der Heap ist der Ort, an dem JavaScript typischerweise Objekte, Arrays, Funktionen und Zeichenketten speichert, die länger bestehen können als ein einzelner Aufruf.
  • Der Abfallsammler (Garbage Collector, GC) sucht nach Daten im Heap, die von keiner lebenden Referenz mehr erreichbar sind, und holt diese Speicherbereiche zurück, um sie erneut zu verwenden.
  • Ein kleines Beispiel macht diese Beziehung konkret:

    function showName(user) {
      return user.name.toUpperCase();
    }
    
    showName({ name: "Ada" });
    

    Während toUpperCase() ausgeführt wird, enthält der Stack einige Frames: den Aufruf von showName sowie die von diesem aufgerufene Zeichenkettenmethode. Das eigentliche User-Objekt befindet sich auf dem Heap. Wenn showName zurückkehrt, ist sein Frame verschwunden, doch das Objekt wird erst zur Sammlung freigegeben, wenn es von nichts anderem mehr referenziert wird. In diesem Auszug wird es von nichts mehr referenziert, sodass es zur Sammlung in Frage kommt; wäre eine Cache-Struktur, ein Listener oder eine Variablen auf Modulebene eine Referenz geblieben, würde das Objekt weiterhin bestehen.

    Das führt zur Regel, die alles Weitere bestimmt: der GC holt sich unerreichbare Daten zurück. Das Entfernen einer Komponente oder das Verlassen des Gültigkeitsbereichs einer lokalen Variablen befreit nichts von selbst; es beseitigt lediglich einen möglichen Zugang zu den Daten.

    Auch die Timing-Faktoren sind wichtig. Der Garbage Collector gibt keine Zusicherungen darüber ab, wann er ausgeführt wird. Hermes verwendet einen konkurrierenden Sammler namens Hades, der darauf ausgelegt ist, Pausen kurz zu halten, damit die Benutzeroberfläche reibungslos bleibt (siehe die Hermes-Veröffentlichungen). Eine Folge davon ist, dass das Fehlen eines sofortigen Speicherrückgangs nach der Navigation kein Beweis für einen Memory Leak ist. Wie die Hermes-Entwickler in einer Diskussion zum Verhalten des Garbage Collectors erklären, können unerreichbare Objekte bestehen bleiben, bis der Sammler entscheidet, dass aufgrund des Speicherdrucks eine Sammlung sinnvoll ist. Ein Memory Leak liegt vor, wenn der Speicher bei wiederholten Ausführungen derselben Aktion weiter ansteigt, und nicht, wenn der Rückgang des Speichers langsam ist.

    Wo bleibt der Speicher in einer React Native-App aktiv?

    Die meisten React Native-Apps laufen heutzutage unter Hermes, wobei der genaue Engine je nach App und deren Konfiguration variiert. Der Engine beeinflusst die Werte des Heap-Speichers sowie die Dauer der Garbage-Collection-Pausen, nicht jedoch die erste Frage bei der Fehlersuche: Entsteht der Speicherverbrauch durch JavaScript-Speicher oder durch nativen Speicher?

    Das Diagramm unten zeigt die typischen Beziehungen über diese Grenze hinweg:

    JavaScript runtime                 Native runtime
    ------------------                 --------------------
    state and props       ─────►       views and view layers
    arrays and objects    ─────►       decoded image pixels
    hooks and callbacks   ─────►       camera/video buffers
    JSI values            ◄─────►      TurboModules / C++ data
    

    State und Props steuern die nativen Ansichten, Arrays und Objekte verweisen auf decodierte Bilder, Hooks und Callbacks speichern Kamera- oder Videopuffer, und JSI-Werte interagieren in beide Richtungen mit TurboModules sowie C++-Daten. Betrachten Sie diese Grenze als Eigentumslinie. Immer dann, wenn eine Ressource sie überschreitet, sollten Sie in der Lage sein, drei Fragen zu beantworten:

    1. Wer besitzt diese Ressource im Moment?
    2. Was hält sie am Leben?
    3. Welcher spezifische Aufruf befreit sie?

    Falls die Antwort auf die dritte Frage lautet „der Garbage Collector kümmert sich darum“, sollten Sie das als Warnsignal betrachten. Bei nativen Ressourcen ist das in der Regel nicht sicher.

    Leck 1: Ein natives Modul, das Ihren JavaScript-Callback speichert

    Dieses Muster hat im Browser-JavaScript keinen echten Entsprechung. Ein natives Modul kann einen von JavaScript empfangenen Callback speichern, und dieser Callback hält seine gesamte Umgebung am Leben – Zustände, Eigenschaften, große Objekte sowie sogar das Modell eines nicht mehr vorhandenen Bildschirms.

    Hier abonniert ein Effekt den Upload-Progress und gibt eine Aufräumfunktion zurück, die das Abonnement entfernt:

    useEffect(() => {
      const sub = UploadManager.onProgress((done) => {
        reportProgress(draft.id, done);
      });
      return () => sub.remove();
    }, [draft.id]);
    

    Das ist nur sicher, wenn remove() tatsächlich dem nativen Teil mitteilt, den Callback freizugeben. Fehlt die Aufräumfunktion oder enthält die Bibliothek einen Fehler, entsteht eine solche Verweilkette:

    native UploadManager → JS callback → closure → draft → image list
    

    Der native UploadManager enthält den Callback, der Callback wiederum seine Closure, die Closure enthält draft, und draft beherbergt schließlich eine ganze Liste von Bildern. Das Entfernen des Screens beeinträchtigt diese Kette keineswegs. Nur die Aufräumarbeiten bezüglich der Abonnements führen dazu, weshalb es sinnvoll ist zu überprüfen, ob die Abonnements von Drittanbietern tatsächlich auf der nativen Seite getrennt werden, und nicht nur in JavaScript.

    Leck 2: Ein kleines JSI-Objekt, das einen großen nativen Ressourcen versteckt

    JSI ermöglicht es JavaScript, weitaus direkter mit nativen Objekten zu interagieren als die alte Brückentechnologie, was sich positiv auf die Leistung auswirkt und das Konzept des Eigentums deutlich wichtiger macht. Stellen Sie sich eine Bibliothek vor, die einen nativen Video-Decoder, einen Datenbankcursor oder eine Kamerasitzung öffnet und JavaScript ein leichtgewichtiges Objekt zur Verfügung stellt, das diese darstellt. Das JavaScript-Objekt kann sehr klein sein, weshalb der Garbage Collector keinen Grund hat, es sofort zu bereinigen, während die dahinterliegende native Ressource viel Speicher verbraucht.

    Die folgende Anwendung öffnet eine Kamerasitzung, startet sie und beendet sowie schließt sie wieder im Zuge der Aufräumarbeiten:

    useEffect(() => {
      const session = CameraSession.open();
      session.start();
      return () => {
        session.stop();
        session.close();
      };
    }, []);
    

    Der Aufruf von close() ist der entscheidende. Hardware-Sessions, Dekodierer, geöffnete Dateien, Cursor sowie GPU-Allokationen sollten alle absichtlich freigegeben werden – niemals dem Sammler überlassen. Da eine Sammlung nicht terminiert werden kann, dient sie nicht als Freigabeplan, und Finalisatoren können verspätet ausgeführt werden oder gar nicht.

    Falls Sie eine JSI- oder Native-Bibliothek betreiben, geben Sie jedem objektbezogenen Ressourcenobjekt eine explizite Freigabemethode und stellen Sie sicher, dass diese idempotent ist, sodass ein zweimaliger Aufruf unbedenklich ist. Im Anwendungscode sollten Sie diese Methode vom entsprechenden Aufräumpunkt aus aufrufen. Das Hermes-Team gibt denselben Rat in der GC-Diskussion: Stellen Sie anstelle der Verlassenschaft auf Finalisierungen eine methodeähnliche close()-Funktion für native Ressourcen bereit.

    Leck 3: Der Bildkomponente fehlt, die Pixel jedoch nicht

    Dieses Muster steht hinter einem großen Anteil an Speicherfehlern. Ein Foto, das auf der Festplatte noch überschaubar ist, wird nach der Dekodierung enorm groß, da komprimierte Formate wie JPEG in Rohpixel umgewandelt werden müssen, damit sie angezeigt werden können. Eine grobe Schätzung der Dekodiergröße lautet:

    width × height × 4 bytes
    

    Bei vier Bytes pro Pixel benötigt ein 4.000 mal 3.000 große Foto etwa 48 MB – und das noch bevor zusätzliche Kopien für Skalierung, Caching oder den Upload an die GPU erstellt werden. Betrachten wir nun ein Vorschaubild, das ein solches Foto darstellt:

    <Image
      source={{ uri: fullSizePhotoUrl }}
      style={{ width: 160, height: 160 }}
    />
    

    Die Ansicht ist nur 160 mal 160 Pixel groß, doch das Gerät muss dennoch die Datei in voller Auflösung herunterladen und dekodieren. In einem Feed mit vielen großen Bildern kann der native Speicher stetig ansteigen, während der JavaScript-Heap konstant bleibt – genau diese Situation verwirrt Entwickler, die nur auf den JS-Speicher achten.

    Nicht jeder Anstieg der Speichernutzung bedeutet einen Leck. Ein Bildcaching, das kürzlich angezeigte Bilder speichert, erfüllt seine Funktion. Ein Problem entsteht, wenn der Speicherbedarf bei jeder Durchlaufung desselben Ablaufs weiter steigt, sich nie normalisiert und schließlich der Prozess beendet wird.

    Fangen Sie damit an, das zu korrigieren, was in den Bildverarbeitungspfad eingegeben wird:

    • Bitten Sie um Bilder in einer Größe, die der angezeigten Größe nahekommt.
    • Verwenden Sie Vorschaubilder in Listen und laden Sie nur in hoher Auflösung auf der Detailansicht hoch.
    • Begrenzen Sie die Anzahl der gleichzeitig angezeigten Bilder, beispielsweise durch Listenvirtualisierung.
    • Lernen Sie die Caching-Regeln Ihrer Bildbibliothek kennen und konfigurieren Sie deren Grenzen.
    • Testen Sie auf einem echten Gerät mit geringem Speicher, nicht nur auf einem Simulator.

    Hermes für ein 48 MB großes Bitmap-Bild die Schuld zu geben, geht am Kern des Problems vorbei. Der JavaScript-Engine kann keine nativen Pixel zurückgewinnen, die weiterhin von einer Bildansicht, dem Bildverarbeitungspfad, einem Cache oder einer GPU-Textur genutzt werden.

    Veraltete Schließungen in Hooks: Erkennen Sie, welches Problem Sie haben

    Eine häufige Behauptung ist, dass eine falsche Abhängigkeitsliste Speicher freisetzt. Das kann der Fall sein, aber öfter führt sie zunächst zu einem Fehler mit falschen Werten, und dieser Unterschied beeinflusst die Art und Weise, wie man das Problem behebt.

    Eine Schließung behält die Werte bei, auf die sie verweist. Wenn ein nativer Listener oder ein Timer diese Schließung länger als beabsichtigt speichert, behält die Schließung alte Daten bei sich. In diesem Fall ist die Abhängigkeitsliste leer, sodass der Fortschritts-Callback nur einmal erstellt wird:

    useEffect(() => {
      const sub = player.onProgress(() => {
        savePosition(episode.id);
      });
      return () => sub.remove();
    }, []); // episode may change, but this callback will not
    

    Der Callback greift bei der ersten Darstellung episode auf. Wenn der Benutzer zu einer anderen Episode wechselt, wird der Fortschritt weiterhin unter Verwendung der ursprünglichen ID gespeichert. Das ist eine veraltete Schließung. Wenn die Abonnementverbindung beim Entfernen des Elements aufgehoben wird, handelt es sich um einen Korrekturfehler und nicht um eine anhaltende Speicherverlustsituation. Wenn der native Player den Callback nach dem Entfernen weiterhin speichert, treten beide Probleme auf.

    Die Lösung besteht nicht darin, jedes Objekt in den Dependency-Array aufzunehmen. Stattdessen sollte der Effect genau das Ressourcenelement beschreiben, das er verwaltet, wobei als Schlüssel der Wert verwendet wird, der dieses Element definiert:

    useEffect(() => {
      const sub = player.onProgress(() => {
        savePosition(episode.id);
      });
      return () => sub.remove();
    }, [episode.id]);
    

    Wenn sich nun episode.id ändert, führt React die Aufräumfunktion aus, um das alte Abonnement zu löschen, bevor ein neues erstellt wird. Dabei greift der Callback nur auf den kleinen Wert zu, den er benötigt. Die Verwendung einer primitiven ID anstelle des gesamten episode-Objekts verhindert außerdem, dass bei jeder Neuzeichnung erneut abonniert wird, wenn sich die Identität des Objekts ändert, aber nicht die des Episodeninhalts.

    Echte Speichermanagement-Untersuchungen durchführen

    Vage Beobachtungen wie „Die App verbraucht viel Speicher“ sind nicht handlungsorientiert. Definieren Sie stattdessen einen wiederholbaren Ablauf – zum Beispiel: Öffnen Sie den Feed, scrollen Sie darin, öffnen Sie zehn Fotos, gehen Sie zurück und wiederholen Sie die gesamte Abfolge fünfmal.

    Dann prüfen Sie, welches Muster die Messungen zeigen:

    • Der JS-Heap wächst nach jedem Loop: Das Leck liegt höchstwahrscheinlich auf der JavaScript-Seite, daher sollten Sie mit Heap-Snaps beginnen.
    • Der JS-Heap bleibt im Großen und Ganzen konstant, während die Gesamtmemory des Apps zunimmt: Prüfen Sie das native Memory mit Tools der Plattform.

    Diagnose des Wachstums des JavaScript-Heaps

    Nutzen Sie den Hermes-Inspektor und erstellen Sie Heap-Snaps, sofern Ihre React Native-Version sowie die Build-Konfiguration dies unterstützen. Wählen Sie ein verdächtiges Objekt im Snapshot aus und verfolgen Sie seinen Retention-Pfad zurück zu einer Wurzel. Typische Schuldige weisen Namen wie listeners, timers, stores, caches, callbacks oder module-level Singletons auf.

    Achten Sie darauf, dies nicht mit dem Hermes Sampling Profiler zu verwechseln, der hauptsächlich ein CPU-Tool ist. Er zeigt an, welcher JavaScript-Code aktiv war, aber nicht, warum sich das Speicherbedarf des nativen Bildes erhöht. Der React Native-Leitfaden zu Profiling mit Hermes erklärt, wie man ein CPU-Profil aufnimmt und öffnet.

    Diagnose des nativen Speichers auf iOS

    Führen Sie die App über Xcode auf einem physischen Gerät aus. Beginnen Sie mit dem Speichermesser, um einen schnellen Überblick über die Entwicklung zu erhalten, und öffnen Sie anschließend das Debug Memory Graph, um Objekte sowie die Verweise zwischen ihnen zu sehen. Instruments, insbesondere das Vorlage-Tool Allocations, ermöglicht es Ihnen, die Speicherzuweisungen während wiederholter Abläufe zu überwachen. Apples Xcode-Dokumentation zu Speichernutzung erläutert, wie das Speichergraphen Objekte, Zuweisungen und deren Verbindungen darstellt sowie wie Allocations sowohl den Heap als auch den virtuellen Speicher verfolgt.

    Falls Instruments während eines prozessorientierten Ablaufs ein stetiges Wachstum anzeigt, während der Hermes-Heap kaum Bewegung zeigt, sollten Sie nicht weiter nach einer JavaScript-Closure suchen – das Problem liegt auf der nativen Seite.

    Diagnose des nativen Speichers auf Android

    Nutzen Sie den Memory Profiler von Android Studio an einem echten Gerät, um die Speichernutzung der App im Laufe der Zeit zu überwachen. Die mit Android Studio 4.1 eingeführte native Speichermanagement-Analyse protokolliert die Zuweisung und Freigabe von Speicherressourcen sowie fasst die nativen Objekte zusammen.

    Die Anzeigen des Profilers ändern sich je nach Android Studio-Version, doch die Vorgehensweise bleibt gleich: Es wird der wiederholbare Ablauf aufgezeichnet, der Beginn jeder Schleife markiert und nach einem Anstieg des Speicherverbrauchs gesucht, der nicht wieder auf einen stabilen Wert zurückkehrt.

    Eine Überprülliste für jede Anzeige

    Was auch immer eine Anzeige startet, ihre Aufräumarbeiten sollten dies beenden. Eine schnelle Überprüfung umfasst:

    • Der gestartete Timer wird bereinigt.
    • Ein hinzugefügter Zuhörer wird entfernt.
    • Eine native Abonnementsverbindung wird gekündigt.
    • Ein Kamera-, Player-, Datei- oder Datenbankcursor wird gestoppt und geschlossen.
    • Ein von Ihnen erstellter Cache erhält eine Größenbegrenzung oder eine Löschrichtlinie.
  • Große Bilder ergeben kleinere Quelldateien sowie eine Obergrenze für die angezeigten Ansichten.
  • Es handelt sich dabei nicht um eine manuelle Speichermanagement-Logik im C-Sinn. Man befreit keine willkürlichen Bytes; vielmehr wird die Arbeit abgeschlossen und die Ressourcen zurückgegeben, sobald die Anwendung nicht mehr über sie verfügt.

    Zusammenfassung

    Die Müllsammlung verspricht niemals, dass alles, was eine Komponente berührt hat, verschwindet, sobald sie deinstalliert wird. Wenn der Speicherbedarf steigt, sollten Sie in dieser Reihenfolge zwei Fragen stellen: Entsteht der Anstieg im JavaScript-Heap oder im nativen Prozess – und wer besitzt weiterhin das Objekt, dessen Größe zunimmt?

    Die Antwort weist direkt auf die Lösung hin. Ein anhaltender Callback bedeutet ein Abonnement, das nie entfernt wurde. Eine native Sitzung deutet auf ein fehlendes close() hin. Ein 48 MB großer Bitmap bedeutet, dass Sie ein kleineres Bild oder einen effizienteren Cache benötigen. Speichermängel in React Native werden nicht mehr rätselhaft, sobald man auf das Garbage Collection warten lässt und stattdessen den Verantwortlichen findet, um dessen Besitzrecht zu beenden.

    Überall sollte man auf echten Geräten profilieren. Ein Simulator eignet sich zwar zur Wiedergabe eines Ablaufs, kann aber den Speicherdruck verbergen, der die App auf dem Telefon eines Benutzers zum Absturz bringt.