die Swift und Kotlin Brücke selbst bauen
Wenn eine bestehende Bibliothek eine Lücke in React Native nicht schließt, führt kein Weg an einem eigenen nativen Modul vorbei. Dieser Artikel zeigt, wie die Swift und Kotlin Brücke zwischen JavaScript und nativem Code aufgebaut ist, wie Turbo Modules und JSI die alte Bridge ablösen, und wie ein sauber typisiertes natives Modul von der Spec-Datei bis zum fertigen JS-Wrapper entsteht.
Inhaltsverzeichnis
- 1. Wann ein eigenes natives Modul wirklich nötig ist
- 2. Die alte Architektur: Bridge und asynchrone Serialisierung
- 3. Die neue Architektur: Turbo Module, Codegen und JSI
- 4. Natives Modul in Swift schreiben
- 5. Natives Modul in Kotlin schreiben
- 6. Events von nativ zu JS senden
- 7. Den JS/TypeScript Wrapper schreiben
- 8. Codegen konfigurieren und ausführen
- 9. Testing und Debugging nativer Module
- 10. Zusammenfassung
- 11. FAQ
1. Wann ein eigenes natives Modul wirklich nötig ist
Die JavaScript-Bridge von React Native deckt einen großen Teil der App-Anforderungen bereits über Core-APIs und Community-Bibliotheken ab. Ein eigenes natives Modul lohnt sich erst dann, wenn eine konkrete Lücke in der API-Oberfläche von React Native besteht, die keine bestehende Bibliothek schließt. Das betrifft häufig sehr spezifische Betriebssystemfunktionen: ein neues iOS-Framework, das noch keine JavaScript-Anbindung hat, oder ein Android-System-Service, der nur über Java- oder Kotlin-APIs erreichbar ist.
Der zweite typische Anlass ist das Einbinden eines bestehenden nativen SDKs, etwa eines Zahlungsanbieters, eines Hardware-Herstellers oder eines proprietären Analytics-Systems. Diese SDKs liefern in der Regel nur Swift-, Objective-C- oder Kotlin-Bibliotheken aus, keine JavaScript-Variante. Ein natives Modul übernimmt hier die Rolle einer Übersetzungsschicht: Es ruft die native SDK-Methode auf und reicht das Ergebnis in einer für JavaScript verständlichen Form zurück. Auch performancekritischer Code, etwa Bildverarbeitung, kryptografische Operationen oder Signalverarbeitung von Sensordaten, gehört in diese Kategorie, weil die JavaScript-Ausführung dafür regelmäßig zu langsam ist.
Ein drittes Szenario, das ein natives Modul motiviert, sind Anforderungen, die über den Lebenszyklus der React-Native-App hinausgehen, etwa Bluetooth-Low-Energy-Verbindungen, die auch bei geschlossener App bestehen bleiben sollen, oder Hintergrundprozesse, die eigene Betriebssystem-APIs für Background-Ausführung benötigen. Solche Fälle sind eigene Themen für sich, zeigen aber gut, warum die Native-Module-Brücke zwischen JavaScript und nativem Code überhaupt existiert: Sie macht Funktionen erreichbar, die React Native von sich aus nicht anbietet.
2. Die alte Architektur: Bridge und asynchrone Serialisierung
In der klassischen React-Native-Architektur, häufig als Old Architecture bezeichnet, kommunizieren JavaScript-Thread und nativer Thread ausschließlich über die Bridge. Jeder Methodenaufruf und jede Rückgabe wird dabei in JSON serialisiert, über die Bridge geschickt und auf der Gegenseite wieder deserialisiert. Dieser Vorgang läuft asynchron ab, unabhängig davon, ob der Aufruf eigentlich eine synchrone Antwort erwarten würde. Für einfache Aufrufe wie das Lesen eines Gerätenamens bedeutet das einen spürbaren Overhead, der bei häufigen Aufrufen, etwa in einer Animation oder einem Sensor-Stream, zum limitierenden Faktor wird.
Auf iOS exportiert ein natives Modul in dieser Architektur Methoden über RCT_EXPORT_METHOD, auf Android über die Annotation @ReactMethod. Beide Mechanismen registrieren die Methode zur Laufzeit in einer Methodentabelle, die die Bridge beim Aufruf nachschlägt. Das funktioniert zuverlässig, bringt aber zwei strukturelle Schwächen mit sich. Erstens gibt es keine Typsicherheit zwischen JavaScript-Aufruf und nativer Implementierung, Tippfehler in Methodennamen oder falsche Parametertypen fallen erst zur Laufzeit auf. Zweitens werden beim App-Start alle registrierten native Module initialisiert, unabhängig davon, ob sie tatsächlich gebraucht werden, was die Startzeit größerer Apps mit vielen Modulen messbar verlängert.
3. Die neue Architektur: Turbo Module, Codegen und JSI
Die neue React-Native-Architektur ersetzt die JSON-Bridge durch das JavaScript Interface, kurz JSI. JSI erlaubt es JavaScript-Code, direkt auf native C++-Objekte zu verweisen und deren Methoden ohne Serialisierungsschritt aufzurufen. Für ein natives Modul bedeutet das: Statt eine Nachricht über die Bridge zu schicken und asynchron auf die Antwort zu warten, kann ein Turbo Module Methoden bereitstellen, die synchron aufgerufen werden, wenn das sinnvoll ist. Der JSON-Umweg entfällt komplett, was gerade bei häufigen, kleinen Aufrufen einen erheblichen Geschwindigkeitsgewinn bringt.
Turbo Modules werden nicht mehr manuell in einer Methodentabelle registriert, sondern aus einer TypeScript-Spezifikationsdatei generiert. Diese Spec-Datei beschreibt die Methoden, Parameter und Rückgabetypen des nativen Moduls als TypeScript-Interface. Ein Codegen-Schritt liest diese Datei zur Build-Zeit ein und erzeugt daraus passenden nativen Interface-Code für iOS und Android, gegen den die eigene Swift- oder Kotlin-Implementierung dann kompiliert wird. Weicht die Implementierung vom generierten Interface ab, schlägt der Build fehl, nicht erst ein Testfall zur Laufzeit.
Ein weiterer Vorteil der neuen Architektur ist Lazy Loading: Turbo Modules werden erst instanziiert, wenn JavaScript sie tatsächlich referenziert, nicht pauschal beim App-Start. Bei Apps mit vielen native Modulen wirkt sich das direkt auf die Startzeit aus. In Summe verschiebt die neue Architektur die Fehlererkennung von der Laufzeit in die Build-Zeit und ersetzt die Bridge durch einen deutlich direkteren Aufrufpfad über JSI.
| Aspekt | Bridge (Old Architecture) | Turbo Modules (New Architecture) | Auswirkung |
|---|---|---|---|
| Datenübertragung | JSON-Serialisierung, immer asynchron | Direkter JSI-Zugriff, synchron möglich | Kein Serialisierungs-Overhead pro Aufruf |
| Modul-Initialisierung | Alle native Module beim App-Start | Lazy Loading bei erster Referenz | Kürzere Startzeit bei vielen Modulen |
| Typsicherheit | Manuell, erst zur Laufzeit sichtbar | Codegen aus TypeScript-Spec, Build-Zeit | Tippfehler brechen den Build, nicht die App |
| Methodenregistrierung | RCT_EXPORT_METHOD / @ReactMethod-Tabelle | Aus Spec generiertes Protokoll/Basisklasse | Implementierung folgt festem Interface |
| Debugging | Bridge-Messages schwer einsehbar | Direkter Call-Stack über JSI | Fehlerursache leichter lokalisierbar |
4. Natives Modul in Swift schreiben
Für ein natives Modul in Swift gibt es zwei gängige Wege: die klassische Variante über eine Klasse, die RCTBridgeModule per @objc-Annotation implementiert, oder die Turbo-Module-Variante, bei der die Swift-Klasse gegen ein aus dem Codegen erzeugtes Protokoll konform ist. Beide Varianten teilen sich das gleiche Grundmuster: Eine Methode wird für JavaScript exportiert, nimmt Parameter entgegen und liefert das Ergebnis über einen RCTPromiseResolveBlock beziehungsweise einen RCTPromiseRejectBlock zurück, statt einen klassischen Rückgabewert zu nutzen.
Bei Fehlern reicht der Reject-Block einen Fehlercode, eine lesbare Fehlermeldung und optional den ursprünglichen NSError weiter. Auf JS-Seite kommt das als abgelehntes Promise mit einer code-Eigenschaft an, worauf die aufrufende Seite gezielt reagieren kann. Konstanten, die beim App-Start feststehen, etwa SDK-Versionsnummern oder Standardkonfigurationen, exportiert man über constantsToExport(), statt sie über eine eigene Methode abzufragen, was einen zusätzlichen Bridge-Roundtrip spart.
Threading verdient bei einem Swift-basierten nativen Modul besondere Aufmerksamkeit. Standardmäßig läuft jedes native Modul auf einer eigenen Warteschlange, nicht auf dem Hauptthread. Erfordert eine SDK-Methode aber UI-Interaktion oder muss aus anderen Gründen auf dem Hauptthread laufen, etwa weil ein SDK das intern voraussetzt, muss die Implementierung explizit mit DispatchQueue.main.async dorthin wechseln. Wird das vergessen, entstehen schwer reproduzierbare Abstürze, die erst unter Last oder auf bestimmten iOS-Versionen auftreten.
import Foundation
@objc(PaymentBridgeModule)
class PaymentBridgeModule: NSObject {
// Runs on the module's own queue by default, not the main thread
@objc
static func requiresMainQueueSetup() -> Bool {
return false
}
@objc(chargeCard:amount:resolver:rejecter:)
func chargeCard(cardToken: String, amount: NSNumber,
resolver resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
// Some SDKs require the main thread for UI presentation
DispatchQueue.main.async {
PaymentSDK.charge(token: cardToken, amount: amount.doubleValue) { result in
switch result {
case .success(let receipt):
resolve(["transactionId": receipt.id, "status": receipt.status])
case .failure(let error):
reject("E_CHARGE_FAILED", error.localizedDescription, error)
}
}
}
}
@objc
func constantsToExport() -> [String: Any] {
return ["defaultCurrency": "EUR", "sdkVersion": PaymentSDK.version]
}
}
5. Natives Modul in Kotlin schreiben
Auf Android erbt ein natives Modul in der klassischen Variante von ReactContextBaseJavaModule und überschreibt getName(), um den Namen festzulegen, unter dem das Modul in NativeModules auf JS-Seite sichtbar wird. In der Turbo-Module-Variante erbt die Kotlin-Klasse stattdessen von einer durch Codegen generierten abstrakten Basisklasse, die bereits die Methodensignaturen aus der TypeScript-Spec vorgibt. Methoden werden in beiden Fällen mit @ReactMethod annotiert und erhalten als letzten Parameter ein Promise-Objekt, über das resolve() oder reject() aufgerufen wird.
Ein wichtiger Unterschied zu Swift: Die JS-Thread-Ausführung auf Android darf unter keinen Umständen blockiert werden. Aufwändige SDK-Aufrufe oder Datei-I/O gehören deshalb in einen eigenen Thread oder Executor, nicht direkt in die @ReactMethod-Funktion. Wird eine langlaufende Operation synchron im Modul ausgeführt, friert im schlimmsten Fall die komplette JavaScript-Ausführung der App ein, was sich als eingefrorene UI bemerkbar macht, obwohl die UI-Schicht selbst gar nicht betroffen ist.
Damit ein neues natives Modul überhaupt sichtbar wird, muss es zusammen mit seinem ReactPackage in MainApplication.kt registriert werden. Fehlt dieser Schritt, bleibt NativeModules.PaymentBridgeModule auf JS-Seite undefined, ohne dass eine offensichtliche Fehlermeldung auf die Ursache hinweist, ein Stolperstein, der gerade bei neu aufgesetzten native Modulen regelmäßig für Verwirrung sorgt.
package de.mironsoft.paymentbridge
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
class PaymentBridgeModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "PaymentBridgeModule"
@ReactMethod
fun chargeCard(cardToken: String, amount: Double, promise: Promise) {
// Never block the JS thread, offload the SDK call to a worker thread
Thread {
try {
val receipt = PaymentSdk.charge(cardToken, amount)
val result = Arguments.createMap().apply {
putString("transactionId", receipt.id)
putString("status", receipt.status)
}
promise.resolve(result)
} catch (error: PaymentException) {
promise.reject("E_CHARGE_FAILED", error.message, error)
}
}.start()
}
}
// MainApplication.kt: register the package alongside the default packages
class MainApplication : Application(), ReactApplication {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
add(PaymentBridgePackage())
}
}
6. Events von nativ zu JS senden
Neben direkten Methodenaufrufen muss ein natives Modul oft auch von sich aus Ereignisse an JavaScript melden, etwa den Fortschritt eines Downloads oder eine Statusänderung eines externen Geräts. Auf iOS übernimmt dafür eine von RCTEventEmitter abgeleitete Klasse diese Aufgabe. Sie implementiert supportedEvents(), um die zulässigen Event-Namen zu deklarieren, und ruft bei Bedarf sendEvent(withName:body:) auf, um ein Event an JavaScript zu schicken.
Auf Android übernimmt diese Rolle der RCTDeviceEventEmitter, den man über reactContext.getJSModule(DeviceEventEmitterModule::class.java).emit(eventName, params) anspricht. Anders als bei iOS gibt es kein separates Basisklassen-Muster für den Event-Namen, die Emission erfolgt direkt über das generische Device-Event-System von React Native. Beide Plattformen erwarten dabei ein serialisierbares Payload, üblicherweise eine Map beziehungsweise ein WritableMap, kein beliebiges natives Objekt.
Auf JS-Seite abonniert man diese Events über eine Instanz von NativeEventEmitter, der das eigene native Modul als Argument übergeben wird. Der Rückgabewert von addListener() liefert ein Subscription-Objekt mit einer remove()-Methode, die beim Unmount der Komponente aufgerufen werden muss. Wird diese Aufräumarbeit vergessen, sammeln sich mit jedem Mount weitere Listener an, was zu doppelten Callback-Aufrufen und schwer auffindbaren Speicherlecks führt.
7. Den JS/TypeScript Wrapper schreiben
Ein natives Modul sollte nie direkt über NativeModules.PaymentBridgeModule im Rest der App verwendet werden. Stattdessen kapselt ein eigener TypeScript-Wrapper den Zugriff, definiert saubere Typen für Parameter und Rückgabewerte und versteckt native Implementierungsdetails wie Fehlercodes hinter einer eigenen Fehlerklasse. Das erleichtert spätere Refactorings, weil sich native Details ändern lassen, ohne den Aufrufcode in der gesamten App anzupassen.
Fehlerbehandlung verdient hier besondere Sorgfalt. Ein abgelehntes Promise aus einem nativen Modul liefert in der Regel ein Objekt mit code und message, aus dem der Wrapper eine eigene, typisierte Fehlerklasse konstruiert. Aufrufende Komponenten können dann anhand des Codes gezielt reagieren, etwa eine Nutzermeldung bei E_CHARGE_FAILED anzeigen, statt jede Fehlermeldung pauschal als generischen Absturz zu behandeln.
import { NativeModules, NativeEventEmitter, Platform } from 'react-native';
const { PaymentBridgeModule } = NativeModules;
export interface ChargeReceipt {
transactionId: string;
status: string;
}
export class PaymentBridgeError extends Error {
constructor(public code: string, message: string) {
super(message);
}
}
// Clean typed API, native details stay hidden from callers
export async function chargeCard(cardToken: string, amount: number): Promise<ChargeReceipt> {
try {
return await PaymentBridgeModule.chargeCard(cardToken, amount);
} catch (error: any) {
throw new PaymentBridgeError(error.code ?? 'E_UNKNOWN', error.message ?? 'Charge failed');
}
}
const emitter = new NativeEventEmitter(Platform.OS === 'ios' ? PaymentBridgeModule : undefined);
// Subscribe to native events, for example status pushes from the SDK
export function onChargeStatusChanged(callback: (status: string) => void) {
const subscription = emitter.addListener('chargeStatusChanged', (event) => {
callback(event.status);
});
return () => subscription.remove();
}
8. Codegen konfigurieren und ausführen
Damit Codegen eine TypeScript-Spec-Datei überhaupt findet und verarbeitet, muss die package.json des Moduls einen codegenConfig-Block enthalten. Dieser Block legt unter anderem den internen Namen der generierten Spezifikation, das Verzeichnis der Spec-Dateien und, für Android, den Java-Package-Namen fest, unter dem der generierte Code abgelegt wird. Die Spec-Datei selbst folgt einer festen Namenskonvention: Sie beginnt immer mit Native, gefolgt vom Modulnamen, etwa NativePaymentBridgeModule.ts, und exportiert ein TurboModule-Interface mit den Methodensignaturen.
Der eigentliche Codegen-Lauf erzeugt aus dieser Spec-Datei plattformspezifischen Code: auf iOS Objective-C++-Header und -Klassen, auf Android abstrakte Java-Klassen, gegen die die eigene Kotlin-Implementierung dann kompiliert wird. Diese generierten Dateien landen in Build-Verzeichnissen, die nicht ins Repository eingecheckt werden, sie entstehen bei jedem Build neu aus der Spec-Datei. Ändert sich die Spec, ändert sich automatisch auch das erwartete native Interface, ein Rückstand zwischen JS-Definition und nativer Implementierung wird dadurch unmöglich, statt still zu Laufzeitfehlern zu führen.
In der Praxis reicht meist ein einzelner Codegen-Lauf vor dem Build, ergänzt um ein pod install auf iOS, damit CocoaPods die neu generierten Podspecs berücksichtigt. Auf Android greift Gradle automatisch auf die generierten Quellen zu, ein separater Schritt ist hier in der Regel nicht nötig. Wer Codegen-Probleme diagnostizieren will, beginnt am besten mit einem sauberen Codegen-Lauf und einer Prüfung, ob die generierten Dateien tatsächlich die erwarteten Methodensignaturen enthalten.
{
"name": "payment-bridge",
"version": "1.0.0",
"codegenConfig": {
"name": "PaymentBridgeSpec",
"type": "modules",
"jsSrcsDir": "src/specs",
"android": {
"javaPackageName": "de.mironsoft.paymentbridge"
}
}
}
# Regenerate native interface code from the TypeScript spec file
npx react-native codegen
# iOS: install pods so CocoaPods picks up the generated podspecs
cd ios && pod install && cd ..
# Android: Gradle picks up generated Java sources automatically
# on the next build, no separate step needed
npx react-native run-android
9. Testing und Debugging nativer Module
Die native Seite eines Moduls lässt sich unabhängig von React Native testen, was in der Praxis oft übersehen wird. Auf iOS prüft XCTest die Swift-Implementierung direkt, ohne dass eine laufende App oder eine JavaScript-Bridge nötig wäre, etwa um sicherzustellen, dass ein Fehlerfall tatsächlich den richtigen Fehlercode zurückgibt. Auf Android übernimmt JUnit dieselbe Rolle für die Kotlin-Implementierung. Diese Unit-Tests laufen deutlich schneller als End-to-End-Tests über die komplette App und decken Logikfehler auf, bevor sie überhaupt bis zur JavaScript-Seite vordringen.
Fehler in der Bridge oder in JSI zeigen sich oft nicht als klare Exception, sondern als unerklärliches Verhalten: ein Promise, das nie auflöst, ein Event, das nie ankommt, oder ein Absturz ohne aussagekräftigen Stacktrace. Natives Logging, etwa über os_log auf iOS oder Logcat auf Android, gepaart mit gezielten Log-Ausgaben an jedem Übergabepunkt zwischen JS und nativem Code, hilft dabei, den Punkt einzugrenzen, an dem die Kommunikation tatsächlich abbricht.
Typische Stolperfallen bei nativen Modulen wiederholen sich projektübergreifend. Ein in einer Closure retainter JS-Callback, der nie aufgerufen wird, verhindert, dass die zugehörige Bridge-Ressource wieder freigegeben wird, und führt so zu einem Speicherleck. Ein UI-Aufruf, der versehentlich auf dem falschen Thread landet, erzeugt auf iOS harte Abstürze und auf Android zumindest Warnungen im Log. Und schlicht vergessene Package-Registrierung in MainApplication.kt sorgt dafür, dass ein ansonsten korrektes natives Modul auf JS-Seite einfach nicht existiert.
Mironsoft
React-Native-Entwicklung, native Module und App-Infrastruktur
Ein SDK, das nur nativ existiert, aber in React Native gebraucht wird?
Wir bauen eigene native Module in Swift und Kotlin, sauber typisiert über Codegen, mit vollständiger Fehlerbehandlung, Event-Anbindung und Tests für iOS und Android.
Native-Module-Entwicklung
Swift- und Kotlin-Implementierung inklusive Turbo-Module-Anbindung
SDK-Integration
Bestehende native SDKs sauber in eine typisierte JS-API übersetzen
Migration & Debugging
Bestehende Bridge-Module auf Turbo Modules und JSI migrieren
10. Zusammenfassung
Ein eigenes natives Modul ist kein Selbstzweck, sondern die Antwort auf eine konkrete Lücke, die weder Core-API noch Community-Bibliothek schließt: fehlende Plattformfunktionen, ein SDK, das nur nativ existiert, oder Code, der für JavaScript zu performancekritisch ist. Die alte Bridge löst diese Fälle über asynchrone JSON-Serialisierung, mit RCT_EXPORT_METHOD auf iOS und @ReactMethod auf Android, aber ohne Typsicherheit und mit spürbarem Overhead. Die neue Architektur ersetzt das durch Turbo Modules, aus einer TypeScript-Spec generiert, und JSI als direkten, teils synchronen Zugriffspfad ohne Serialisierungsschritt.
Eine saubere Swift und Kotlin Brücke berücksichtigt Promise-basierte Rückgaben, exportierte Konstanten, korrektes Threading und eine funktionierende Event-Anbindung über RCTEventEmitter und RCTDeviceEventEmitter. Ein typisierter JS-Wrapper verbirgt die native Implementierung hinter einer stabilen API, Codegen hält Spec und native Implementierung synchron, und Unit-Tests auf beiden Plattformen fangen Logikfehler ab, bevor sie überhaupt bis zur App-Oberfläche vordringen.
Eigene native Module in React Native: Das Wichtigste auf einen Blick
Wann nötig
Fehlende Plattform-API, natives SDK ohne JS-Anbindung oder performancekritischer Code, der in JavaScript zu langsam wäre.
Bridge vs. JSI
Die alte Bridge serialisiert jeden Aufruf asynchron als JSON, JSI erlaubt direkten, teils synchronen Zugriff ohne Umweg.
Swift & Kotlin
Promise-basierte Methoden, exportierte Konstanten und sauberes Threading auf beiden Plattformen nicht vergessen.
Codegen & Tests
TypeScript-Spec treibt Codegen an, XCTest und JUnit sichern die native Seite unabhängig von der App ab.