Strona główna / Artykuły

Gdzie kryje się opóźnienie w React Native: przechodzenie granic, a nie wolny kod

Dowiedz się, dlaczego szybkie API i komponenty z pamięcą podręczną mogą nadal wydawać się powolne w React Native, oraz jak grupowanie zadań, ograniczanie częstotliwości wykonywania i projektowanie z użyciem JSI zmniejszają koszty między różnymi środowiskami działania.

Wydawca: REACTAPP.TOP19 min czytania4454 słów

Źródła: The Hidden Latency Cost in React Native Bridge Architectures

Ekran w React Native może wydawać się powolny, mimo że wszystkie mierzone parametry wskazują na prawidłową pracę: API odpowiada w około 100 ms, zapytania są indeksowane, komponenty są memoizowane, a obrazy pochodzą z pamięci cache. Mimo to kliknięcie jest rejestrowane z zbyt dużym opóźnieniem, panel na dole przeglądarki działa nierówno, znaczniki na mapie nie odpowiadają rzeczywistości, animacja, która jest bezbłędna na flagowym telefonie, ma problemy na tańszym urządzeniu z systemem Android, a nagła seria zdarzeń z czujników blokuje interfejs. W wielu takich przypadkach brakujący czas jest tracony w miejscu, które zespoły rzadko monitorują: przy przenoszeniu danych pomiędzy środowiskiem wykonywania JavaScript a kodem natywnym.

Ten przewodnik wyjaśnia, w jaki sposób odbywała się ta przenoszenie danych w ramach starszej architektury Bridge, dlaczego jej koszt narasta stopniowo zamiast pojawiać się tylko w jednej konkretnej rozmowie, co faktycznie zmienia nowa architektura (JSI, TurboModules, Fabric oraz przeprojektowany renderer) oraz jakie nawyki projektowe sprawiają, że ruch między różnymi środowiskami wykonawczymi pozostaje tani, niezależnie od wybranej architektury. Nowa architektura jest domyślna od wersji React Native 0.76, ale architektura Bridge nadal warto zrozumieć – wiele aplikacji produkcyjnych korzysta z modułów starszego typu, warstw interfejsowych, starszych bibliotek oraz wzorców opracowanych przy jej użyciu. Co ważniejsze, usunięcie architektury Bridge nie sprawia, że słabo zaprojektowana komunikacja między środowiskami wykonawczymi stanie się bezpłatna.

Dwa środowiska wykonawcze w jednej aplikacji

Korzystnym modelem mentalnym jest to, że aplikacja React Native działa przynajmniej w dwóch środowiskach. Twoje komponenty, stan, reguły biznesowe oraz funkcje sieciowe znajdują się po stronie JavaScripta; wszystko, co ma związek z systemem operacyjnym, znajduje się po stronie natywnej.

┌──────────────────────────────┐
│        JavaScript / TS       │
│                              │
│ React Components             │
│ State Management             │
│ Business Logic               │
│ API Handling                 │
└──────────────┬───────────────┘
               │
               │ JS ↔ Native communication
               │
┌──────────────▼───────────────┐
│          Native Side         │
│                              │
│ Android / iOS APIs           │
│ Camera                       │
│ Bluetooth                    │
│ GPS                          │
│ Storage                      │
│ Native Views                 │
│ Sensors                      │
└──────────────────────────────┘

Kod React może działać w JavaScriptie, ale prawie każda istotna funkcja ostatecznie wymaga wsparcia platformy. Typowe powody, dla których konieczne jest połączenie z platformą, to:

  • wyświetlanie natywnych widoków interfejsu użytkownika,
  • otwieranie kamery,
  • czytanie współrzędnych GPS,
  • dostęp do systemu plików,
  • aktywowanie efektów wibracyjnych,
  • komunikacja z urządzeniami Bluetooth,
  • używanie bezpiecznego przechowywania danych,
  • wywoływanie narzędzi SDK natywnych od third party,
  • odbieranie danych z czujników,
  • wykorzystywanie innych API specyficznych dla danej platformy.

Dane więc nieustannie przemieszczają się pomiędzy tymi dwoma środowiskami. W klasycznej architekturze cały ten ruch odbywał się przez React Native Bridge.

Jak stary Bridge przekazywał żądania

Pierwotny projekt celowo oddzielał wykonywanie kodu JavaScript od wykonywania kodu natywnego. Jedna podróż w obie strony wyglądała mniej więcej tak:

JavaScript
    ↓
Prepare parameters
    ↓
Serialize data
    ↓
Queue bridge message
    ↓
Cross execution boundary
    ↓
Native receives message
    ↓
Native performs operation
    ↓
Prepare result
    ↓
Serialize result
    ↓
Queue response
    ↓
JavaScript receives result

