Von React Native zu Flutter mitten im Projekt — die ehrliche Rechnung
Sechs Wochen, drei TestFlight-Betas, sechs zahlende Merchants. Ein direkter Bericht über das Umschreiben einer funktionierenden React-Native-App in Flutter im November 2024 — was brach, was es kostete, und was ich wieder tun würde.
Wir hatten eine funktionierende React-Native-App. Drei TestFlight-Betas, acht Monate Arbeit, sechs zahlende Merchants im Pilot. Im November 2024 schrieb ich sie in Flutter neu. Ich werde durchgehen, warum, was es gekostet hat, und was ich wieder tun würde — einschließlich der Teile, die ich bereue.
Das ist kein "Flutter gegen React Native"-Post. Ich habe viele davon gelesen, und sie alle haben dasselbe Problem: Der Autor verkauft dir eine Entscheidung, statt zu beschreiben, was tatsächlich passiert ist. Was folgt, ist nur, was in unserem Codebase, mit unseren Constraints, in unserem Zeitplan tatsächlich passiert ist.
Wo wir gestartet sind
Stork ist eine türkische Sendungsverfolgungs-App. Die erste Version wurde in React Native mit Expo SDK 49 und TypeScript gebaut. Wir wählten RN aus dem Grund, aus dem die meisten Teams es wählen: Der Gründungs-Engineer (ich) kannte JavaScript am besten, und einen funktionierenden iOS- und Android-Build aus einem Codebase auszuliefern war der schnellste Weg zu einer TestFlight-Beta, die wir Merchants zeigen konnten.
Acht Monate lang funktionierte es. Die ersten drei TestFlight-Runden waren stabil. Wir sammelten Feedback von den Pilot-Merchants. Die App tat, was sie tun sollte.
Dann begann sie zu brechen.
Was zu brechen begann
Drei konkrete Schmerzpunkte, alle native-modul-bezogen:
Der Barcode-Scanner. Wir nutzten eine Community-Camera-Bibliothek, um Paketcodes zu lesen. Die Bibliothek funktionierte, aber jedes iOS-Minor-Update brachte einen Release-Notes-Eintrag über veraltete APIs, die wir nutzten. Bis Oktober 2024 hatte der Scanner eine 200-300ms Auslöseverzögerung auf iPhone 13 und darunter, die wir nicht eliminieren konnten. Der Maintainer reagierte, war aber überlastet, und der Fix war ständig "warte auf den nächsten Release".
Push-Notification-Routing. Wir routeten Benachrichtigungen unterschiedlich, je nachdem, ob die App im Vordergrund, Hintergrund oder beendet war. Die Expo-Notification-Bibliothek handhabte die ersten zwei Fälle sauber. Der dritte Fall — beendete App, Tippen auf eine Benachrichtigung, Landen auf dem richtigen Sendungs-Detail-Bildschirm — erforderte benutzerdefinierten nativen Code auf beiden Plattformen. Bei jedem Expo-SDK-Upgrade hielt ich den Atem an, ob der Deeplink noch am richtigen Ort landet.
OS-Level Background-Sync. Wir wollten, dass sich Sendungsstatus einmal pro Stunde aktualisieren, auch wenn die App geschlossen ist. Auf iOS ist das BGAppRefreshTask; auf Android ist das WorkManager. Beides erforderte das Ejecten aus managed Expo und das Maintainen eines benutzerdefinierten nativen Moduls. Wir verschoben es vier Monate, weil das Eject-Gespräch immer wieder aufkam und verschoben wurde.
Jedes einzelne Problem war lösbar. Das aggregierte Problem war, dass wir einen bedeutsamen Anteil jedes Sprints für Yak-Shaves verbrachten — Modul-Updates, Versions-Pinning, "das funktioniert in Expo Go, aber nicht in einem echten Build"-Debugging. Die meisten davon brauchten je zwei Tage. Keines davon produzierte neue Features.
Die Woche, in der es kippte
In der Woche des 21. Oktober 2024 erschien iOS 18.1 und brach vier unserer Abhängigkeiten in fünf Tagen. Zwei waren mit einem Versions-Bump machbar. Eine erforderte einen Fork. Die vierte war die Barcode-Kamera-Bibliothek, und der Fix war "wir sind dran". Wir hatten Merchants, die vom Scanner abhängig waren.
An jenem Wochenende prototypisierte ich den Scanner-Bildschirm in Flutter. Login, Scanner, Sendungs-Detail — drei Bildschirme. Es dauerte einen Abend. Das Flutter mobile_scanner-Plugin funktionierte beim ersten Versuch ohne Auslöseverzögerung. Ich klappte das Laptop zu und ging spazieren. Als ich zurückkam, hatte ich entschieden.
Warum spezifisch Flutter
Ich möchte beim Reasoning spezifisch sein, weil "Flutter ist besser" kein Argument ist, das ich von jemand anderem akzeptieren würde:
- Ein einziges Compilation-Target. Dart kompiliert über AOT zu nativem Code. Es gibt keine JS-Bridge, keinen Expo-Dev-Client, keine Hermes-vs-JSC-Frage. Wenn etwas in der Entwicklung funktioniert, funktioniert es in Produktion. Die Zahl der "aber funktioniert das in einem Release-Build?"-Gespräche fiel auf null.
- Cupertino- und Material-Widgets in derselben Toolkit. Wir wollten, dass sich die iOS-App wie eine iOS-App und die Android-App wie eine Android-App anfühlt. Flutter liefert beide Widget-Familien mit. Wir mussten keine UI-Bibliothek dazuschrauben.
- Release-Kadenz und Ökosystem. Das ist der unmoderne Punkt. Flutter hat weniger Pakete als RN, und die meisten davon werden vom Flutter-Team oder einer kleinen Zahl konsistenter Mitwirkender gepflegt. Das Ökosystem von RN hat mehr Optionen, aber mehr Fragmentierung. Wir wollten keine Optionen. Wir wollten langweilig.
Den dritten Punkt würde ich am härtesten verteidigen. "Mehr Pakete" klingt wie ein Vorteil, bis man an einem Freitagabend zwischen drei Barcode-Scannern auswählt, während Merchants warten.
Wie die Migration tatsächlich aussah
Ich gab mir sechs Wochen. Es brauchte sechs Wochen.
Woche 1 — Spike und Umgebung. Flutter auf meiner Dev-Maschine einrichten, iOS- und Android-Emulatoren laufen lassen, Login- und Home-Bildschirme als vertikalen Slice portieren. Neun RN-Pakete identifiziert, die Flutter-Äquivalente brauchten, und nach Risiko geordnet: Barcode-Scanner, Push-Notifications, Background-Sync, Secure Storage, File Picker, Image Picker, Deep Links, Biometrie, In-App-Browser. Die ersten drei machten mir Sorgen.
Wochen 2-4 — Bildschirm für Bildschirm neu schreiben. Ich ging in Feature-Reihenfolge, nicht in Bildschirm-Reihenfolge. Authentifizierung, dann Sendungsliste, dann Sendungs-Detail, dann Scanner, dann Einstellungen. Ich ließ die RN-App parallel laufen und brachte die Flutter-Version dort, wo es wichtig war, pixelgenau zur Übereinstimmung (CTA-Platzierung, Listen-Dichte) und dort, wo es nicht wichtig war, nicht (Transition-Kurven, Splash-Timing).
// package.json — RN-Seite, die Teile, die wehtun
{
"dependencies": {
"expo": "~49.0.0",
"expo-barcode-scanner": "~12.5.0",
"expo-notifications": "~0.20.0",
"expo-task-manager": "~11.3.0",
"@react-native-community/netinfo": "^9.3.10",
"react-native-reanimated": "~3.3.0",
"react-native-svg": "~13.9.0",
"react-native-mmkv": "^2.10.0"
}
}# pubspec.yaml — Flutter-Seite, die Äquivalente
dependencies:
flutter:
sdk: flutter
mobile_scanner: ^5.2.3 # 1:1-Ersatz für expo-barcode-scanner
firebase_messaging: ^15.1.6 # ersetzt expo-notifications-Routing
workmanager: ^0.5.2 # ersetzt expo-task-manager
connectivity_plus: ^6.1.0 # ersetzt @react-native-community/netinfo
flutter_secure_storage: ^9.2.2 # ersetzt react-native-mmkvWoche 5 — die schweren Pakete. Background-Sync und Push-Routing. Push-Routing stellte sich als einfacher heraus als erwartet, weil Flutters firebase_messaging Terminated-App-Deeplinks über einen einzigen getInitialMessage()-Aufruf handhabt. Das RN-Äquivalent erforderte separate Handler auf iOS und Android.
Das RN-Bridge-Modul, das am häufigsten brach, war unser benutzerdefinierter Sendungs-Sync-Background-Task. Grob:
// RN-Seite — das Bridge-Modul, das immer wieder brach
import { NativeModules, NativeEventEmitter } from "react-native";
const { ShipmentSync } = NativeModules;
const emitter = new NativeEventEmitter(ShipmentSync);
// Abonniere Sync-Events von der nativen Seite. Das Problem:
// jedes Expo-SDK-Upgrade benannte etwas um, und das Event-
// Payload-Format driftete über Versionen zweimal.
emitter.addListener("shipmentSyncDidFinish", (payload) => {
// payload.shipmentIds war manchmal ein Array,
// manchmal ein JSON-String. Echter Code.
const ids = Array.isArray(payload.shipmentIds)
? payload.shipmentIds
: JSON.parse(payload.shipmentIds);
refreshLocalStore(ids);
});Die Flutter-Version war ein Aufruf an Workmanager().registerPeriodicTask(...) und ein Top-Level-Callback. Keine Bridge, keine Payload-Drift, kein Event-Emitter:
// Flutter-Seite — der 1:1-Ersatz, sauberer
@pragma("vm:entry-point")
void callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
// Läuft in einem Background-Isolate. Holt die neuesten
// Sendungsstatus, schreibt sie in die lokale DB.
final ids = await syncShipmentsFromServer();
await refreshLocalStore(ids);
return true;
});
}
void main() {
Workmanager().initialize(callbackDispatcher);
Workmanager().registerPeriodicTask(
"shipment-sync",
"syncShipments",
frequency: const Duration(hours: 1),
);
runApp(const StorkApp());
}Woche 6 — App-Store-Wiedereinreichung. Eigene Geschichte. Die Kurzversion ist, dass das Durchlaufen des Reviews mit einem Flutter-Binary etwa so lange dauerte wie das Durchlaufen mit einem RN-Binary. Apple kümmert es nicht, was eure Toolkit ist.
Was die Migration kostete
In Stunden: etwa 240 Stunden Solo-Arbeit über sechs Wochen. In einigen dieser Wochen pflegte ich auch noch den RN-Build für Pilot-Merchants, was beide Tracks verlangsamte.
An Opportunitätskosten: drei Wochen neue Features, die ich stattdessen hätte ausliefern können. Der Merchant-Pilot hatte eine Feature-Wunschliste; wir lieferten die Migration aus, bevor wir irgendeines davon auslieferten.
An Ökosystemkosten: zwei der neun Pakete, die ich wählte, sind immer noch suboptimal. Das Flutter-flutter_inappwebview-Paket funktioniert, fühlt sich aber für unseren Use-Case übergewichtig an; das türkische Adress-Autocomplete-Paket, das wir nutzen, wird community-maintained von einer Person, und der Bus-Faktor macht mir Sorgen. Ich lebe mit beidem.
Was ich wieder tun würde
Die Migration selbst, ja. Spezifisch:
- Ich würde immer noch Flutter für diese Art von App wählen — ein Sendungsverfolgungs-UI mit Barcode-Scanner, Push-Routing und Background-Sync.
- Ich würde es immer noch solo machen. Zwei Leute bei einer Neuschreibung erzeugen mehr Koordinations-Overhead, als sie sparen.
- Ich würde immer noch ein Sechs-Wochen-Budget setzen und es nicht padden. Das Budget hielt mich davon ab, die Neuschreibung zu überengineeren.
Was ich bereue
Eine spezifische Sache. Ich hätte den Barcode-Scanner an Tag eins der Woche eins in Flutter prototypisieren sollen, nicht in Woche drei. Am Ende funktionierte er, aber zwei Abende des Tunings von Fokus- und Auslöseverhalten geschahen in Woche drei, als ich weniger Puffer hatte. Hätte der Scanner nicht funktioniert, wäre die gesamte Migration die falsche Entscheidung gewesen, und ich hätte das zwei Wochen zu spät gelernt.
Die allgemeine Regel, die ich für nächstes Mal aufschreiben würde: Wenn man eine App neu schreibt, prototypisiere den einzelnen riskantesten nativen Flow in der neuen Toolkit, bevor du dich festlegst. Nicht danach.
Der langweilige Punkt
Leute fragen, was besser ist, RN oder Flutter, und das ist die falsche Frage. Die richtige Frage ist, was für deine spezifische App langweiliger ist. "Langweilig" bedeutet hier: welche Toolkit lässt dich weniger Zeit damit verbringen, die Toolkit zu debuggen, und mehr Zeit damit, Produkt auszuliefern.
Für Stork, im November 2024, war das Flutter. Für ein anderes Team — JS-lastig, Web-first, mit gemeinsamer Logik über ein Next.js-Codebase — wäre es immer noch React Native.
Drei Tage, nachdem ich die Flutter-Version gepusht hatte, fragte ein Junior im Team: "Moment, die App lädt jetzt in 1,2 Sekunden?" Ja, sagte ich. Es ist einfach Dart.
// wenn du schon hier bist
- 8 Min Lesezeit
React Native, der Native Driver und der Jank, den du endlich fühlen kannst
Wann useNativeDriver dir wirklich etwas bringt, wann er dich anlügt, und wie du den verlorenen Frame findest, der deinen Scanner-Bildschirm langsam wirken lässt.
react-nativeperformancemobile - 8 Min Lesezeit
Wie Apple meine App dreimal abgelehnt hat, weil sie zu türkisch war
Elf Tage, drei Ablehnungen, ein Launch-Fenster. Ein ehrlicher Bericht darüber, wie die Stork-Mobile-App das App-Store-Review überstanden hat — als die erste Beschwerde des Reviewers war, dass die Sprache nicht Englisch sei.
mobileiosapp-storeinternationalization