Strona główna / Artykuły

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 z kodu natywnego, oraz jak odkryć, co utrzymuje w życiu funkcje zwrotne, obiekty JSI i odszyfrowane obrazy.

Wydawca: REACTAPP.TOP11 min czytania2614 słów

Źródła: The Stack, the Heap, and Garbage Collection: Why Your React Native App Still Runs Out of Memory

Ekran w React Native jest demontowany, i można by spodziewać się, że jego pamięć zostanie wraz z nim zwalniona. Jednak po dziesięciokrotnym otwieraniu i zamykaniu tego ekranu aplikacja działa wolno, telefon z małą ilością pamięci w systemie Android całkowicie ją zamyka, a Xcode informuje o przekroczeniu limitu pamięci przez aplikację. JavaScript posiada kolektor śmieci, więc jak to może się zdarzyć?

Odpowiedź brzmi, że aplikacja React Native funkcjonuje w dwóch światach pamięci, a kolektor śmieci reguluje tylko jeden z nich. Ten przewodnik wyjaśnia, jak faktycznie zachowują się stos, sterta pamięci oraz proces zbierania śmieci, omawia trzy typowe problemy wycieku pamięci charakterystyczne dla React Native oraz powszechny błąd związany z hookami, a także pokazuje, jak wykorzystać narzędzia Hermes, Xcode i Android Studio do znalezienia tego, co nadal zajmuje rosnącą ilość pamięci.

Dwa światy pamięci, które utrzymują się nawzajem przy życiu

Z jednej strony znajduje się świat JavaScripta: obiekty, tablice, funkcje, stan i atrybuty React oraz zamykania, które przechwytują twoje funkcje zwrotne. Z drugiej strony jest świat natywny: widoki platformy, oddekodowane piksele obrazów, buforzy kamery i wideo, identyfikatory plików oraz dane przechowywane przez moduły natywne.

Oba te światy są ze sobą powiązane, a każdy z nich może utrzymać drugi przy życiu. Uporządkowany stos JavaScripta nic nie mówi o całkowitej pamięci aplikacji, a odmontowanie komponentu samo w sobie nie uwalnia żadnych zasobów natywnych, którymi się zajmował. Pamiętanie o tym rozróżnieniu stanowi podstawę każdej późniejszej diagnozy.

Jak działają stos, stos pamięci i zbieranie śmieci

