Startseite / Artikel

Wo sich die Latenz in React Native versteckt: Grenzüberschreitungen, nicht langsamer Code

Erfahren Sie, warum schnelle APIs und gememorierte Komponenten in React Native dennoch träge wirken können, sowie wie Batching, Throttling und das Design aus der JSI-Ära die Kosten zwischen verschiedenen Laufzeiten reduzieren.

Verlag: REACTAPP.TOP19 Min. Lesezeit4454 Wörter

Quellen: The Hidden Latency Cost in React Native Bridge Architectures

Ein React Native-Bildschirm kann träge wirken, selbst wenn alle gemessenen Werte in Ordnung sind: Die API antwortet innerhalb von etwa 100 ms, die Abfragen sind indiziert, Komponenten werden gememorisert und Bilder stammen aus dem Cache. Dennoch braucht ein Tap etwas zu lange, um erfasst zu werden, eine Bottom Sheet funktioniert nicht reibungslos, Kartenmarker folgen der Realität nicht rechtzeitig, eine Animation, die auf einem High-End-Telefon fehlerfrei ist, stockt auf einem günstigen Android-Gerät, und eine Welle an Sensorereignissen blockiert die Benutzeroberfläche. In vielen dieser Fälle verstreicht die fehlende Zeit an einem Ort, den Teams selten überwachen: beim Übergang zwischen dem JavaScript-Laufzeitumfeld und dem nativen Code.

Dieser Leitfaden erläutert, wie dieser Übergang unter der alten Bridge funktionierte, warum seine Kosten sich summieren anstatt in einem einzelnen Aufruf sichtbar zu werden, was die Neue Architektur (JSI, TurboModules, Fabric sowie der neu gestaltete Renderer) tatsächlich verändert, und welche Entwurfspraktiken dafür sorgen, dass der Datenverkehr zwischen verschiedenen Laufzeiten unabhängig von der verwendeten Architektur günstig bleibt. Die Neue Architektur ist seit React Native 0.76 die Standardlösung, doch die Bridge lohnt sich weiterhin zum Verständnis: Viele Produktionsanwendungen enthalten noch alte Module, Interoperabilitätsschichten, ältere Bibliotheken sowie Entwurfsmuster, die von der Bridge geprägt wurden. Noch wichtiger ist, dass das Entfernen der Bridge eine schlecht entworfene Kommunikation zwischen den Laufzeiten nicht kostenlos macht.

Zwei Ausführungsumgebungen in einer App

Ein nützliches mentales Modell ist, dass eine React Native-App in mindestens zwei Umgebungen läuft. Ihre Komponenten, der Zustand, die Geschäftsregeln sowie die Netzwerkkommunikation befinden sich auf der JavaScript-Seite; alles, was mit dem Betriebssystem zu tun hat, befindet sich auf der nativen Seite.

┌──────────────────────────────┐
│        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                      │
└──────────────────────────────┘

React-Code kann in JavaScript ausgeführt werden, doch fast jede sinnvolle Funktion benötigt letztendlich die Plattform. Typische Gründe dafür sind:

  • Das Darstellen nativer UI-Elemente,
  • Das Öffnen der Kamera,
  • Das Lesen von GPS-Koordinaten,
  • Der Zugriff auf das Dateisystem,
  • Das Auslösen von Haptik-Effekten,
  • Die Kommunikation mit Bluetooth-Geräten,
  • Der Einsatz sicheren Speichers,
  • Die Aufrufe von Drittanbieter-SDKs für die native Umgebung,
  • Das Empfangen von Sensordaten,
  • Die Nutzung weiterer plattformspezifischer APIs.

So bewegen sich die Daten ständig zwischen den beiden Umgebungen. In der klassischen Architektur floss ganz dieser Datenverkehr über die React Native Bridge.

Wie die herkömmliche Bridge einen Aufruf übertrug

Das ursprüngliche Design hielt die Ausführung in JavaScript und die native Ausführung absichtlich voneinander getrennt. Eine einzelne Hin- und Rückreise sah ungefähr so aus:

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

Die entscheidende Eigenschaft war, dass der gesamte Austausch asynchron ablief. Argumente wurden serialisiert, in eine Warteschlange gelegt, über die Grenze übertragen, wieder deserialisiert und auf der nativen Seite verarbeitet; jedes Ergebnis machte anschließend denselben Weg zurück. Die Entwickler des React Native-Teams bezeichneten in ihrer neuen Architektur die Serialisierung sowie das asynchrone Brückenbauverfahren als zentrale Einschränkungen des alten Modells.

