mit der Expo Modules API schreiben
Die Expo Modules API ersetzt handgeschriebenes React-Native-Bridging durch eine deklarative Swift- und Kotlin-DSL, mit der eigene native Module ohne manuelles Bridging-Boilerplate entstehen. Dieser Artikel zeigt Architektur, ein vollständiges Praxisbeispiel und die Grenzen beim Testen in Expo Go.
Inhaltsverzeichnis
- 1. Warum die Expo Modules API klassisches Bridging ablöst
- 2. Architektur: ModuleDefinition als deklarative Beschreibung
- 3. Projekt-Setup mit create-expo-module
- 4. Praxisbeispiel: Ein Taschenlampen-Modul von Grund auf
- 5. Swift-Seite: Functions, AsyncFunctions und Constants
- 6. Kotlin-Seite: Das identische Modul für Android
- 7. Der TypeScript-Layer: Typsichere Nutzung in der App
- 8. Testen und Debuggen: Grenzen von Expo Go
- 9. Publishing, Versionierung und Wartung
- 10. Zusammenfassung
- 11. FAQ
1. Warum die Expo Modules API klassisches Bridging ablöst
Klassisches React-Native-Bridging über NativeModules verlangte handgeschriebene Objective-C-Header, manuelles Promise-Handling und eine strikte Trennung zwischen JavaScript-Thread und nativem Thread über die Bridge. Wer ein eigenes natives Modul brauchte, musste sich durch RCT_EXPORT_METHOD-Makros, manuelle Typkonvertierung und eine eigene Podspec-Datei kämpfen, bevor überhaupt eine einzige Zeile Fachlogik geschrieben war. Die Expo Modules API kehrt dieses Verhältnis um: Sie stellt eine deklarative Swift- und Kotlin-DSL bereit, die Methodensignaturen, asynchrone Funktionen und Events aus einer einzigen Modulklasse ableitet, ohne dass Entwickler die Bridge-Mechanik selbst verdrahten müssen.
Der Wechsel ist kein bloßer Komfortgewinn, sondern eine strukturelle Vereinfachung: Typkonvertierung zwischen JavaScript und nativem Code, Speicherverwaltung von Callback-Referenzen und die Registrierung im Autolinking-System übernimmt das Framework. Für Teams, die regelmäßig kleine native Erweiterungen bauen, etwa Zugriff auf ein herstellerspezifisches SDK oder ein Sensor-API, reduziert das die Einstiegshürde erheblich, weil kein tiefes Wissen über die alte Bridge-Architektur mehr Voraussetzung ist.
2. Architektur: ModuleDefinition als deklarative Beschreibung
Im Zentrum jedes Expo-Moduls steht eine Klasse, die von Module erbt und eine definition-Eigenschaft überschreibt. Innerhalb dieser ModuleDefinition beschreibt man deklarativ, welche Funktionen, Konstanten und Events das Modul nach außen anbietet. Der Name des Moduls, seine Funktionen und seine Events werden über einen Builder-artigen DSL-Block definiert, den die Expo-Modules-Core-Bibliothek zur Laufzeit auswertet und automatisch mit dem JavaScript-Layer verknüpft.
Diese Architektur funktioniert auf iOS über Swift und auf Android über Kotlin nahezu identisch, was den mentalen Kontextwechsel zwischen den Plattformen deutlich reduziert. Wer die Struktur einmal auf iOS verstanden hat, erkennt auf Android dieselben Konzepte unter fast identischen Namen wieder, etwa Function, AsyncFunction und Events. Das senkt die Wartungskosten spürbar, weil ein Modul konzeptionell an einer Stelle entworfen und dann auf beiden Plattformen parallel implementiert wird, statt zwei völlig unterschiedliche native Codebasen pflegen zu müssen.
3. Projekt-Setup mit create-expo-module
Der offizielle Einstiegspunkt ist die CLI npx create-expo-module, die ein eigenständiges Modul-Repository mit iOS-, Android- und TypeScript-Ordnern erzeugt, inklusive Beispielprojekt zum sofortigen Testen. Das erzeugte Modul folgt dabei automatisch der Namenskonvention und Ordnerstruktur, die das Autolinking-System von Expo erwartet, sodass es ohne zusätzliche manuelle Konfiguration in einer bestehenden App eingebunden werden kann, sobald es als lokale Dependency referenziert wird.
Wichtig für Teams mit bestehenden Bare-Workflow-Projekten: Die Expo Modules API ist nicht an Expo Go oder den Managed Workflow gebunden. Sobald das Paket expo-modules-core als Abhängigkeit eingebunden ist, funktioniert das Autolinking auch in einem klassischen React-Native-Projekt ohne Expo-Konfiguration, solange npx expo install expo-modules-core ausgeführt und die native Konfiguration einmalig synchronisiert wurde. Das macht die Migration von reinem Legacy-Bridging schrittweise möglich, statt einen kompletten Umstieg auf den Managed Workflow zu erzwingen.
4. Praxisbeispiel: Ein Taschenlampen-Modul von Grund auf
Als durchgängiges Beispiel eignet sich ein kleines Modul, das die Kamera-Taschenlampe unabhängig vom Kamera-Preview steuert, ein Anwendungsfall, für den es keine offizielle Expo-API gibt. Auf der TypeScript-Seite reicht eine schlanke Wrapper-Datei, die das native Modul über requireNativeModule lädt und typsicher weiterreicht. Die eigentliche Fachlogik, also der direkte Zugriff auf AVCaptureDevice unter iOS beziehungsweise CameraManager unter Android, bleibt vollständig in der jeweiligen nativen Implementierung gekapselt.
Dieser Aufbau zeigt den Kernvorteil der Expo Modules API besonders deutlich: Die App-seitige Nutzung unterscheidet sich nicht von einem eingebauten Expo-Paket, obwohl es sich um selbst geschriebenen nativen Code handelt. Entwickler, die das Modul später verwenden, müssen weder wissen, dass es sich um ein Custom-Modul handelt, noch sich mit Bridging-Details befassen. Sie importieren die Funktion, rufen sie auf und erhalten ein typisiertes Promise zurück, exakt wie bei jeder anderen Expo-SDK-Funktion.
// modules/expo-torch/index.ts
import { requireNativeModule } from "expo-modules-core";
type ExpoTorchModule = {
setTorchEnabled(enabled: boolean): Promise<void>;
isTorchAvailable(): boolean;
};
const ExpoTorch = requireNativeModule<ExpoTorchModule>("ExpoTorch");
export async function toggleFlashlight(enabled: boolean): Promise<void> {
await ExpoTorch.setTorchEnabled(enabled);
}
5. Swift-Seite: Functions, AsyncFunctions und Constants
Auf iOS beschreibt die ModuleDefinition in Swift jede exportierte Funktion über die Function- beziehungsweise AsyncFunction-Builder. Synchron auswertbare Operationen, etwa das reine Auslesen eines aktuellen Zustands, nutzt man über Function, während alles mit I/O oder Wartezeit über AsyncFunction läuft und automatisch als JavaScript-Promise ankommt. Fehler wirft man einfach über Swifts natives throws, ohne manuell Reject-Callbacks zu verdrahten, das Framework übersetzt geworfene Fehler automatisch in eine abgelehnte Promise mit strukturierter Fehlermeldung.
Konstanten, etwa Plattform-Feature-Flags oder Hardware-Fähigkeiten, exportiert man über den Constants-Block, der beim Modul-Laden einmalig synchron ausgewertet wird und in JavaScript als einfaches Objekt zur Verfügung steht. Für wiederkehrende Ereignisse wie Statusänderungen definiert man zusätzlich Events über Events und sendet sie mit sendEvent, was auf JavaScript-Seite über einen addListener-Aufruf auf dem Modul konsumiert wird, komplett ohne manuelle NativeEventEmitter-Konfiguration.
// ios/ExpoTorchModule.swift
import ExpoModulesCore
import AVFoundation
public class ExpoTorchModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoTorch")
Function("isTorchAvailable") { () -> Bool in
guard let device = AVCaptureDevice.default(for: .video) else {
return false
}
return device.hasTorch
}
AsyncFunction("setTorchEnabled") { (enabled: Bool) in
guard let device = AVCaptureDevice.default(for: .video), device.hasTorch else {
throw TorchNotAvailableException()
}
try device.lockForConfiguration()
device.torchMode = enabled ? .on : .off
device.unlockForConfiguration()
}
}
}
internal final class TorchNotAvailableException: Exception {
override var reason: String {
"No torch-capable camera device found on this hardware"
}
}
6. Kotlin-Seite: Das identische Modul für Android
Auf Android folgt die Kotlin-Implementierung derselben ModuleDefinition-DSL mit fast identischer Syntax, was den Umstieg zwischen den Plattformen ungewohnt einfach macht im Vergleich zu klassischem Bridging, wo iOS- und Android-Implementierung oft grundverschieden strukturiert waren. Der Zugriff auf die Android-Taschenlampe läuft über CameraManager und dessen setTorchMode-Methode, gekapselt in einer AsyncFunction, die intern auf einen Coroutine-Kontext zurückgreift, um Blockierungen des Haupt-Threads zu vermeiden.
Ein Detail, das häufig übersehen wird: Android verlangt für den Taschenlampenzugriff eine gültige Kamera-ID, die man vorab über cameraManager.cameraIdList ermitteln und auf die rückseitige Kamera filtern muss, da nicht jedes Gerät eine Taschenlampe an derselben Kamera-ID besitzt. Fehler, etwa wenn keine passende Kamera-ID gefunden wird, wirft man als reguläre Kotlin-Exception, die das Framework genau wie unter Swift automatisch in eine abgelehnte JavaScript-Promise übersetzt, sodass die Fehlerbehandlung auf App-Seite plattformunabhängig identisch bleibt.
// android/src/main/java/expo/modules/torch/ExpoTorchModule.kt
package expo.modules.torch
import android.hardware.camera2.CameraManager
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoTorchModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoTorch")
AsyncFunction("setTorchEnabled") { enabled: Boolean ->
val manager = appContext.reactContext
?.getSystemService(android.content.Context.CAMERA_SERVICE) as CameraManager
val cameraId = manager.cameraIdList.firstOrNull { id ->
manager.getCameraCharacteristics(id)
.get(android.hardware.camera2.CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
} ?: throw TorchNotAvailableException()
manager.setTorchMode(cameraId, enabled)
}
}
}
7. Der TypeScript-Layer: Typsichere Nutzung in der App
Die TypeScript-Seite eines Expo-Moduls besteht typischerweise aus zwei Dateien: einem Interface, das die Modul-API beschreibt, und einer Wrapper-Datei, die requireNativeModule mit dem exakten nativen Modulnamen aufruft, den man in Swift und Kotlin über Name festgelegt hat. Diese Trennung erlaubt es, im Wrapper zusätzliche Plausibilitätsprüfungen, Standardwerte oder plattformspezifisches Verhalten mit Platform.OS zu ergänzen, bevor der Aufruf an die native Ebene weitergereicht wird.
Weil requireNativeModule generisch über TypeScript typisiert ist, erhält jeder Aufrufer im restlichen App-Code vollständige Autovervollständigung und Compile-Zeit-Prüfung der Parameter, ganz ohne zusätzliche Typdeklarationsdateien pflegen zu müssen. Das reduziert eine ganze Klasse von Laufzeitfehlern, die beim klassischen Bridging typisch waren, etwa vertippte Methodennamen oder falsch sortierte Parameter in einem NativeModules.MyModule.someMethod()-Aufruf ohne jede Typprüfung.
8. Testen und Debuggen: Grenzen von Expo Go
Ein zentraler Punkt, den Teams beim Umstieg unterschätzen: Custom-Native-Module funktionieren grundsätzlich nicht in der Standard-Expo-Go-App, weil Expo Go eine vorab kompilierte Binary mit einem fest definierten Satz an nativen Modulen ist. Für die Entwicklung und das Testen eines eigenen Moduls ist zwingend ein Development Build über npx expo run:ios beziehungsweise npx expo run:android oder EAS Build notwendig, der das Modul tatsächlich mitkompiliert.
Für die tägliche Iteration lohnt sich ein lokal referenziertes Modul im modules/-Ordner der App, kombiniert mit dem mitgelieferten Beispielprojekt aus create-expo-module, das bereits eine funktionierende Testumgebung mit Fast Refresh für die TypeScript-Seite und native Rebuilds für Swift- und Kotlin-Änderungen enthält. Native Codeänderungen erfordern weiterhin einen vollständigen Rebuild, JavaScript-seitige Änderungen am Wrapper dagegen profitieren normal vom Metro-Fast-Refresh, was den Entwicklungszyklus insgesamt deutlich beschleunigt gegenüber reinem Bridging-Debugging über Xcode- und Android-Studio-Logs.
9. Publishing, Versionierung und Wartung
Sobald ein Modul stabil ist, lässt es sich als eigenständiges npm-Paket veröffentlichen, entweder privat innerhalb einer Organisation oder öffentlich, wenn der Anwendungsfall generisch genug ist. Die von create-expo-module erzeugte Ordnerstruktur ist bereits publish-fertig aufgebaut, inklusive package.json, Podspec und Android-build.gradle, sodass kein zusätzliches Tooling wie react-native-builder-bob notwendig ist, um ein sauber konsumierbares Paket zu erzeugen.
Für die Versionierung empfiehlt sich striktes Semantic Versioning gekoppelt an die unterstützte Expo-SDK-Version, da sich die interne expo-modules-core-API zwischen größeren SDK-Versionen gelegentlich ändert. Ein Modul, das gegen SDK 51 gebaut wurde, funktioniert nicht automatisch gegen SDK 54, deshalb lohnt sich eine CI-Pipeline, die das Modul gegen mehrere unterstützte SDK-Versionen testet, bevor eine neue Version veröffentlicht wird.
| Aspekt | Klassisches Bridging | Expo Modules API |
|---|---|---|
| Sprache iOS | Objective-C mit RCT_EXPORT_METHOD | Swift mit ModuleDefinition-DSL |
| Sprache Android | Java mit ReactContextBaseJavaModule | Kotlin mit ModuleDefinition-DSL |
| Typkonvertierung | Manuell über Bridge-Typen | Automatisch über die DSL |
| Autolinking | Manuelle Podspec- und Gradle-Pflege | Automatisch über expo-modules-autolinking |
| Expo-Go-Kompatibilität | Nicht relevant, reines Bare-Setup | Nur mit Development Build nutzbar |
Mironsoft
React-Native-App-Entwicklung und Magento-Anbindung
Eine mobile App zum Magento-Shop, die wirklich rund läuft?
Wir entwickeln React-Native-Apps, die sauber an die Magento REST- oder GraphQL-API angebunden sind, von der ersten Codezeile bis zur Veröffentlichung im App Store und bei Google Play.
App-Konzeption
Architektur und Feature-Umfang einer Magento-angebundenen App gemeinsam planen.
Magento-API-Integration
Produktkatalog, Warenkorb und Checkout sauber an die Shop-API anbinden.
Store-Veröffentlichung
App Store- und Google-Play-Freigabeprozess ohne Stolperfallen begleiten.
10. Zusammenfassung
Expo Modules API: Das Wichtigste auf einen Blick
Für wen
Teams, die eigene native SDK-Anbindungen ohne klassisches Bridging bauen wollen.
Voraussetzung
Development Build statt Expo Go, da Custom-Module dort nicht funktionieren.
Kernvorteil
Identische ModuleDefinition-DSL auf Swift und Kotlin, automatische Typkonvertierung.
Aufwand
Deutlich geringer als klassisches Bridging, aber weiterhin natives Know-how nötig.