Trzy koncepcje warto dokładnie określić.

  • Stos wywołań śledzi funkcje, które są aktualnie wykonywane. Każde wywołanie dodaje małą ramkę do stosu; powrót usuwa ją.
  • Heap to miejsce, w którym JavaScript zazwyczaj przechowuje obiekty, tablice, funkcje i łańcuchy znaków, które mogą przetrwać dłużej niż jedna konkretne wywołanie.
  • Kolektor śmieci (GC) szuka danych w heapie, do których żadna aktywna referencja już nie ma dostępu, i przywraca tę pamięć do ponownego użycia.
  • Niewielki przykład ilustruje tę zależność:

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

    Gdy wykonywana jest funkcja toUpperCase(), stos zawiera kilka ram: wywołanie showName oraz metodę łańcucha znaków, którą ona wywołuje. Sam obiekt user znajduje się na heapie. Gdy showName zwraca wartość, jego rama znika, ale obiekt może zostać zgromadzony do usuwania tylko wtedy, gdy żadna inna referencja do niego nie istnieje. W tym fragmencie tak nie jest, więc obiekt nadaje się do usunięcia; gdyby cache, słuchacz lub zmienna na poziomie modułu przechowywały do niego referencję, obiekt pozostałby.

    To daje zasadę, która rządzi wszystkim innym: GC przywraca dane niedostępne. Rozłączenie komponentu lub opuszczenie zakresu zmiennej lokalnej samo w sobie nie uwalnia żadnych zasobów; jedynie usuwa jeden możliwy ścieżkę do tych danych.

    Czas wykonywania również ma znaczenie. GC nie daje żadnych gwarancji co do momentu swojego uruchomienia. Hermes wykorzystuje kolektor równoległy o nazwie Hades, zaprojektowany tak, aby krótkie przerwy nie zakłócały płynności interfejsu (zobacz nowości w Hermes). Skutkiem tego jest to, że brak natychmiastowego spadku pamięci po nawigacji nie jest dowodem na wyciek. Jak wyjaśniają inżynierowie Hermes w dyskusji na temat zachowania GC, obiekty niedostępne mogą pozostać w pamięci, dopóki kolektor nie uzna, że presja pamięciusprawia, iż zbieranie danych jest konieczne. Wyciek to pamięć, która stale rośnie podczas powtarzanych sesji tego samego procesu, a nie pamięć, która spada powoli.

    Gdzie pamięć pozostaje aktywna w aplikacji React Native

    Obecnie większość aplikacji React Native działa na silniku Hermes, chociaż dokładny silnik zależy od aplikacji i jej konfiguracji. Silnik wpływa na wartości pamięci heap oraz czas przerw GC, ale nie na pierwsze pytanie diagnostyczne: czy wzrost dotyczy pamięci JavaScript, czy pamięci natywnej?

    Poniższy diagram pokazuje typowe zależności pomiędzy tymi obszarami:

    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
    

    Stan i atrybuty sterują elementami natywnymi, tablice i obiekty odnoszą się do odszyfrowanych obrazów, hooki i funkcje zwrotne przechowują bufory kamer lub wideo, a wartości JSI współpracują we dwukierunkowym trybie z TurboModules oraz danymi w języku C++. Traktuj tę granicę jako linię własności. Gdy tylko jakiś zasób ją przekroczy, powinieneś być w stanie odpowiedzieć na trzy pytania:

    1. Kto obecnie posiada ten zasób?
    2. Co utrzymuje go w stanie aktywnym?
    3. Która konkretna operacja go uwalnia?

    Jeśli odpowiedź na trzecie pytanie brzmi „zbieracz śmieci się tym zajmie”, traktuj to jako sygnał ostrzegawczy. W przypadku zasobów natywnych często nie jest to bezpieczne.

    Ucieka 1: moduł natywny przechowujący funkcję callback z JavaScript

    Taki wzorzec nie ma rzeczywistego odpowiednika w JavaScriptie przeglądarki. Moduł natywny może przechowywać funkcję callback otrzymaną z JavaScript, a ta funkcja utrzymuje całe swoje otoczenie w stanie aktywnym: stan, właściwości, duży obiekt roboczy, a nawet model ekranu, który już nie istnieje.

    Tutaj efekt subskrybuje postęp przesyłania i zwraca funkcję do czyszczenia, która usuwa tę subskrypcję:

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

    To jest bezpieczne tylko wtedy, gdy remove() rzeczywiście informuje stronę natywną o zwolnieniu funkcji callback. Jeśli brakuje funkcji do czyszczenia lub w bibliotece jest błąd, powstaje taka łańcuchowa rezerwacja zasobów:

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

    Natywny UploadManager przechowuje funkcję zwrotną, ta z kolei przechowuje swoją strukturę zamknięcia, struktura zamknięcia przechowuje obiekt draft, a ten z kolei zawiera całą listę obrazów. Odłączenie ekranu w żaden sposób nie przerywa tej łańcuchowej struktury. Tylko czyszczenie subskrypcji to może zrobić, dlatego warto sprawdzić, czy subskrypcje od strony third-party rzeczywiście są odłączane po stronie natywnej, a nie tylko w JavaScript.

    Ucieczka pamięci 2: mały obiekt JSI obsługujący duży zasób natywny

    JSI umożliwia JavaScriptowi interakcję z obiektami natywnymi w znacznie bardziej bezpośredni sposób niż stary most komunikacyjny, co jest doskonałe pod względem wydajności i sprawia, że kwestia własności staje się o wiele ważniejsza. Wyobraź sobie bibliotekę, która otwiera natywnego dekodera wideo, kursor bazy danych lub sesję kamery i przekazuje JavaScriptowi lekki obiekt reprezentujący to. Obiekt w JavaScriptu może być bardzo mały, więc GC nie ma powodu, by pośpiesznie go usunąć, podczas gdy natywny zasób za nim zużywa dużo pamięci.

    Poniższy przykład otwiera sesję kamery, uruchamia ją, a podczas czyszczenia zarówno ją zatrzymuje, jak i zamyka:

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

    To właśnie wywołanie close() ma znaczenie. Sesje sprzętowe, dekodery, otwarte pliki, kursorzy oraz zasoby GPU powinny być celowo zwalniane, a nie pozostawiane na znalezienie przez mechanizm zbierania odpadków. Nie można zaplanować procesu zbierania, więc nie może on służyć jako plan zwalniania zasobów, a metody finalizacyjne mogą zostać wywołane późno lub wcale.

    Jeśli utrzymujesz bibliotekę JSI lub natywną, daj każdemu obiektowi posiadającemu zasoby wyraźną metodę zwalniania i spraw, by była ona idempotentna, dzięki czemu jej dwukrotne wywołanie nie będzie miało skutków negatywnych. W kodzie aplikacji wywołaj tę metodę w odpowiednim miejscu czyszczenia. Zespół Hermes udziela takiej samej rady w dyskusji na temat GC: udostępnij metodę w stylu close() dla zasobów natywnych, zamiast polegać na mechanizmie finalizacji.

    Ucieczka zasobów 3: komponent obrazu zniknął, ale piksele pozostały

    To wzorzec stoi u podstaw dużej liczby awarii spowodowanych brakiem pamięci. Zdjęcie, które na dysku zajmuje niewiele miejsca, staje się ogromne po dekodowaniu, ponieważ formaty kompresowane takie jak JPEG muszą zostać rozszerzone do surowych pikseli w celu wyświetlenia. Oryginalna wielkość po dekodowaniu wynosi mniej więcej:

    width × height × 4 bytes
    

    Przy czterech bajtach na piksel, zdjęcie o rozmiarze 4 000 na 3 000 wymaga około 48 MB, a to jeszcze przed dodatkowymi kopiami potrzebnymi do skalowania, buforowania lub przesyłania na GPU. Teraz rozważmy miniaturę, która wyświetla takie zdjęcie:

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

    Rozmiar tej miniatury to zaledwie 160 na 160 punktów, ale urządzenie może nadal pobrać i zdekodować plik w pełnej rozdzielczości. W feedzie zawierającym wiele dużych obrazów ilość pamięci używanej przez system może stale rosnąć, podczas gdy ilość pamięci w stosie JavaScript pozostaje stała – to właśnie taka sytuacja myli programistów, którzy obserwują tylko pamięć JavaScript.

    Nie każdy wzrost zużycia pamięci oznacza wyciek danych. Pamięć cache zawierająca ostatnio oglądane obrazy dobrze pełni swoją funkcję. Problem pojawia się, gdy zużycie pamięci rośnie przy każdym przetwarzaniu tego samego obrazu, nie spada na stałym poziomie i ostatecznie proces zostaje zakończony.

    Zacznij od poprawy tego, co wprowadzasz do procesu przetwarzania obrazów:

    • Żądaj obrazów o rozdzielczości zbliżonej do tej, w której będą wyświetlane.
    • Używaj miniatur w listach i ładuj obrazy w pełnej rozdzielczości tylko na ekranie szczegółów.
    • Ogranicz liczbę obrazów wyświetlanych jednocześnie, na przykład poprzez wirtualizację list.
    • Zapoznaj się z zasadami przechowywania w cache w Twojej bibliotece obrazów i skonfiguruj odpowiednie limity.
    • Testuj na rzeczywistym urządzeniu o małej ilości pamięci, a nie tylko na symulatorze.

    Oskarżanie Hermes o problem z 48 MB bitmapy jest błędne. Silnik JavaScript nie może odzyskać pikseli należących nadal do obrazu, procesu przetwarzania obrazów, cache lub tekstury GPU.

    Zastarzałe zamknięcia w hookach: dowiedz się, jaki masz błąd

    Powszechnie twierdzi się, że błędna tablica zależności powoduje wyciek pamięci. Może to się zdarzyć, ale częściej najpierw powstaje błąd wynikający z niewłaściwej wartości, a ta różnica wpływa na sposób jego naprawy.

    Zamknięcie przechowuje wartości, do których się odnosi. Jeśli natywny słuchacz lub timer przechowuje to zamknięcie dłużej, niż jest to konieczne, zamknięcie utrzymuje ze sobą stare dane. W takim przypadku tablica zależności jest pusta, więc funkcja callback dla postępu jest tworzona tylko raz:

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

    Callback przechwytuje episode z pierwszego renderowania. Gdy użytkownik zmienia odcinki, postęp jest nadal zapisywany pod kątem oryginalnego ID. To jest przykład zastarzałego zamknięcia. Jeśli subskrypcja zostanie usunięta podczas dezmontowania, mamy do czynienia z błędem poprawnościowym, a nie trwałym wyciekiem pamięci. Jeśli natywny odtwarzacz zachowuje callback po dezmontowaniu, mamy oba te problemy.

    Rozwiązaniem nie jest dodawanie każdego obiektu do tablicy zależności. Chodzi o to, aby efekt dokładnie opisywał zasób, który posiada, używając do tego wartości, która go definiuje:

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

    Teraz, za każdym razem gdy zmienia się episode.id, React uruchamia procedurę czyszczenia w celu usunięcia starej subskrypcji przed utworzeniem nowej, a funkcja zwrotna przechowuje tylko tę małą wartość, która jest jej potrzebna. Poleganie na prymitywnej identyfikatorze zamiast na całym obiekcie episode zapobiega również ponownemu subskrybowaniu się przy każdym renderowaniu, gdy zmienia się tożsamość obiektu, ale nie sama odcinka.

    Prowadzenie rzeczywistego badania pamięci

    Niejasne obserwacje typu „aplikacja zużywa dużo pamięci” nie są przydatne do podjęcia działań. Zamiast tego zdefiniuj jedną powtarzalną sekwencję, na przykład: otwórz feed, przewiń go, otwórz dziesięć zdjęć, wróć i powtórz całą sekwencję pięć razy.

    Następnie sprawdź, jaki wzorzec pokazują pomiary:

    • Stos JS rozrasta się po każdej pętli: nieszczelność najprawdopodobniej występuje po stronie JavaScript, więc zacznij od robienia zrzutów stanu stosu.
    • Stos JS pozostaje mniej więcej stały, podczas gdy całkowita pamięć aplikacji rośnie: sprawdź pamięć natywną za pomocą narzędzi platformy.

    Diagnozowanie wzrostu stosu JavaScript

    Użyj inspektora Hermes i rob zrzutów stanu stosu tam, gdzie twoja wersja React Native oraz konfiguracja budowania to umożliwiają. Wybierz podejrzany obiekt w zrzucie i prześledź ścieżkę jego utrzymywania aż do źródła. Typowi winowajcy mają nazwy takie jak listeners, timers, stores, caches, callbacks lub singletoni na poziomie modułu.

    Nie myl tego z narzędziem do profilowania Hermes Sampling, które jest przede wszystkim narzędziem dla procesora. Pokazuje ono, który kod JavaScript był aktywny, a nie dlaczego pamięć obrazu natywnego rośnie. Przewodnik React Native dotyczący profilowania za pomocą Hermes opisuje, jak rejestrować i otwierać profil procesora.

    Diagnozowanie pamięci natywnej w iOS

    Zainstaluj aplikację z Xcode na urządzeniu fizycznym. Zacznij od wskaźnika pamięci, aby szybko ocenić tendencje, a następnie otwórz Debug Memory Graph, aby zobaczyć obiekty oraz powiązania między nimi. Narzędzie Instruments, szczególnie szablon Allocations, umożliwia obserwację alokacji pamięci podczas powtarzania procesu. Dokumentacja Xcode dotycząca zarządzania pamięcią od Apple opisuje, w jaki sposób graf pamięci ujawnia obiekty, alokacje i ich powiązania, oraz jak Allocations śledzi zarówno pamięć stosową, jak i wirtualną.

    Gdy narzędzie Instruments pokazuje stały wzrost zużycia pamięci podczas pracy z dużą ilością zdjęć, a pamięć stosowa Hermes prawie się nie zmienia, przestań szukać problemu w zamknięciach JavaScript. Problem leży po stronie kodu natywnego.

    Użyj Profilera pamięci Android Studio na rzeczywistym urządzeniu, aby obserwować zużycie pamięci aplikacji w czasie. Jego wbudowany profilowanie pamięci, dostępny od wersji Android Studio 4.1, rejestruje alokacje i zwalnianie zasobów w języku natywnym oraz podsumowuje obiekty tego typu.

    Ekrany profilera zmieniają się w zależności od wersji Android Studio, ale sama metoda pozostaje niezmienna: rejestruje powtarzalny przebieg działań, oznacza początek każdego pętla i sprawdza, czy zużycie pamięci rośnie bez powrotu do stabilnego poziomu.

    Lista kontrolna czyszczenia dla każdego ekranu

    Cokolwiek uruchomi dany ekran, jego czyszczenie powinno to zakończyć. Szybka weryfikacja obejmuje:

    • Czyszczenie zegara, który został uruchomiony.
    • Usunięcie dodanego słuchacza.
    • Anulowanie subskrypcji w języku natywnym.
    • Zatrzymanie i zamknięcie kamery, odtwarzacza, pliku lub kursora bazy danych.
    • Ustalenie limitu wielkości lub polityki usuwania dla utworzonej pamięci cache.
  • Zdjęcia o dużych rozmiarach dają mniejsze pliki źródłowe oraz ograniczenie liczby wyświetlanych widoków.
  • To nie jest ręczne zarządzanie pamięcią w sensie języka C. Nie uwalniasz dowolnych bajtów; zakończasz działanie aplikacji i zwracasz zasoby w momencie, gdy przestaje ona nimi dysponować.

    Podsumowanie

    Zbieranie śmieci nigdy nie obiecywało, że wszystko, czego dotknął komponent, zniknie po jego odmontowaniu. Gdy pamięć rośnie, zadaj dwa pytania w tej kolejności: czy wzrost dotyczy pamięci JavaScript lub procesu natywnego, i kto nadal posiada element, który się powiększa?

    Odpowiedź wskazuje bezpośrednio na rozwiązanie. Trwający callback oznacza subskrypcję, która nigdy nie została usunięta. Natywna sesja oznacza brak metody close(). Obrazek o rozdzielczości 48 MB oznacza, że potrzebujesz mniejszego obrazu lub lepiej zarządzanego cache’u. Błędy pamięci w React Native przestają być tajemnicze, gdy przestaniesz czekać na GC i zaczniesz szukać właściciela problemu, a następnie zakończysz jego prawa do korzystania z pamięci.

    Przez cały czas profiluj aplikację na rzeczywistych urządzeniach. Symulator nadaje się do odtwarzania określonych scenariuszy, ale może ukrywać presję na pamięć, która powoduje zamknięcie aplikacji na telefonie użytkownika.

    Literatura pokrewna

  • Niestabilne zależności useEffect: diagnozowanie zużycia baterii w React Native — Dowiedz się, jak zależność od obiektu oraz brak tablicy zależności spowodowały zużycie baterii i problemy z renderowaniem w React Native, jak to skontrolować oraz jakie trzy rozwiązania okazały się skuteczne.
  • Polowanie na wycieki pamięci w JavaScript: dostępność, retenery i oczyszczanie — Przeczytaj, dlaczego JavaScript z mechanizmem zbierania śmieci nadal powoduje wycieki pamięci, jakie codzienne wzorce utrzymują pamięć oraz jak znaleźć przyczynę za pomocą zrzutów pamięci i łańcuchów retenerów.