Kluczową cechą było to, że cała wymiana danych była asynchroniczna. Argumenty były serializowane, umieszczane w kolejce, przekazywane przez granicę, deserializowane i przetwarzane po stronie natywnej, a wszelkie wyniki wracały tą samą drogą. Zespół React Native w swoim dokumentacji o nowej architekturze określił serializację i asynchroniczne łączenie jako główne ograniczenia starego modelu.

Dla sporadycznych żądań ten nakład pracy nie stanowi problemu. Dobrym przykładem jest zapisanie ciągu znaków do schowka.

await Clipboard.setString("Hello");

Niezależnie od kosztu przekroczenia granicy, żaden użytkownik tego nie odczuje. Problemy pojawiają się wtedy, gdy ta granica staje się kanałem o wysokiej częstotliwości, od którego aplikacja ciągle korzysta.

Jedna wywołanie jest tanie; to wzorzec powoduje problemy

W dyskusjach na temat wydajności Bridge często zakłada się, że każde wywołanie jest niezwykle drogie. Taki sposób patrzenia na sprawę pomija sedno problemu. Prawdziwe spowolnienia zwykle wynikają z połączenia kilku czynników, które się mnożą:

częstotliwość × rozmiar danych × presja planowania × wymagania synchronizacji

Załóżmy, że jedno przekroczenie granicy kosztuje prawie nic. Teraz zwiększmy liczbę takich przekroczeń:

1 call
10 calls
100 calls
1,000 calls
10,000 calls

Następnie można zwiększyć rozmiar każdego ładunku danych, wysyłać aktualizacje w sposób ciągły podczas przewijania przez użytkownika lub wymagać przejścia od języka natywnego do JavaScripta i z powrotem, zanim będzie można narysować następny stan wizualny. Koszt, który był zaniedbywalny osobno, teraz znajduje się na kluczowej ścieżce renderowania. Żadna pojedyncza funkcja nie wydaje się wolna w narzędziu do analizy wydajności; opóźnienie jest rozproszone w całym systemie, i to właśnie dlatego pozostaje niewidoczne.

Traktuj opóźnienia w interakcjach jak łańcuch przetwarzania

Pomaga zapisanie każdego etapu, przez który przechodzi interakcja:

Total Latency =
    JS Work
  + Serialization
  + Queue Waiting
  + Context Boundary Cost
  + Native Work
  + Return Communication
  + Rendering Delay

Zespoły mają tendencję do optymalizacji jednego elementu osobno, zazwyczaj pracy w języku natywnym:

Native Work

lub pracy w JavaScriptie:

JS Work

Jednak użytkownik doświadcza tylko sumy tych wszystkich elementów:

TOTAL LATENCY

Rozważmy operację w języku natywnym, która kończy się w:

2 ms

Sam w sobie to doskonała liczba. Ale pełny rozkład może wyglądać następująco:

JS preparation             2 ms
Serialization              2 ms
Queue waiting              4 ms
Native operation           2 ms
Response communication     3 ms
React update               4 ms
--------------------------------
Total                     17 ms

„Operacja trwająca 2 ms” przekształciła się w interakcję trwającą około 17 ms. Dokumentacja React Native opisuje budżet klatki na poziomie około 16,67 ms przy 60 FPS; jeśli zostanie on przekroczony, klatka może zostać pominięta. Wiele obecnych telefonów odświeża ekran szybciej niż 60 Hz, co jeszcze bardziej zmniejsza ten budżet.

Budżety klatek pozostawiają niewiele miejsca na dodatkowe obciążenia

Przy 60 Hz arytmetyka wygląda następująco:

1000 ms / 60 ≈ 16.67 ms

To daje w przybliżeniu

16.67 ms

na wygenerowanie każdej klatki. Przy 120 Hz wartość ta jest o połowę mniejsza:

1000 ms / 120 ≈ 8.33 ms