Für gelegentliche Aufrufe ist dieser Aufwand unbedenklich. Das Schreiben einer Zeichenkette in den Zwischenablage ist ein gutes Beispiel:

await Clipboard.setString("Hello");

Egal, wie hoch die Kosten für eine Überquerung hier sind – kein Benutzer wird sie je wahrnehmen. Probleme entstehen, wenn die Grenze zu einem Hochfrequenzkanal wird, auf den die App ständig zurückgreift.

Eine Anfrage ist günstig; das Muster ist es, was schadet

In Diskussionen über die Leistung von Bridge wird oft angenommen, dass jede einzelne Anfrage unverschämt teuer sei. Diese Sichtweise verfehlt den Kern der Sache. Tatsächliche Verlangsamungen entstehen in der Regel durch die Kombination mehrerer Faktoren, die sich gegenseitig verstärken:

Frequenz × Größe des Datenträgers × Planungsspannung × Synchronisierungsanforderungen

Nehmen wir an, eine einzelne Überquerung kostet fast nichts. Skalieren wir nun die Anzahl:

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

Machen Sie dann jede Payload größer, senden Sie kontinuierlich Aktualisierungen, während der Benutzer scrollt, oder erfordern Sie eine Rückreise von Native → JavaScript → Native, bevor der nächste visuelle Zustand dargestellt werden kann. Ein Aufwand, der für sich genommen vernachlässigbar war, liegt nun auf dem kritischen Renderpfad. Keine einzelne Funktion erscheint im Profiler als langsam; die Latenz ist im ganzen System verteilt, genau deshalb bleibt sie verborgen.

Betrachten Sie die Interaktionslatenz als Pipeline

Es hilft, jede Stufe aufzulisten, durch die eine Interaktion geht:

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

Teams neigen dazu, einen Parameter für sich genommen zu optimieren – in der Regel die native Verarbeitung:

Native Work

oder die JavaScript-Verarbeitung:

JS Work

Der Benutzer erlebt jedoch nur die Summe aller Phasen:

TOTAL LATENCY

Betrachten Sie eine native Operation, die in folgender Zeit abgeschlossen wird:

2 ms

Allein schon ist das eine hervorragende Zahl. Doch die detaillierte Aufschlüsselung könnte wie folgt aussehen:

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

Die „2 ms lange Operation“ hat sich zu einer Interaktion von etwa 17 ms entwickelt. In den React Native-Dokumentationen wird ein Frame-Budget von rund 16,67 ms bei 60 FPS beschrieben; wird dieses überschritten, kann ein Frame weggelassen werden. Viele aktuelle Handys erneuern den Bildschirm schneller als 60 Hz, was das Budget noch weiter verringert.

Frame-Budgets lassen wenig Spielraum für Overhead

Bei 60 Hz ergibt sich folgende Rechnung:

1000 ms / 60 ≈ 16.67 ms

Das ergibt ungefähr

16.67 ms

für die Erstellung jedes Frames. Bei 120 Hz halbiert sich dieser Wert:

1000 ms / 120 ≈ 8.33 ms