Około osiem milisekund to bardzo mało. Jeśli część z nich jest wykorzystywana na bezsensowną wymianę danych pomiędzy różnymi komponentami systemu, to sama architektura pochłania budżet prześwietlania. Objawy są znajome:

  • przerwy w animacjach,
  • gesty, które reagują z opóźnieniem,
  • wolne przejścia pomiędzy elementami nawigacji,
  • przerywane klatki obrazu,
  • opóźniona informacja zwrotna po dotknięciu,
  • nierównomierne przewijanie,
  • aktualizacje interfejsu, które pojawiają się jedną klatkę później niż powinny.
  • Dokumentacja dotycząca wydajności rozróżnia również FPS wątku JavaScript od FPS głównego wątku interfejsu. Gdy wątek JavaScript jest przeciążony, nie może szybko przetwarzać zdarzeń dotykowych ani aktualizacji interfejsu sterowanych przez JavaScript, podczas gdy interakcje realizowane w sposób natywny, takie jak przewijanie listy natywnej, mogą pozostać płynne. To rozróżnienie tłumaczy klasyczny problem podczas debugowania: jedna część ekranu wydaje się płynna, podczas gdy druga jest zamarznięta.

    Sytuacja: transmisja lokalizacji na mapę

    Wyobraźmy sobie aplikację do zamawiania przejazdów otrzymującą ciągły strumień danych GPS. Prosta architektura wygląda w ten sposób:

    Native GPS
        ↓
    Bridge
        ↓
    JavaScript
        ↓
    State Update
        ↓
    React Render
        ↓
    Bridge
        ↓
    Native Map
        ↓
    Marker Position Updated
    

    Teraz uruchom ten pętla dla każdej korekty. Ani obliczanie pozycji, ani przesuwanie markera samo w sobie nie są kosztowne, ale taka architektura powoduje ciągłe powtarzanie się tej operacji:

    Native → JS → Native
    

    Proces ten trwa bez przerwy, dopóki ekran jest otwarty. Jeśli pokażemy wiele kierowców jednocześnie, ruch się mnoży:

    Driver 1 position
    Driver 2 position
    Driver 3 position
    Driver 4 position
    ...
    Driver 100 position
    

    Wysyłanie stu małych aktualizacji pojedynczo może być znacznie droższe niż wysłanie jednej, dobrze przygotowanej grupy aktualizacji opisującej je wszystkie. Główną przyczyną jest kształt przepływu danych, a nie mechanizm renderowania w React.

    Szczegóły: strumienie z czujników

    Dane z akcelerometru i żyroskopu podążają za tym samym wzorcem. Załóżmy, że kod natywny wysyła:

    60 events / second
    

    Każde zdarzenie trafia do JavaScripta, jest tam przetwarzane i może wywołać nową aktualizację wracającą do interfejsu natywnego. W rezultacie powstaje pipeline w czasie rzeczywistym, który przechodzi przez tę granicę:

    Sensor
      ↓
    Native
      ↓
     JS
      ↓
    Calculation
      ↓
    React
      ↓
    Native UI
    

    Przy niskiej częstotliwości zdarzeń może to działać doskonale. Przy wysokiej częstotliwości, a szczególnie na słabszym sprzęcie, zdarzenia mogą pojawiać się szybciej, niż aplikacja jest w stanie je przetworzyć, co wprowadza kolejny ukryty koszt.

    Kolejki, które rosną szybciej, niż są przetwarzane

    Załóżmy, że zdarzenia przychodzą co

    10 ms
    

    ale skuteczne przetworzenie każdego z nich zajmuje

    15 ms
    

    To nie jest już tylko powolna operacja; to kumulacja zadań, która rośnie bez ograniczeń. Nowe dane przychodzą, podczas gdy stare wciąż czekają na przetworzenie, więc interfejs zawsze pokazuje dane z przeszłości. Dla interfejsów w czasie rzeczywistym jest to najgorszy możliwy scenariusz. Użytkownik rzadko potrzebuje wszystkich pośrednich wartości; potrzebuje on najnowszej istotnej wartości. Projekty, które łączą lub odrzucają przestarzałe zdarzenia, utrzymują krótką kolejkę i aktualny wygląd interfejsu.

    Interfejsy umożliwiające komunikację zwiększają liczbę przekroczeń granic

    To zagadnienie nie dotyczy wyłącznie React Native; inżynierowie budujący systemy rozproszone mierzą się z nim codziennie. Porównajmy API modułu, które ustawia jedno pole za każdym razem, gdy jest wywoływane:

    await nativeModule.setName(name);
    await nativeModule.setEmail(email);
    await nativeModule.setAge(age);
    await nativeModule.setCountry(country);
    await nativeModule.setLanguage(language);
    

    z takim, które przyjmuje całą aktualizację jednocześnie:

    await nativeModule.updateProfile({
      name,
      email,
      age,
      country,
      language,
    });
    

    Druga wersja wymaga znacznie mniej przekroczeń granic, aby osiągnąć ten sam rezultat. Zasada jest prosta:

    Rób więcej pracy przy każdym przekroczeniu granicy, ale robić to rzadziej.

    To samo zasada odnosi się do:

    • API HTTP,
    • wywołań mikrosług,
    • dostępu do bazy danych,
    • wątków roboczych,
    • komunikacji między procesami,
    • mostów mobilnych,
    • systemów rozproszonych w ogóle.

    W sieciach komputerowych określa się to jako komunikację rozmowną. Stary most komunikacyjny po prostu przeniósł ten klasyczny problem systemów rozproszonych do pojedynczej aplikacji mobilnej.

    Rozmiar danych to drugi czynnik

    Częstotliwość przesyłania to tylko połowa historii; ważne jest również to, ile danych przekazujemy. Rozważmy przesłanie czegoś takiego przez granicę starego systemu:

    {
      users: [...10000Users]
    }
    

    Wysyłający musi zakodować strukturę, a odbiorca musi ją odbudować i przetworzyć. Duże tablice, głęboko zagnieżdżone obiekty, metadane obrazów, dane binarne oraz duże drzewa konfiguracyjne powodują znaczący dodatkowy obciążenie. Najbardziej narażone na to zadania to:

    • kadry z kamery,
    • bufory audio,
    • przetwarzanie obrazów,
    • dane wejściowe do sztucznej inteligencji,
    • duże wyniki zapytań,
    • złożone animacje,
    • mapy na żywo,
    • strumienie z czujników.

    Dokumentacja architektury React Native przedstawia skrajny, ale pouczający przypadek: buforowanie klatek z kamery w czasie rzeczywistym, które może wynosić dziesiątki megabajtów na klatkę oraz ogromne ilości danych na sekundę. Powtarzające się serializowanie takich danych za pomocą mostu asynchronicznego to po prostu niewłaściwe narzędzie.

    Dlaczego łańcuchy przetwarzania obrazów z kamery ujawniają ten koszt

    Jedna klatka może zawierać megabajty pikseli. Teraz trzeba dostarczać klatki w

    30 FPS
    

    lub nawet

    60 FPS
    

    Jeśli każda z nich musi zostać przekształcona na formę serializowalną przyjazną dla JavaScript i wysłana przez warstwę asynchroniczną, transport staje się wąskim gardłem. Należy unikać takiego podejścia na poziomie każdej pojedynczej klatki:

        Native Camera
             ↓
    Huge serialized payload
             ↓
        JavaScript
             ↓
          Process
             ↓
       Serialized result
             ↓
          Native
    

    Dlatego biblioteki o wysokiej wydajności coraz częściej przenoszą intensywne obliczenia w pobliże pamięci natywnej i zamiast tego oferują JavaScriptowi wąski, efektywny interfejs. Taki kierunek prowadzi bezpośrednio do jednej z najważniejszych zmian w nowoczesnym React Native.

    Jakie zmiany wprowadza JSI

    JSI (JavaScript Interface) umożliwia JavaScriptowi znacznie bardziej bezpośrednią interakcję z kodem natywnym i C++. Zamiast polegać wyłącznie na metodzie serializacji i kolejki w starym Bridge’u:

    JavaScript
         ↓
     Serialize
         ↓
       Bridge
         ↓
    Deserialize
         ↓
      Native
    

    nowa architektura może udostępniać funkcjonalność natywną poprzez bezpośrednie interfejsy:

    JavaScript
         ↓
        JSI
         ↓
    C++ / Native
    

    Zgodnie z dokumentacją React Native, JSI umożliwia JavaScriptowi przechowywanie referencji do obiektów C++ oraz C++ przechowywanie referencji do obiektów JavaScript, dzięki czemu metody mogą być wywoływane bez użycia modelu serializacji Bridge’a. Koncepcyjnie wywołanie metody natywnej zaczyna przypominać wywołanie funkcji w obiekcie hosta, zamiast wysyłania wiadomości do innego procesu. To fundamentalna zmiana w architekturze.

    Dlaczego JSI nie kończy prac nad wydajnością

    Powszechny błędny wniosek brzmi następująco:

    "JSI eliminuje Bridge’a, więc problemy z wydajnością znikają."

    To nieprawda. Transport jest o wiele lepszy, ale słabe projektowanie systemu nadal powoduje opóźnienia. Sto tysięcy synchronicznych wywołań przez JSI:

    100,000 synchronous native calls
    

    Każda z tych metod może być tańsza od wywołania Bridge, ale większość tej pracy nadal jest niepotrzebna. To samo dotyczy przekazywania wielkich obiektów raz po raz, wykonywania intensywnych operacji na CPU w wątku JavaScript, blokowania głównego wątku UI lub renderowania tysięcy komponentów, które nie wymagały renderowania. Dostęp synchroniczny wprowadza nawet nowe ryzyko: powolne synchroniczne wywołanie natywnego kodu teraz blokuje bezpośrednio wątek JavaScript zamiast zostać rozwiązane później.

    Zatem wniosek nie brzmi „Bridge było wolne, JSI jest szybkie”. Jest bliższy temu:

    Każde przechodzenie między różnymi środowiskami wykonawczymi ma swoją cenę. Ustrukturyzuj aplikację tak, aby niepotrzebne przechodzenia w ogóle nie miały miejsca.

    TurboModules: nowoczesny system modułów natywnych

    TurboModules zastępują starszy system Native Modules. Stare moduły były dostępne przez Bridge; nowe integrują się bliżej za pomocą JSI i Codegen, które generuje typowane powiązania na podstawie specyfikacji. Do zalet wymienionych przez React Native należą:

    • synchroniczny dostęp do komponentów natywnych, gdy tego wymaga dany scenariusz,
    • bezpieczeństwo typów po obu stronach granicy,
    • możliwość współpracy z C++,
    • dzielenie się kodem między różnymi platformami,
    • ładowanie modułów w trybie opóźnionym.

    Ładowanie w trybie opóźnionym zasługuje na szczególną uwagę. W starym modelu inicjalizacja modułów natywnych podczas uruchamiania mogła powodować dodatkową pracę przed pokazaniem pierwszej ekranu. Dzięki ładowaniu opóźnionemu moduł jest inicjalizowany dopiero w momencie pierwszego użycia. Zamiast:

    Application starts
            ↓
    Initialize everything
            ↓
    Eventually show UI
    

    proces uruchamiania może wyglądać bardziej tak:

        Application starts
                  ↓
    Initialize required components
                  ↓
               Show UI
                  ↓
    Load additional modules when needed
    

    Takie zmiany wpływają na to, co użytkownicy zauważają znacznie bardziej niż liczby z testów wydajnościowych: na to, jak szybko aplikacja jest gotowa do użycia.

    Fabric: strona renderowania w ramach przebudowy

    Kwarterowanie komunikacji nie było jedyną częścią przebudowaną. Fabric, nowy system renderowania, przenosi większą część infrastruktury renderowania do wspólnego kodu C++ i umożliwia funkcje, które były trudne do zrealizowania w starym modelu asynchronicznym, między innymi:

    • dostęp do układu synchroniczny tam, gdzie jest to konieczne,
    • lepszą interoperacyjność,
    • wielokrotne priorytety aktualizacji,
    • wsparcie dla nowoczesnych funkcji React,
    • bardziej elastyczne planowanie.

    Priorytety są ważne, ponieważ interfejsy nie traktują każdej aktualizacji w ten sam sposób:

    User presses button        → HIGH priority
    Background data refresh    → LOWER priority
    Analytics event            → LOW priority
    Animation                  → HIGH priority
    Prefetching                → LOW priority
    

    Skuteczny renderer nie powinien traktować naciśnięcia przycisku i wysyłki danych analitycznych jako równie pilnych zadań. Priorytetyzacja jest podstawą responsywnego interfejsu użytkownika, a Fabric umożliwia React Native udział w równoległym modelu planowania React. Jeśli chcesz zobaczyć, jak sam React koduje priorytety aktualizacji, artykuł na temat sposobu, w jaki React wykorzystuje maski bitowe do kodowania priorytetów opisuje te mechanizmy.

    Prawdziwym wrogiem jest zmienność granic

    Gdyby cały problem należało streścić w dwóch słowach, byłyby to zmienność granic: ciągłe przekazywanie małych fragmentów informacji tam i z powrotem pomiędzy środowiskami wykonywania.

      JS
      ↓
    Native
      ↓
      JS
      ↓
    Native
      ↓
      JS
      ↓
    Native
    

    Gdy widzisz taki układ, zazwyczaj oznacza to, że odpowiedzialność za dany funkcjonalny element została rozdzielona w niewłaściwym miejscu. Pytanie, które należy zadać, brzmi: gdzie właściwie powinna znajdować się ta praca? Weźmy na przykład przetwarzanie dźwięku. Projekt z dużą ilością operacji wygląda w ten sposób:

    Native microphone
    → JS audio frame
    → calculate
    → native
    → next frame
    → JS
    → calculate
    → native
    

    Lepsze rozdzielenie obowiązków polega na tym, aby przetwarzanie wysokich częstotliwości odbywało się natywnie, a do JavaScripta wysyłane były tylko te wyniki, które wymagają dalszych działań:

    Native microphone
            ↓
    Native processing
            ↓
    Native processing
            ↓
    Native processing
            ↓
    Meaningful result
            ↓
        JavaScript
    

    Ilość obliczeń może być podobna, ale wzorzec komunikacji jest zupełnie inny, podobnie jak obciążenie obu wątków.

    Praktyczne techniki dla efektywniejszych operacji

    Zgrupowanie operacji w jedną wywołanie

    Pętla, która na każdy element oczekuje na wywołanie natywne, jest częstym źródłem problemów:

    for (const item of items) {
      await NativeModule.saveItem(item);
    }
    

    Z 500 elementami może to oznaczać setki oddzielnych operacji przekraczania granic, z których każda wiąże się ze swoimi kosztami czasowymi. Lepsza, natywna API przyjmuje całą kolekcję i pozwala kodowi natywnemu na przetwarzanie jej elementów:

    await NativeModule.saveItems(items);
    

    Różnica w strukturze jest łatwa do sformułowania:

    ❌ 500 boundary crossings × small operation
    
    ✅ 1 boundary crossing × 500 native operations
    

    Grupowanie to jedna z najprostszych i najskuteczniejszych optymalizacji architektonicznych dostępnych obecnie. Jedną z kwestii do zaplanowania jest to, że wywołanie typu batch wymaga jasnego kontraktu błędów, np. informacji o tym, które elementy zawiodły, aby jeden błędny element nie spowodował bezpowrotnego utraty pozostałych.

    Ograniczanie lub łączenie wydarzeń o wysokiej częstotliwości

    Załóżmy, że moduł natywny emituje 120 aktualizacji na sekundę. JavaScript rzadko potrzebuje ich wszystkich. Dla wielu interfejsów około 20 istotnych aktualizacji na sekundę wygląda praktycznie tak samo na ekranie. Zamiast obsługiwać

    120 events / second
    

    można przetwarzać

    20 events / second
    

    lub gromadzić zdarzenia po stronie natywnej i przekazywać tylko najnowszy stan. Wykonywanie ograniczeń natywnie jest zazwyczaj lepsze niż robienie tego w JavaScript, ponieważ zdarzenia utracone w JavaScript już zapłaciły koszt przekierowania. Ten podejście nadaje się do:

    • śledzenia lokalizacji,
    • zdarzeń przewijania,
    • danych z czujników,
    • raportowania postępów,
    • poziomów dźwięku,
    • statystyk sieciowych,
    • ruchu na mapie,
    • żywych paneli kontrolnych.

    Najtańszym zdarzeniem jest to, które nigdy nie zostaje wysłane.

    Przenoś znaczenie, a nie surowe dane

    Zamiast przesyłać coś takiego jak

    10 MB raw dataset
    

    z języka natywnego do JavaScript i proszenia JavaScript o wyznaczenie odpowiedzi, oblicz odpowiedź tam, gdzie dane już się znajdują. Zastąp ten przepływ:

    Native
      ↓
    10 MB data
      ↓
    JavaScript
      ↓
    find average
    

    tym:

            Native
              ↓
      calculate average
              ↓
    JavaScript receives: 42.7
    

    Krótko mówiąc, przenieś

    meaning
    

    Zamiast tego

    raw information
    

    gdy tylko architektura na to pozwala. Wadą jest to, że logika przenosi się do kodu natywnego, który musi być pisany i testowany dla każdej platformy lub w wspólnym C++; należy to stosować tylko w przypadku danych naprawdę dużych lub często używanych.

    Opóźnienie sieciowe to nie opóźnienie interakcji

    Szybki backend może ukryć wolne tempo działania. Załóżmy, że API odpowiada w

    120 ms
    

    a panel monitoringu jest w kolorze zielonym. Jednak po otrzymaniu odpowiedzi nadal musi się wydarzyć wiele rzeczy:

    API Response
        ↓
    JSON Parsing
        ↓
    State Transformation
        ↓
    Redux / Zustand / Context
        ↓
    Component Re-rendering
        ↓
    JS ↔ Native interaction
        ↓
    Layout
        ↓
    UI Commit
    

    Zanim wynik pojawi się na ekranie, użytkownik może już czekać

    200+ ms
    

    Raporty monitoringu backendu wskazują, że wszystko działa szybko; użytkownik natomiast zgłasza, że aplikacja jest powolna. Obie strony mówią prawdę, ponieważ mierzą różne rzeczy. Opóźnienie backendu i opóźnienie interakcji to odrębne wskaźniki, a w przypadku aplikacji mobilnych to właśnie drugie decyduje o tym, czy produkt sprawia dobre wrażenie.

    Testowanie na urządzeniach, które faktycznie posiadają twoi użytkownicy

    Problemy wydajnościowe lubią się ukrywać na sprzęcie deweloperów. Flagowy telefon ma szybki procesor, dużo pamięci, szybkie urządzenie przechowywania, mocny GPU oraz dobrą zdolność chłodzenia, więc wszystko wydaje się w porządku. Użytkownicy końcowi uruchamiają jednak tę samą wersję aplikacji na telefonach z znacznie mniejszymi zasobami, i

    small inefficiency × thousands of operations
    

    nagle problemy stają się widoczne. Testowanie wyłącznie na urządzeniach premium jest ryzykowne. Solidna matryca urządzeń obejmuje przynajmniej:

    High-end device
    Mid-range device
    Lower-end supported device
    

    To ma największe znaczenie, gdy twoja publiczność obejmuje rynki, na których duży odsetek użytkowników korzysta z tanich telefonów Android.

    Mierz wersje wydawnicze, a nie wersje rozwojowe

    Wersje rozwojowe wykonywają dodatkową pracę związaną z ostrzeżeniami, debugowaniem i narzędziami, a oficjalny przewodnik dotyczący wydajności wyraźnie zaleca mierzenie przy użyciu wersji wydawniczych. Zanim doszukasz się winy u samego React Native, przeprowadź analizę wydajności

    Release / Production build
    

    zamiast polegać wyłącznie na

    Development build
    

    W przeciwnym razie możesz spędzić godziny na naprawianiu spowolnienia, którego żaden użytkownik nigdy nie doświadczy.

    Listwa diagnostyczna dla wolnego ekranu

    Rozmieszczenie funkcji useMemo() w powolnym interfejsie rzadko jest właściwym pierwszym krokiem. Zamiast tego rozważ te pytania.

    1. Czy wątek JavaScript jest zajęty?

    Szukaj:

    • drogich pętli,
  • synchroniczne parsowanie dużych ilości danych,
  • skomplikowane transformacje stanu,
  • niepotrzebne ponowne renderowanie,
  • nadmierne logowanie,
  • bardzo duże drzewa komponentów.
  • 2. Czy wątek UI jest zajęty?

    Szukaj:

    • drogiego renderowania w języku natywnym,
    • skomplikowanych układów,
    • zbyt dużych obrazów,
    • głębokich hierarchii widoku,
    • animacji uruchamianych na niewłaściwym wątku.

    3. Czy zbyt często przekraczany jest limit języka natywnego?

    Szukaj operacji w obie strony o takiej strukturze, szczególnie tych wykonywanych często:

    Native → JS → Native
    

    4. Jak duże są dane przekazywane?

    Liczba wywołań nie wystarcza. Pomierz, ile danych przepływa przez każde z nich.

    5. Czy wywołania można grupować?

    Gdzie to możliwe, zamień

    many small operations
    

    na

    one meaningful operation
    

    6. Czy JavaScript w ogóle potrzebuje tych danych?

    Jeśli narzędzie typu native generuje 1000 punktów danych, a JavaScript wykorzystuje tylko ich średnią, wysyłaj tę średnią.

    7. Czy musi to nastąpić natychmiast?

    Zachowaj

    User-critical work
    

    poza

    background work
    

    Analityka nigdy nie powinna konkurować z informacjami zwrotnymi dotyczącymi interakcji użytkownika w tym samym kadru.

    8. Czy nadal istnieje zależność od architektury legacy?

    Obecne dokumentacje React Native określają Legacy Native Modules oraz Legacy Native Components jako przestarzałe i zalecają użycie Turbo Native Modules oraz Fabric Native Components tam, gdzie to możliwe. Wybierając pakiet do funkcji wymagającej wysokiej wydajności, sprawdź, jaką architekturę obsługuje; biblioteka działająca przez warstwę interop może nie przynieść oczekiwanych korzyści. Status wsparcia zmienia się szybko, dlatego sprawdź go w aktualnych notatkach wydawniczych biblioteki.

    Zasada przydziału zadań

    Dla każdej funkcji wymagającej dobrej wydajności najpierw należy zdecydować, która część aplikacji jest faktycznie odpowiedzialna za jej wykonywanie.

    • Gdy zadanie należy do JavaScriptu (reguły biznesowe, stan interfejsu, orkiestracja), niech pozostanie tam i unikaj korzystania z kodu natywnego na każdym kroku.
    • Gdy zadanie należy do kodu natywnego (pętle czujników, przetwarzanie klatek, buforzy audio), niech te intensywnie wykorzystywane ścieżki działają w formie kodu natywnego i pozostaną tam.
    • Przekraczaj granice tylko wtedy, gdy istnieje coś istotnego do przekazania, a nie po to, by każdy krok pośredni był widoczny z obu stron.

    Zdrowy podział zazwyczaj wygląda w ten sposób:

    ┌───────────────────────┐
    │      JavaScript       │
    │                       │
    │ Business decisions    │
    │ Application state     │
    │ UI orchestration      │
    └───────────┬───────────┘
                │
         Meaningful calls
                │
    ┌───────────▼───────────┐
    │        Native         │
    │                       │
    │ Platform APIs         │
    │ High-frequency work   │
    │ Hardware interaction  │
    └───────────────────────┘
    

    a nie w taki sposób, setki razy na sekundę:

    JS ↔ Native ↔ JS ↔ Native ↔ JS ↔ Native
    

    Co faktycznie naprawia nowa architektura

    Nowa architektura eliminuje kilka ograniczeń strukturalnych starego modelu. Zamiast budować wszystko wokół obowiązkowego, serializowanego, asynchronicznego mostu, nowoczesny React Native umożliwia bezpośrednie połączenie poprzez JSI:

           JSI
            ↓
    Direct JS/native interfacing
    

    razem z innymi elementami, które funkcjonują na jego bazie:

           TurboModules
                ↓
    Modern native module system         Fabric
                ↓
    Modern rendering architecture         Codegen
                ↓
    Typed interfaces between environments   Modern scheduling
                ↓
    Better prioritization of work
    

    Zespół React Native opisuje usunięcie mostu jako zmniejszenie obciążenia związkanego z serializacją, poprawę szybkości uruchamiania aplikacji oraz umożliwienie bardziej bezpośredniej komunikacji między JavaScriptem a środowiskiem uruchomieniowym natywnym. Głębszą zmianą jest elastyczność – framework ma teraz większą kontrolę nad:

    • czasem wykonywania zadań,
    • miejscem wykonywania zadań,
    • sposobem dostępu do funkcjonalności natywnych,
    • tym, które aktualizacje mają pierwszeństwo.

    To zasada odnosi się do każdej granicy

    Cofnijmy się na chwilę od React Native – wzorzec jest ogólny: każda granica architektoniczna ma swoją cenę. Pomyślmy o tych przejściach:

    Browser → API
    Service → Service
    Application → Database
    Process → Worker
    JavaScript → Native
    Application → Redis
    Backend → Message Broker
    

    Systemy są często projektowane tak, jakby wywołania pomiędzy tymi granicami zachowywały się jak zwykłe wywołania funkcji w obrębie procesu. Tak nie jest. Każda granica wprowadza pewną kombinację następujących elementów:

    • serializacji,
    • kopiowania,
    • planowania,
    • umieszczania w kolejce,
    • pracy w sieci,
    • synchronizacji,
    • zmiany kontekstu,
    • obsługi błędów,
    • zarządzania pamięcią.

    Bridge sprawia, że ta zasada staje się wyjątkowo widoczna w aplikacjach mobilnych.

    Napraw architekturę przed mikrooptymalizacjami

    Kuszące jest uciekanie się najpierw do takich narzędzi:

    useMemo(...)
    useCallback(...)
    React.memo(...)
    

    Mają one rzeczywiste zastosowania. Ale jeśli aplikacja przesyła 5 MB niepotrzebnych danych między różnymi fazami działania 30 razy na sekundę, to oszczędzenie 0,2 ms dzięki useMemo() nie będzie miało znaczenia. Prace nad wydajnością są najskuteczniejsze, gdy są realizowane w takiej kolejności:

    Architecture
        ↓
    Data flow
        ↓
    Scheduling
        ↓
    Rendering
        ↓
    Algorithms
        ↓
    Micro-optimizations
    

    a nie odwrotnie.

    Główne wnioski

    Legacy Bridge nie był po prostu słabym rozwiązaniem inżynieryjnym. Umożliwiał on zachowanie oddzielenia JavaScript od kodu natywnego przy jednoczesnej komunikacji asynchronicznej i w znacznym stopniu przyczynił się do rozwoju React Native. Gdy aplikacje zaczęły wykorzystywać komunikację w czasie rzeczywistym, zaawansowane animacje, pipeline’y do obsługi kamery i sztucznej inteligencji, mapy, przetwarzanie dźwięku, złożone gesty oraz inne interakcje natywne o wysokiej częstotliwości, granice serializowanej komunikacji asynchronicznej szybko stały się problematyczne. JSI, TurboModules, Fabric oraz przeprojektowany model planowania i renderowania eliminują wiele z tych ograniczeń, ale nie same podstawowe aspekty ekonomiczne.

    • Mierz cały proces interakcji, a nie tylko wywołanie w kodzie natywnym lub odpowiedź API.
    • Kieruj się limitem czasowym klatki: około 16,67 ms przy 60 Hz i około 8,33 ms przy 120 Hz.
    • Grupuj operacje, ograniczaj lub łącz strumienie zdarzeń na poziomie źródła oraz wysyłaj wyniki zamiast surowych danych.
  • Określ, który silnik wykonawczy odpowiada za każdą funkcję, i trzymaj pętle o wysokiej częstotliwości wykonywania po jednej stronie.
  • Zbadaj wydania aplikacji na urządzeniach średniej i niskiej klasy pod kątem wydajności, sprawdzając, czy zależności nadal polegają na starych modułach.
  • Rozpatruj JSI jako szybszą metodę przekazywania danych, a nie jako uprawnienie do wykonywania większej liczby wywołań.
  • Gdy aplikacja wydaje się wolna, mimo że każdy z komponentów działa szybko, sprawdź luki pomiędzy nimi. Często kosztowną częścią nie jest sama praca, lecz odległość, jaką muszą przebyć dane, aby do niej dotrzeć.