Etwa acht Millisekunden sind sehr wenig. Wenn einige davon unnötigerweise für den Datenaustausch zwischen den Laufzeiten verwendet werden, verbraucht die Architektur selbst das Rendering-Budget. Die Symptome sind bekannt:

  • stotternde Animationen,
  • Grüße, die verzögert reagieren,
  • langsame Übergänge bei der Navigation,
  • ausfallende Frames,
  • verzögerte Rückmeldung bei Berührungen,
  • unregelmäßiges Scrollen,
  • UI-Updates, die einen Frame später eintreffen als erwartet.
  • In den Leistungsbeschreibungen wird außerdem zwischen JavaScript-Thread-FPS und UI-(Haupt-)Thread-FPS unterschieden. Wenn der JS-Thread überlastet ist, kann er Berührungsereignisse oder von JavaScript gesteuerte UI-Updates nicht rechtzeitig verarbeiten, während interaktive Aktionen, die nativ ablaufen, wie das Scrollen einer nativen Liste, weiterhin reibungslos funktionieren. Diese Trennung erklärt ein klassisches Debugging-Problem: Ein Teil des Bildschirms wirkt flüssig, während ein anderer eingefroren erscheint.

    Szenario: Streamen von Standortdaten in eine Karte

    Stellen Sie sich eine Fahrdienst-App vor, die einen kontinuierlichen Strom an GPS-Daten erhält. Die einfache Vernetzung sieht wie folgt aus:

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

    Laufen Sie nun diese Schleife für jede Korrektur ab. Weder die Berechnung der Position noch das Verschieben eines Markers ist an sich aufwändig, doch die Architektur erzeugt eine wiederholte Hin- und Rückreise:

    Native → JS → Native
    

    Es wird immer wieder ausgeführt, solange der Bildschirm geöffnet ist. Zeigen Sie viele Fahrer gleichzeitig an – der Verkehr verdoppelt sich dadurch:

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

    Das Emittern von hundert kleinen Aktualisierungen nacheinander kann weitaus kostspieliger sein als das Emittern einer einzigen, gut vorbereiteten Batch-Datei, die sie alle beschreibt. Die Ursache liegt in der Struktur des Datenflusses und nicht in Reacts Rendering-Verfahren.

    Szenario: Sensorströme

    Daten des Beschleunigungsmessers und des Gyroskops folgen demselben Muster. Angenommen, der native Code gibt aus:

    60 events / second
    

    Jedes Ereignis gelangt in JavaScript, wird verarbeitet und kann eine neue Aktualisierung zurück zur nativen Benutzeroberfläche auslösen. Das Ergebnis ist ein Echtzeit-Pipeline-Verlauf, der über die Grenze hinweg verläuft:

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

    Bei einer niedrigen Ereignisrate funktioniert dies hervorragend. Bei hoher Rate, insbesondere auf schwächerer Hardware, können die Ereignisse schneller eintreffen, als die Anwendung sie verarbeiten kann, was einen weiteren verborgenen Kostenfaktor mit sich bringt.

    Warteschlangen, die schneller wachsen als sie geleert werden

    Nehmen wir an, Ereignisse kommen alle

    10 ms
    

    aber die effektive Verarbeitung jedes einzelnen erfordert

    15 ms
    

    Es handelt sich hier nicht mehr nur um eine langsame Operation; es entsteht ein unkontrolliert wachsender Rückstau. Neue Messwerte kommen an, während alte noch verarbeitet werden müssen, sodass die Benutzeroberfläche stets vergangene Werte anzeigt. Für Echtzeit-Interfaces ist das das schlimmste mögliche Szenario. Ein Benutzer benötigt selten alle Zwischenwerte; er braucht vielmehr den neuesten relevanten Wert. Designs, die überholte Ereignisse zusammenfassen oder entfernen, halten die Warteschlange kurz und die Anzeige aktuell.

    Sprachfähige Schnittstellen erhöhen die Anzahl der Übergriffe

    Dieser Leitsatz gilt nicht nur für React Native; Ingenieure, die verteilt arbeitende Systeme entwickeln, sind täglich damit konfrontiert. Vergleichen Sie eine Module-API, die bei jedem Aufruf nur ein Feld setzt:

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

    mit einer, die den gesamten Update-Vorgang auf einmal entgegennimmt:

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

    Die zweite Version benötigt für dasselbe Ergebnis deutlich weniger Übergriffe über die Grenze. Die Faustregel ist einfach:

    Erledigen Sie mehr Arbeit pro Übergriff und tun Sie dies seltener.

    Das gleiche Prinzip gilt für:

    • HTTP-APIs,
    • Aufrufe von Microservices,
    • Datenbankzugriffe,
    • Worker-Threads,
    • Interprozesskommunikation,
    • Mobilbrücken,
    • verteilt arbeitende Systeme im Allgemeinen.

    In der Netzwerktechnik wird dies als gesprächige Kommunikation bezeichnet. Die herkömmliche Brücke brachte dieses klassische Problem von verteilten Systemen einfach in eine einzige mobile Anwendung.

    Die Größe des Payloads ist die zweite Dimension

    Wie oft Daten übertragen werden, ist nur die halbe Geschichte; auch die Menge der übertragenen Daten spielt eine Rolle. Betrachten Sie zum Beispiel den Versand solcher Daten über die herkömmliche Grenze:

    {
      users: [...10000Users]
    }
    

    Der Sender muss die Struktur kodieren, und der Empfänger muss sie wieder aufbauen und verarbeiten. Große Arrays, tief verschachtelte Objekte, Bildmetadaten, binäre Daten sowie große Konfigurationsbäume fügen alle messbare Zusatzbelastungen hinzu. Die Arbeitslasten, die am stärksten davon betroffen sind, sind:

    • Kamerabilder,
    • Audiopuffer,
    • Bildverarbeitung,
    • Eingaben für maschinelles Lernen,
    • große Abfrageresultate,
    • komplexe Animationen,
    • Echtzeitkarten,
    • Sensordatenströme.

    Die Dokumentation zur React Native-Architektur verwendet einen extremen, aber aufschlussreichen Fall: Echtzeit-Kamerabildspeicher, die pro Bild mehrere Dutzend Megabyte und pro Sekunde enorme Datenmengen umfassen können. Solche Daten wiederholt über eine asynchrone Brücke zu serialisieren, ist einfach das falsche Werkzeug.

    Warum Kamerapipelines die Kosten offensichtlich machen

    Ein einzelnes Bild kann Megabyte an Pixeln enthalten. Nun werden die Bilder bei

    30 FPS
    

    oder sogar

    60 FPS
    

    Falls jedes dieser Bilder in eine für JavaScript geeignete, serialisierbare Form umgewandelt und über eine asynchrone Schicht gesendet werden muss, wird der Transport zum Engpass. Man sollte diese Vorgehensweise pro Bild vermeiden:

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

    Deshalb platzieren hochleistungsstarke Bibliotheken zunehmend die aufwändige Verarbeitung in der Nähe des nativen Speichers und bieten JavaScript stattdessen eine enge, effiziente Schnittstelle an. Diese Richtung führt direkt zu einer der bedeutendsten Veränderungen in modernem React Native.

    Welche Änderungen gibt es bei JSI?

    JSI (JavaScript Interface) ermöglicht es JavaScript, viel direkter mit nativem und C++-Code zu interagieren. Anstatt sich vollständig auf den Prozess der Serialisierung und Verarbeitung in der alten Bridge zu verlassen:

    JavaScript
         ↓
     Serialize
         ↓
       Bridge
         ↓
    Deserialize
         ↓
      Native
    

    kann die neue Architektur native Funktionalitäten über direkte Schnittstellen bereitstellen:

    JavaScript
         ↓
        JSI
         ↓
    C++ / Native
    

    Laut den React Native-Dokumentationen ermöglicht JSI es JavaScript, Referenzen auf C++-Objekte zu halten, und C++, Referenzen auf JavaScript-Objekte zu halten. Dadurch können Methoden ohne das Serialisierungsmodell der Bridge aufgerufen werden. Konzeptionell ähnelt das Aufrufen einer nativen Methode eher dem Aufruf einer Funktion in einem Host-Objekt als dem Senden einer Nachricht an einen anderen Prozess. Das stellt einen grundlegenden Wandel in der Architektur dar.

    Warum JSI die Leistungsprobleme nicht löst

    Eine häufige Fehlinterpretation lautet wie folgt:

    "JSI beseitigt die Bridge, daher sind die Leistungsprobleme verschwunden."

    Das ist nicht der Fall. Der Transport ist zwar viel besser, doch ein schlechtes Systemdesign verursacht weiterhin Verzögerungen. Hunderttausend synchrone Aufrufe über JSI:

    100,000 synchronous native calls
    

    Jeder dieser Ansätze mag günstiger sein als ein Bridge-Aufruf, doch der größte Teil der damit verbundenen Arbeit ist dennoch unnötig. Dasselbe gilt für das wiederholte Übergeben großer Objekte, die Ausführung rechenintensiver Logik im JavaScript-Thread, das Blockieren des Haupt-UI-Threads oder die Darstellung von Tausenden von Komponenten, die eigentlich nicht dargestellt werden mussten. Synchroner Zugriff bringt sogar ein neues Risiko mit sich: Ein langsamer synchroner native Aufruf blockiert nun direkt den JavaScript-Thread anstelle dessen, dass er später abgewickelt wird.

    Daher lautet die Schlussfolgerung nicht „Bridge war langsam, JSI ist schnell“. Es liegt eher daran:

    Jeder Übergang zwischen verschiedenen Laufzeiten kostet etwas. Strukturieren Sie die Anwendung so, dass unnötige Übergänge niemals stattfinden.

    TurboModules: das moderne native Modulsystem

    TurboModules ersetzen das veraltete Native Modules-System. Die alten Module wurden über den Bridge bereitgestellt; die neuen integrieren sich enger durch JSI und Codegen, welches typisierte Bindings aus einer Spezifikation erzeugt. Zu den Vorteilen, die React Native aufzählt, gehören:

    • synchroner Zugriff auf native Funktionen, wenn ein Anwendungsfall dies erfordert,
    • Typsicherheit über die Grenzen hinweg,
    • Interoperabilität mit C++,
    • Code-Teilung zwischen Plattformen,
    • verspätete Moduleladung.

    Die verspätete Ladung verdient besondere Aufmerksamkeit. Im alten Modell konnte die Initialisierung der nativen Module beim Start vor dem Auftauchen des ersten Bildschirms zusätzliche Arbeit verursachen. Bei der verspäteten Ladung wird ein Modul erst dann initialisiert, wenn es zum ersten Mal verwendet wird. Anstelle von:

    Application starts
            ↓
    Initialize everything
            ↓
    Eventually show UI
    

    kann der Start eher so aussehen:

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

    Änderungen wie diese beeinflussen etwas, was Nutzer weitaus stärker wahrnehmen als Benchmark-Zahlen: wie schnell die App einsatzbereit erscheint.

    Fabric: Die Darstellung im Zuge der Neugestaltung

    Nicht nur die Kommunikationsschicht wurde neu aufgebaut. Fabric, das neue Darstellungssystem, verlagert einen größeren Teil der Darstellungsinfrastruktur in gemeinsam genutztes C++ und ermöglicht Funktionen, die im alten asynchronen Modell schwierig umzusetzen waren, darunter:

    • synchronen Zugriff auf das Layout dort, wo er benötigt wird,
    • bessere Interoperabilität,
    • mehrere Aktualisierungs-Prioritäten,
    • Unterstützung für moderne React-Funktionen,
    • flexiblere Scheduling-Möglichkeiten.

    Prioritäten sind wichtig, weil die Schnittstellen nicht jede Aktualisierung auf dieselbe Weise behandeln:

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

    Ein leistungsstarker Renderer sollte einen Button-Druck und einen Analytics-Ping nicht als gleich dringend behandeln. Priorisierung ist die Grundlage einer responsiven Benutzeroberfläche, und Fabric ermöglicht es React Native, am koncurrenten Scheduling-Modell von React teilzunehmen. Wenn Sie erfahren möchten, wie React selbst die Priorität von Updates kodiert, erklärt der Artikel zu wie React-Lanes Bitmasken zur Kodierung der Priorität verwenden die zugrunde liegenden Mechanismen.

    Boundary Churn ist der eigentliche Feind

    Falls das ganze Problem auf zwei Worte reduziert werden müsste, wären es Boundary Churn: das wiederholte Hin- und Herübertragen kleiner Informationsfragmente zwischen Ausführungsumgebungen.

      JS
      ↓
    Native
      ↓
      JS
      ↓
    Native
      ↓
      JS
      ↓
    Native
    

    Wenn man diese Struktur sieht, bedeutet das in der Regel, dass die Verantwortung für eine Funktion an den falschen Ort aufgeteilt wurde. Die Frage ist, wo die Arbeit eigentlich stattfinden sollte. Nehmen wir die Audiospeicherung: Ein design, das viel Rechenlast verursacht, sieht so aus:

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

    Eine bessere Aufteilung behält die Hochfrequenzverarbeitung im nativen Code und sendet an JavaScript nur die Ergebnisse, auf die reagiert werden muss:

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

    Die Rechenmenge mag ähnlich sein, doch das Kommunikationsmuster ist völlig unterschiedlich, genauso wie die Last auf beiden Threads.

    Praktische Techniken für günstigere Abläufe

    Batch-Operationen in einen einzigen Aufruf zusammenfassen

    Eine Schleife, die für jedes Element einen nativen Aufruf abwartet, ist eine häufige Quelle für Rechenlast:

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

    Mit 500 Elementen sind potenziell Hunderte von separaten Überquerungen möglich, jede mit ihrem eigenen Planungsaufwand. Eine bessere native API nimmt die gesamte Sammlung entgegen und lässt den nativen Code die Iteration durchführen:

    await NativeModule.saveItems(items);
    

    Der Unterschied in der Struktur lässt sich leicht beschreiben:

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

    Batching gehört zu den einfachsten und effektivsten verfügbaren architektonischen Optimierungen. Ein Designaspekt, den man berücksichtigen sollte: Eine Batch-Aufrufung benötigt einen klaren Fehlerkontrakt, beispielsweise die Angabe, welche Elemente fehlgeschlagen sind, damit ein fehlerhaftes Element nicht den Rest stillschweigend ignoriert.

    Hochfrequente Ereignisse drosseln oder zusammenführen

    Nehmen wir an, ein natives Modul gibt 120 Aktualisierungen pro Sekunde aus. JavaScript benötigt selten alle davon. Für viele Benutzeroberflächen sehen etwa 20 sinnvolle Aktualisierungen pro Sekunde auf dem Bildschirm praktisch identisch aus. Anstatt diese alle zu verarbeiten

    120 events / second
    

    könnte man sie bearbeiten

    20 events / second
    

    oder Ereignisse auf der nativen Seite ansammeln und nur den neuesten Zustand weiterleiten. Das Throttling auf nativer Ebene durchzuführen ist in der Regel besser als im JavaScript, denn Ereignisse, die im JavaScript verloren gehen, haben bereits den Übertragungskostenaufwand in Kauf genommen. Dieser Ansatz eignet sich für:

    • Ortsteuerung,
    • Scroll-Ereignisse,
    • Sensordaten,
    • Fortschrittsberichte,
    • Audioniveaus,
    • Netzwerkstatistiken,
    • Kartenbewegungen,
    • Live-Dashboards.

    Das günstigste Ereignis ist das, das niemals gesendet wird.

    Übertragen Sie die Bedeutung, nicht die Rohdaten

    Anstatt etwas wie

    10 MB raw dataset
    

    von der nativen auf die JavaScript-Sprache zu übertragen und das JavaScript um eine Berechnung zu bitten, berechnen Sie die Antwort dort, wo sich die Daten bereits befinden. Ersetzen Sie diesen Ablauf:

    Native
      ↓
    10 MB data
      ↓
    JavaScript
      ↓
    find average
    

    durch diesen:

            Native
              ↓
      calculate average
              ↓
    JavaScript receives: 42.7
    

    Kurz gesagt: Übertragen Sie

    meaning
    

    anstelle von

    raw information
    

    wenn die Architektur es zulässt. Der Kompromiss besteht darin, dass die Logik in nativen Code übertragen wird, der für jede Plattform einzeln geschrieben und getestet werden muss oder in gemeinsamem C++ implementiert wird; verwenden Sie dies nur für Daten, die tatsächlich groß oder häufig verwendet werden.

    Netzwerkverzögerung ist keine Interaktionsverzögerung

    Ein schneller Backend kann eine langsame Benutzererfahrung verbergen. Angenommen, die API antwortet in

    120 ms
    

    und das Überwachungsdashboard zeigt Grün an. Nachdem die Antwort eingetroffen ist, muss jedoch noch viel geschehen:

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

    Bis das Ergebnis auf dem Bildschirm erscheint, könnte der Benutzer bereits gewartet haben

    200+ ms
    

    Die Überwachungsberichte des Backends besagen, dass alles schnell ist; der Benutzer berichtet jedoch, dass die App langsam ist. Beide haben recht, denn sie messen unterschiedliche Dinge. Die Latenz des Backends und die Interaktionslatenz sind separate Kennzahlen, und bei mobilen Apps ist in der Regel die zweite das entscheidende Kriterium dafür, ob das Produkt angenehm zu nutzen ist.

    Testen Sie auf den Geräten, die Ihre Nutzer tatsächlich besitzen

    Leistungsprobleme verstecken sich gerne auf dem Hardware-Setup der Entwickler. Ein Flagship-Telefon verfügt über einen schnellen CPU, ausreichend Speicher, schnelles Speichersystem, eine leistungsstarke GPU sowie ausreichend Kühlkapazität, sodass alles in Ordnung erscheint. Die echten Nutzer verwenden dann dieselbe Version auf Geräten mit weitaus geringeren Ressourcen, und

    small inefficiency × thousands of operations
    

    werden die Probleme plötzlich sichtbar. Das Testen ausschließlich auf Premium-Geräten ist riskant. Eine solide Gerätematrix umfasst mindestens:

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

    Dies ist besonders wichtig, wenn Ihre Zielgruppe Märkte umfasst, in denen günstige Android-Handys einen großen Anteil der Nutzer ausmachen.

    Messen Sie Release-Builds, nicht Entwicklungsbuilds

    Entwicklungsbuilds erledigen zusätzliche Aufgaben für Warnungen, Debugging und Tools, und die offizielle Leitlinie empfiehlt ausdrücklich das Messen mit Release-Builds. Bevor Sie zu dem Schluss kommen, dass React Native an sich langsam ist, führen Sie eine Profilierung durch

    Release / Production build
    

    anstatt sich nur auf ein

    Development build
    

    Andernfalls können Sie Stunden damit verbringen, eine Verlangsamung zu beheben, der kein Nutzer jemals begegnen würde.

    Diagnose-Checkliste für eine langsame Anzeige

    Das Hinzufügen von useMemo() in einer langsamen Anzeige ist selten der richtige erste Schritt. Beantworten Sie stattdessen diese Fragen.

    1. Ist der JavaScript-Thread beschäftigt?

    Achten Sie auf:

    • teure Schleifen,
  • synchrone Verarbeitung großer Datenmengen,
  • aufwändige Zustandsumwandlungen,
  • vermeidbare erneute Darstellungen,
  • übermäßiges Protokollieren,
  • sehr große Komponentenbäume.
  • 2. Ist der UI-Thread beschäftigt?

    Achten Sie auf:

    • teure native Darstellung,
    • komplexe Layouts,
    • zu große Bilder,
    • tiefe Ansichtshierarchien,
    • Animationen, die auf dem falschen Thread laufen.

    3. Wird die native Grenze zu oft überschritten?

    Suchen Sie nach solchen Hin- und Rückwegvorgängen, insbesondere solchen in hoher Frequenz:

    Native → JS → Native
    

    4. Wie groß sind die Datenmengen?

    Es reicht nicht aus, nur die Anrufe zu zählen. Messen Sie, wie viel Daten bei jedem Anruf übertragen wird.

    5. Können die Anrufe gebündelt werden?

    Wo immer möglich, wandeln Sie

    many small operations
    

    in

    one meaningful operation
    

    6. Braucht JavaScript diese Daten überhaupt?

    Falls die native Implementierung 1.000 Datensätze erzeugt und JavaScript nur ihren Durchschnitt verwendet, senden Sie den Durchschnitt.

    7. Muss das sofort geschehen?

    Behalten

    User-critical work
    

    abgesehen von

    background work
    

    Analytics sollten niemals im selben Frame mit der Touch-Feedback-Funktion konkurrieren.

    8. Gibt es noch Abhängigkeiten von der veralteten Architektur?

    Die aktuellen React Native-Dokumentationen bezeichnen Legacy Native Modules und Legacy Native Components als veraltet und empfehlen bei Bedarf Turbo Native Modules sowie Fabric Native Components. Wenn Sie ein Paket für eine leistungsintensive Funktion auswählen, prüfen Sie, welche Architektur es unterstützt; eine Bibliothek, die über die Interop-Schicht läuft, liefert möglicherweise nicht die erwarteten Vorteile. Der Support-Status ändert sich schnell, daher überprüfen Sie dies anhand der aktuellen Release-Notes der Bibliothek.

    Eine Regel für die Aufteilung der Arbeit

    Für jede leistungsintensive Funktion sollte man zunächst entscheiden, welche Seite der Anwendung tatsächlich für die Ausführung verantwortlich ist.

    • Falls die Aufgabe zu JavaScript gehört (Business-Regeln, UI-Zustand, Orchestrierung), sollte sie dort ausgeführt werden und man sollte vermeiden, bei jedem Schritt auf den nativen Code zurückzugreifen.
    • Falls die Aufgabe zum nativen Code gehört (Sensor-Loops, Frame-Verarbeitung, Audio-Puffer), sollten diese leistungsintensiven Schritte im nativen Code ausgeführt werden und dort bleiben.
    • Man sollte die Grenze überschreiten, wenn es etwas Bedeutendes zu kommunizieren gibt – nicht damit jeder Zwischenschritt auf beiden Seiten sichtbar ist.

    Eine gesunde Aufteilung sieht in der Regel so aus:

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

    und nicht so, Hunderte Male pro Sekunde:

    JS ↔ Native ↔ JS ↔ Native ↔ JS ↔ Native
    

    Was die neue Architektur tatsächlich behebt

    Die neue Architektur beseitigt mehrere strukturelle Einschränkungen des alten Modells. Anstelle davon, alles um eine obligatorische, seriellisierte, asynchrone Brücke zu bauen, bietet das moderne React Native eine direkte Schnittstelle über JSI:

           JSI
            ↓
    Direct JS/native interfacing
    

    neben den anderen Komponenten, die darauf aufbauen:

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

    Das React Native-Team beschreibt die Beseitigung der Brücke als Verringerung des Serialisierungsaufwands, Verbesserung der Startzeit sowie als Möglichkeit zu einer direkteren Kommunikation zwischen JavaScript und dem nativen Laufzeitumfeld. Die tiefere Veränderung liegt in der Flexibilität. Das Framework hat nun mehr Kontrolle über:

    • wann Aufgaben ausgeführt werden,
    • wo Aufgaben ausgeführt werden,
    • wie auf native Funktionalitäten zugegriffen wird,
    • welche Aktualisierungen Vorrang haben.

    Diese Erkenntnis gilt für jede Grenze

    Bleiben Sie einen Schritt zurück von React Native – das Muster ist allgemein gültig: Jede architektonische Grenze hat ihren Preis. Denken Sie an diese Übergänge:

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

    Systeme werden oft so entworfen, als würden Aufrufe über diese Grenzen sich wie gewöhnliche Funktionsaufrufe innerhalb desselben Prozesses verhalten. Das ist jedoch nicht der Fall. Jede Grenze bringt eine Kombination aus folgenden Aspekten mit sich:

    • Serialisierung,
    • Kopieren,
    • Scheduling,
    • Warteschlangenverwaltung,
    • Vernetzung,
    • Synchronisierung,
    • Kontextwechsel,
    • Fehlerbehandlung,
    • Gedächtnisverwaltung.

    Bridge macht dieses Prinzip innerhalb einer Handy-App außergewöhnlich sichtbar.

    Bessern Sie zunächst die Architektur, bevor Sie mikrooptimieren

    Es ist verlockend, zunächst zu Tools wie diesen Zuflucht zu nehmen:

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

    Sie haben tatsächlich einen Nutzen. Doch wenn die App 5 MB an unnötigen Daten 30 Mal pro Sekunde zwischen den Laufzeiten austauscht, macht es keinen Unterschied, wenn man mit useMemo() 0,2 ms einspart. Leistungsoptimierungen sind am wirksamsten, wenn sie in dieser Reihenfolge durchgeführt werden:

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

    und nicht umgekehrt.

    Wichtige Erkenntnisse

    Das ursprüngliche Bridge-System war nicht einfach nur schlechte Ingenieursarbeit. Es ermöglichte es JavaScript und nativem Code, asynchron miteinander zu kommunizieren, ohne voneinander abhängig zu sein, und trug maßgeblich dazu bei, React Native voranzubringen. Als Apps Echtzeit-Kommunikation, anspruchsvolle Animationen, Kamera- und Machine-Learning-Funktionen, Karten, Audioverarbeitung, komplexe Gesten sowie andere interaktive Funktionen in hoher Frequenz einführten, wurden die Grenzen der seriell gesteuerten asynchronen Kommunikation schnell erreicht. JSI, TurboModules, Fabric sowie das neu gestaltete Scheduling- und Rendering-Modell beseitigen viele dieser Grenzen – doch nicht die zugrunde liegenden ökonomischen Aspekte.

    • Messen Sie den gesamten Interaktionsprozess, nicht nur den Aufruf im nativen Code oder die API-Antwort.
    • Berechnen Sie den Zeitrahmen pro Frame: etwa 16,67 ms bei 60 Hz und etwa 8,33 ms bei 120 Hz.
    • Bündeln Sie Operationen, drosseln oder konsolidieren Sie Ereignisströme an der Quelle und senden Sie die Ergebnisse statt Rohdaten.
  • Entscheiden Sie, welche Laufzeitumgebung für jede Aufgabe zuständig ist, und halten Sie Schleifen mit hoher Ausführungshäufigkeit auf einer Seite.
  • Profilieren Sie Versionen auf Geräten mittlerer und niedriger Leistungsklasse und überprüfen Sie, ob Abhängigkeiten weiterhin auf veralteten Modulen beruhen.
  • Betrachten Sie JSI als schnelleren Übertragungsweg, nicht als Berechtigung, mehr Aufrufe zu tätigen.
  • Falls eine App langsam wirkt, obwohl jedes Komponente schnell arbeitet, prüfen Sie die Verbindungen zwischen diesen Komponenten. Oft liegt das Problem nicht in der eigentlichen Arbeit, sondern in der Strecke, die die Daten zurücklegen müssen, um sie zu erreichen.