WebView-App: Payment Flows, State Sync und Plattform-Hacks (Teil 2)
TL;DR: In Teil 1 habe ich gezeigt, warum "einfach die Website einbetten" bei einem Schmuck-E-Commerce-Projekt mit Shopify-Backend komplexer war als gedacht. Jetzt zeige ich: Das war kein Einzelfall. Ein völlig anderes Projekt — Schweizer Elektronik-Retailer, Custom Backend, andere Payment-Provider — kämpft mit denselben strukturellen Problemen. Plus einer ganzen neuen Schicht: Payment-Flows über App-Grenzen hinaus, native State-Synchronisation via API-Interception, und Plattform-Berechtigungen, die zwischen iOS und Android komplett unterschiedlich funktionieren.
WebView ist eine Systemkomponente, die es mobilen Apps erlaubt, Webinhalte direkt innerhalb der App anzuzeigen — im Grunde ein Browser ohne Adressleiste. In einer WebView-App werden bestehende Webseiten in eine native Hülle eingebettet, statt sie nativ nachzubauen. Was nach einer einfachen Lösung klingt, führt in der Praxis zu erheblicher Komplexität — wie dieser Artikel anhand von echtem Flutter-Code zeigt.
Anderes Projekt, gleiche Schmerzen
Laut Precedence Research wächst das Segment für Cross-Platform-App-Entwicklung — zu dem auch WebView-basierte Hybrid-Apps gehören — mit einer jährlichen Wachstumsrate von 17,3 % bis 2034. Mehr Hybrid-Apps bedeutet: mehr Teams, die auf dieselben WebView-Probleme stoßen werden.
Nach der Veröffentlichung von Teil 1 hätte man einwenden können: "Klar, das war ein Shopify-Projekt. Shopify hat eben seine Eigenheiten. Mit einem Custom Backend wäre das einfacher."
Spoiler: Nein.
Das zweite Projekt ist eine Flutter-App für einen Schweizer Elektronik-Retailer. Keine Shopify-Abhängigkeit, sondern ein eigenes Backend mit REST-APIs. Eigene Authentifizierung. Eigene Payment-Integration über Datatrans. Und trotzdem: dieselben strukturellen Probleme.
Die Architektur folgt dem gleichen Hybrid-Ansatz: Native Screens für Auth, Home, Suche und Watchlists. WebViews für Produktseiten, Warenkorb, Checkout und Account-Einstellungen. Dasselbe Plugin-System. Dieselbe JavaScript-Bridge (window.mobileApp). Dieselbe shouldOverrideUrlLoading-Logik mit plattformspezifischen Workarounds für iOS und Android.
Aber dieses Projekt hat ein Element, das im Shopify-Projekt nicht existierte: einen Payment-Flow, der die WebView verlässt. Und genau da wird es richtig interessant.
In Zahlen: 648 Zeilen WebView-Container, 223 Zeilen Payment-Browser, 436 Zeilen Checkout-Seite, 10 Plugins. Für "einfach die Website einbetten".
Payment Flows: Wenn der WebView nicht mehr reicht
Im Shopify-Projekt lief der gesamte Checkout innerhalb des WebViews. Hier nicht. Der Payment-Flow ist ein eigenes Biest.
Das Problem: Drei Welten statt zwei
Beim Checkout passiert Folgendes:
Der Nutzer sieht den Warenkorb (WebView #1)
Er geht zum Checkout (WebView #1 navigiert zur Checkout-URL)
Er wählt eine Zahlungsmethode und klickt "Bezahlen"
Der Payment-Provider (Datatrans) öffnet seine eigene Seite
Je nach Zahlungsmethode öffnet sich eine externe App (TWINT, Google Pay)
Nach der Zahlung muss der Nutzer zurück in die App — auf die Bestätigungsseite
Das sind nicht zwei Welten (nativ + Web), sondern drei: nativ + WebView + externe Payment-Apps. Und jeder Übergang ist eine potenzielle Fehlerquelle.
InAppBrowser: Ein separater Browser für Payments
Die Lösung: Ein InAppBrowser — kein InAppWebView. Der Unterschied ist entscheidend: Ein InAppBrowser ist ein eigenständiges Browserfenster mit eigener Toolbar, das über der App schwebt. Ein InAppWebView ist ein Widget, das in den Flutter-Widget-Tree eingebettet ist.
Warum ein separater Browser? Weil Payment-Provider wie Datatrans URLs aufrufen, die aus dem normalen WebView herausnavigieren müssen — zu TWINT, zu Kreditkarten-Verifizierungsseiten, zu externen Apps. Ein eingebetteter WebView kann das nicht zuverlässig.
// Checkout-Seite: Payment-Flow startenFuture<void> launchPaymentBrowser(Uri paymentUri) async {final paymentBrowser = PaymentBrowser(ref,checkoutWebviewController: webviewController,paymentSuccessCallback:() async {// Nach erfolgreicher Zahlung: zurück zur Startseitecontext.router.popUntilRoot();awaitcontext.router.navigate(constHomeTab());},);awaitpaymentBrowser.openUrlRequest(urlRequest: URLRequest(url: WebUri.uri(paymentUri)),settings: InAppBrowserClassSettings(browserSettings: InAppBrowserSettings(hideUrlBar:true,hideToolbarBottom:true,hideDefaultMenuItems:true,// iOS: eigener Close-Button-TextcloseButtonCaption: 'Zurück zum Shop',// Android: Close-Button als Menü-Item in der Toolbar// (siehe unten)),),);}
// Checkout-Seite: Payment-Flow startenFuture<void> launchPaymentBrowser(Uri paymentUri) async {final paymentBrowser = PaymentBrowser(ref,checkoutWebviewController: webviewController,paymentSuccessCallback:() async {// Nach erfolgreicher Zahlung: zurück zur Startseitecontext.router.popUntilRoot();awaitcontext.router.navigate(constHomeTab());},);awaitpaymentBrowser.openUrlRequest(urlRequest: URLRequest(url: WebUri.uri(paymentUri)),settings: InAppBrowserClassSettings(browserSettings: InAppBrowserSettings(hideUrlBar:true,hideToolbarBottom:true,hideDefaultMenuItems:true,// iOS: eigener Close-Button-TextcloseButtonCaption: 'Zurück zum Shop',// Android: Close-Button als Menü-Item in der Toolbar// (siehe unten)),),);}
// Checkout-Seite: Payment-Flow startenFuture<void> launchPaymentBrowser(Uri paymentUri) async {final paymentBrowser = PaymentBrowser(ref,checkoutWebviewController: webviewController,paymentSuccessCallback:() async {// Nach erfolgreicher Zahlung: zurück zur Startseitecontext.router.popUntilRoot();awaitcontext.router.navigate(constHomeTab());},);awaitpaymentBrowser.openUrlRequest(urlRequest: URLRequest(url: WebUri.uri(paymentUri)),settings: InAppBrowserClassSettings(browserSettings: InAppBrowserSettings(hideUrlBar:true,hideToolbarBottom:true,hideDefaultMenuItems:true,// iOS: eigener Close-Button-TextcloseButtonCaption: 'Zurück zum Shop',// Android: Close-Button als Menü-Item in der Toolbar// (siehe unten)),),);}
Und schon fängt es an: iOS und Android brauchen unterschiedliche Close-Buttons. Auf iOS gibt es einen nativen closeButtonCaption. Auf Android muss man einen Menü-Item in die Toolbar injizieren:
// Android: Close-Button als Menü-Itemif(Platform.isAndroid){browser.addMenuItem(InAppBrowserMenuItem(id: 1,title:'Zurück zum Shop',showAsAction:true,order: 0,onClick:(){browser.close();browser.onExit();},),);}
// Android: Close-Button als Menü-Itemif(Platform.isAndroid){browser.addMenuItem(InAppBrowserMenuItem(id: 1,title:'Zurück zum Shop',showAsAction:true,order: 0,onClick:(){browser.close();browser.onExit();},),);}
// Android: Close-Button als Menü-Itemif(Platform.isAndroid){browser.addMenuItem(InAppBrowserMenuItem(id: 1,title:'Zurück zum Shop',showAsAction:true,order: 0,onClick:(){browser.close();browser.onExit();},),);}
TWINT und intent:// — Wenn URLs keine URLs mehr sind
Dann kommt TWINT. TWINT ist die Schweizer Mobile-Payment-Lösung, und sie nutzt Custom URL-Schemes: twint://, twint-issuer1:// bis twint-issuer39://, twint-extended://. 40 verschiedene URL-Schemes für einen Payment-Provider.
Der Payment-Browser muss diese Schemes erkennen und an das Betriebssystem weiterleiten, damit die TWINT-App geöffnet wird:
bool isCustomAppScheme(String url){if(url.isEmpty)returnfalse;final uri = Uri.parse(url);final scheme = uri.scheme.toLowerCase();// TWINT: twint-issuer1 bis twint-issuer39, twint-extendedif(scheme.startsWith('twint')){returntrue;}// Generische App-Linksif(url.contains('://applinks/')){returntrue;}returnfalse;}
bool isCustomAppScheme(String url){if(url.isEmpty)returnfalse;final uri = Uri.parse(url);final scheme = uri.scheme.toLowerCase();// TWINT: twint-issuer1 bis twint-issuer39, twint-extendedif(scheme.startsWith('twint')){returntrue;}// Generische App-Linksif(url.contains('://applinks/')){returntrue;}returnfalse;}
bool isCustomAppScheme(String url){if(url.isEmpty)returnfalse;final uri = Uri.parse(url);final scheme = uri.scheme.toLowerCase();// TWINT: twint-issuer1 bis twint-issuer39, twint-extendedif(scheme.startsWith('twint')){returntrue;}// Generische App-Linksif(url.contains('://applinks/')){returntrue;}returnfalse;}
Auf Android gibt es zusätzlich intent://-URLs. Das sind spezielle Android-URIs, die eine App direkt öffnen können — mit Fallback auf den Play Store, wenn die App nicht installiert ist. Der Payment-Browser muss das erkennen und an die Android Intent API weiterleiten:
// Android: intent:// URLs an die Intent API übergebenif(Platform.isAndroid && url.startsWith('intent://')){try{awaitAndroidIntent.parseAndLaunch(uri.toString());returnNavigationActionPolicy.CANCEL;}catch(e){log('Error launching Android Intent: $e');returnNavigationActionPolicy.ALLOW;}}
// Android: intent:// URLs an die Intent API übergebenif(Platform.isAndroid && url.startsWith('intent://')){try{awaitAndroidIntent.parseAndLaunch(uri.toString());returnNavigationActionPolicy.CANCEL;}catch(e){log('Error launching Android Intent: $e');returnNavigationActionPolicy.ALLOW;}}
// Android: intent:// URLs an die Intent API übergebenif(Platform.isAndroid && url.startsWith('intent://')){try{awaitAndroidIntent.parseAndLaunch(uri.toString());returnNavigationActionPolicy.CANCEL;}catch(e){log('Error launching Android Intent: $e');returnNavigationActionPolicy.ALLOW;}}
iOS hat intent:// nicht — dort werden Custom URL-Schemes direkt über launchUrl geöffnet. Wieder zwei Code-Pfade für zwei Plattformen.
Fehler-Erkennung und Bestätigungs-Seiten
Der Payment-Browser muss wissen, ob die Zahlung erfolgreich war. Kein Callback, kein Event — nur die URL. Der Browser trackt jede besuchte URL in einer Queue und prüft bei jedem Seitenwechsel:
class PaymentBrowser extendsInAppBrowser{finalvisitedUrls = Queue<Uri>();
@overridevoidonLoadStart(WebUri? url){// Datatrans signalisiert Fehler über URL-Parameterif(url.toString().contains('datatrans=error')){close();return;}if(url != null)visitedUrls.add(url);}
@overrideFuture<void> onExit() async {// Warenkorb refreshenawaitref.read(cartControllerProvider.notifier).refresh();// Letzte URL prüfen: Bestätigungsseite?if(lastVisitedUrl != null){final isConfirmation = awaitisCheckoutConfirmationUrl(lastVisitedUrl!);if(isConfirmation){awaitpaymentSuccessCallback();// → Home navigieren}else{awaitcheckoutWebviewController?.reload();// → Checkout neu laden}}}}
class PaymentBrowser extendsInAppBrowser{finalvisitedUrls = Queue<Uri>();
@overridevoidonLoadStart(WebUri? url){// Datatrans signalisiert Fehler über URL-Parameterif(url.toString().contains('datatrans=error')){close();return;}if(url != null)visitedUrls.add(url);}
@overrideFuture<void> onExit() async {// Warenkorb refreshenawaitref.read(cartControllerProvider.notifier).refresh();// Letzte URL prüfen: Bestätigungsseite?if(lastVisitedUrl != null){final isConfirmation = awaitisCheckoutConfirmationUrl(lastVisitedUrl!);if(isConfirmation){awaitpaymentSuccessCallback();// → Home navigieren}else{awaitcheckoutWebviewController?.reload();// → Checkout neu laden}}}}
class PaymentBrowser extendsInAppBrowser{finalvisitedUrls = Queue<Uri>();
@overridevoidonLoadStart(WebUri? url){// Datatrans signalisiert Fehler über URL-Parameterif(url.toString().contains('datatrans=error')){close();return;}if(url != null)visitedUrls.add(url);}
@overrideFuture<void> onExit() async {// Warenkorb refreshenawaitref.read(cartControllerProvider.notifier).refresh();// Letzte URL prüfen: Bestätigungsseite?if(lastVisitedUrl != null){final isConfirmation = awaitisCheckoutConfirmationUrl(lastVisitedUrl!);if(isConfirmation){awaitpaymentSuccessCallback();// → Home navigieren}else{awaitcheckoutWebviewController?.reload();// → Checkout neu laden}}}}
Das heißt: Wenn der Nutzer den Payment-Browser schließt (über den Close-Button oder die System-Geste), entscheidet die App anhand der letzten besuchten URL, ob die Zahlung erfolgreich war. Keine API-Abfrage, kein Server-Event — nur URL-Matching gegen Remote-Config-Werte.
Und die Bestätigungs-URL kommt nicht hardcoded aus dem Code, sondern aus Firebase Remote Config. Weil sich Payment-URLs ändern können, ohne dass ein App-Update nötig sein soll.
Die Checkout State Machine
Der Checkout selbst hat seine eigene State Machine. Ein cart_mutation-Parameter in der URL signalisiert dem Backend, ob der Warenkorb seit dem letzten Checkout geändert wurde:
// Cart-Hash vergleichen: Hat sich der Warenkorb geändert?bool shouldInitCart(WidgetRef ref,AsyncValue<CartData> cartState){final cartWebviewState = ref.watch(cartWebviewControllerProvider);final currentCartHash = cartState.value.hashCode;final lastCheckoutCartHash = cartWebviewState.lastCheckoutCartHash;// Wenn kein Hash gespeichert oder Hash unterschiedlich → neu initialisierenreturnlastCheckoutCartHash == null ||
currentCartHash != lastCheckoutCartHash;}// URL mit cart_mutation-Flagfinal checkoutUrl = '${baseUrl}/checkout?cart_mutation=${shouldInitCart ? 1 : 0}';
// Cart-Hash vergleichen: Hat sich der Warenkorb geändert?bool shouldInitCart(WidgetRef ref,AsyncValue<CartData> cartState){final cartWebviewState = ref.watch(cartWebviewControllerProvider);final currentCartHash = cartState.value.hashCode;final lastCheckoutCartHash = cartWebviewState.lastCheckoutCartHash;// Wenn kein Hash gespeichert oder Hash unterschiedlich → neu initialisierenreturnlastCheckoutCartHash == null ||
currentCartHash != lastCheckoutCartHash;}// URL mit cart_mutation-Flagfinal checkoutUrl = '${baseUrl}/checkout?cart_mutation=${shouldInitCart ? 1 : 0}';
// Cart-Hash vergleichen: Hat sich der Warenkorb geändert?bool shouldInitCart(WidgetRef ref,AsyncValue<CartData> cartState){final cartWebviewState = ref.watch(cartWebviewControllerProvider);final currentCartHash = cartState.value.hashCode;final lastCheckoutCartHash = cartWebviewState.lastCheckoutCartHash;// Wenn kein Hash gespeichert oder Hash unterschiedlich → neu initialisierenreturnlastCheckoutCartHash == null ||
currentCartHash != lastCheckoutCartHash;}// URL mit cart_mutation-Flagfinal checkoutUrl = '${baseUrl}/checkout?cart_mutation=${shouldInitCart ? 1 : 0}';
Warum? Weil der Checkout-WebView zwischen Tab-Wechseln nicht zerstört wird. Wenn der Nutzer zum Warenkorb wechselt, ein Produkt hinzufügt, und zurück zum Checkout geht, muss die App wissen, ob der Checkout neu initialisiert werden muss oder ob der bestehende noch gültig ist.
Dazu gibt es eine URL-Blacklist aus Remote Config:
// Remote Config: Bestimmte URLs sollen nicht im Checkout-WebView öffnenfinal blacklistHit = shopConfig
.checkoutBlacklistRules
.matchUri(uri);if(blacklistHit){// In separatem WebView öffnen statt im Checkoutcontext.router.push(StandaloneWebViewRoute(url: uri.toString()));returnNavigationActionPolicy.CANCEL;}
// Remote Config: Bestimmte URLs sollen nicht im Checkout-WebView öffnenfinal blacklistHit = shopConfig
.checkoutBlacklistRules
.matchUri(uri);if(blacklistHit){// In separatem WebView öffnen statt im Checkoutcontext.router.push(StandaloneWebViewRoute(url: uri.toString()));returnNavigationActionPolicy.CANCEL;}
// Remote Config: Bestimmte URLs sollen nicht im Checkout-WebView öffnenfinal blacklistHit = shopConfig
.checkoutBlacklistRules
.matchUri(uri);if(blacklistHit){// In separatem WebView öffnen statt im Checkoutcontext.router.push(StandaloneWebViewRoute(url: uri.toString()));returnNavigationActionPolicy.CANCEL;}
Das erlaubt es, zur Laufzeit — ohne App-Update — bestimmte URLs aus dem Checkout-Flow herauszunehmen. Wenn ein Payment-Provider eine neue Redirect-Seite einführt, die den Checkout bricht, kann das Backend-Team die URL in die Blacklist aufnehmen, und die App öffnet sie in einem separaten WebView.
Android: CSS-Injection im Payment-Browser
Und dann ist da noch das: Android rendert den Payment-Browser ohne ausreichend Abstand zur Toolbar. Die Lösung? CSS-Injection:
@overrideFuture<void> onLoadStop(WebUri? url) async {// Android: Padding oben hinzufügen, weil die Toolbar den Content überlapptif(Platform.isAndroid){awaitwebViewController?.evaluateJavascript(source:"""
(function(){varstyle = document.createElement('style');style.textContent = 'body { padding-top: 60px !important; }';document.head.appendChild(style);})();""");
}}
@overrideFuture<void> onLoadStop(WebUri? url) async {// Android: Padding oben hinzufügen, weil die Toolbar den Content überlapptif(Platform.isAndroid){awaitwebViewController?.evaluateJavascript(source:"""
(function(){varstyle = document.createElement('style');style.textContent = 'body { padding-top: 60px !important; }';document.head.appendChild(style);})();""");
}}
@overrideFuture<void> onLoadStop(WebUri? url) async {// Android: Padding oben hinzufügen, weil die Toolbar den Content überlapptif(Platform.isAndroid){awaitwebViewController?.evaluateJavascript(source:"""
(function(){varstyle = document.createElement('style');style.textContent = 'body { padding-top: 60px !important; }';document.head.appendChild(style);})();""");
}}
60 Pixel padding-top per JavaScript-Injection, weil der Payment-Browser auf Android die Toolbar über den Content legt. Auf iOS passiert das nicht. Wieder ein plattformspezifischer Workaround.
Native State-Synchronisation: Die unsichtbare Komplexität
Im Shopify-Projekt gab es eine JavaScript-Bridge, über die die Webseite die App aktiv benachrichtigt hat ("Hey, der Warenkorb hat sich geändert!"). Das setzt voraus, dass die Webseite kooperiert — dass auf Web-Seite Code eingebaut wird, der die Bridge aufruft.
In diesem Projekt gibt es einen zusätzlichen, passiven Ansatz: Die App beobachtet die API-Aufrufe des WebViews.
onLoadResource: Der stille Beobachter
Jedes Mal, wenn der WebView eine Ressource lädt — Bilder, Scripts, API-Aufrufe — feuert der onLoadResource-Callback. Die App nutzt das, um bestimmte API-Endpunkte zu erkennen und den nativen State zu aktualisieren:
onLoadResource:(controller,resource){// Preiseinstellungen: Brutto/Netto-Anzeige synchronisierenhandlePricePreferenceChange(resource,ref);// Watchlist: Änderungen erkennen und nativen State invalidierenhandleWatchlistChanges(resource,ref);// Vergleichsliste: Änderungen erkennenhandleCompareListChange(resource,ref);},
onLoadResource:(controller,resource){// Preiseinstellungen: Brutto/Netto-Anzeige synchronisierenhandlePricePreferenceChange(resource,ref);// Watchlist: Änderungen erkennen und nativen State invalidierenhandleWatchlistChanges(resource,ref);// Vergleichsliste: Änderungen erkennenhandleCompareListChange(resource,ref);},
onLoadResource:(controller,resource){// Preiseinstellungen: Brutto/Netto-Anzeige synchronisierenhandlePricePreferenceChange(resource,ref);// Watchlist: Änderungen erkennen und nativen State invalidierenhandleWatchlistChanges(resource,ref);// Vergleichsliste: Änderungen erkennenhandleCompareListChange(resource,ref);},
Konkret sieht das so aus:
voidhandlePricePreferenceChange(LoadedResource resource,WidgetRef ref){final isPricesEndpoint = resource.url
.toString()
.endsWith('api/accounts/settings/prices');// Wenn die Web-UI den Preis-Endpunkt aufruft, hat der Nutzer// möglicherweise zwischen Brutto- und Netto-Anzeige gewechselt.// → Session-State refreshen, damit die native UI konsistent bleibt.if(isPricesEndpoint){ref.read(sessionControllerProvider.notifier).refreshSessionState();}}voidhandleWatchlistChanges(LoadedResource resource,WidgetRef ref){// Watchlist-Übersicht: /api/watchlistsfinal isWatchlistOverview = resource.url
.toString()
.endsWith('/api/watchlists');if(isWatchlistOverview){ref.invalidate(watchlistOverviewControllerProvider);}// Einzelne Items: /api/watchlists/{id}/items oder /items/{itemId}final addItemRegex = RegExp(r'/api/watchlists/(.+)/items$');final deleteItemRegex = RegExp(r'/api/watchlists/(.+)/items/(.+)$');
final isItemChange = resource.url.toString().contains(addItemRegex) ||
resource.url.toString().contains(deleteItemRegex);if(isItemChange){// Watchlist-ID aus der URL extrahieren und gezielt refreshenfinal watchlistId = resource.url
.toString()
.split('/api/watchlists/')[1]
.split('/')[0];ref.read(watchlistControllerFamily(watchlistId).notifier).refresh();ref.invalidate(watchlistOverviewControllerProvider);}}
voidhandlePricePreferenceChange(LoadedResource resource,WidgetRef ref){final isPricesEndpoint = resource.url
.toString()
.endsWith('api/accounts/settings/prices');// Wenn die Web-UI den Preis-Endpunkt aufruft, hat der Nutzer// möglicherweise zwischen Brutto- und Netto-Anzeige gewechselt.// → Session-State refreshen, damit die native UI konsistent bleibt.if(isPricesEndpoint){ref.read(sessionControllerProvider.notifier).refreshSessionState();}}voidhandleWatchlistChanges(LoadedResource resource,WidgetRef ref){// Watchlist-Übersicht: /api/watchlistsfinal isWatchlistOverview = resource.url
.toString()
.endsWith('/api/watchlists');if(isWatchlistOverview){ref.invalidate(watchlistOverviewControllerProvider);}// Einzelne Items: /api/watchlists/{id}/items oder /items/{itemId}final addItemRegex = RegExp(r'/api/watchlists/(.+)/items$');final deleteItemRegex = RegExp(r'/api/watchlists/(.+)/items/(.+)$');
final isItemChange = resource.url.toString().contains(addItemRegex) ||
resource.url.toString().contains(deleteItemRegex);if(isItemChange){// Watchlist-ID aus der URL extrahieren und gezielt refreshenfinal watchlistId = resource.url
.toString()
.split('/api/watchlists/')[1]
.split('/')[0];ref.read(watchlistControllerFamily(watchlistId).notifier).refresh();ref.invalidate(watchlistOverviewControllerProvider);}}
voidhandlePricePreferenceChange(LoadedResource resource,WidgetRef ref){final isPricesEndpoint = resource.url
.toString()
.endsWith('api/accounts/settings/prices');// Wenn die Web-UI den Preis-Endpunkt aufruft, hat der Nutzer// möglicherweise zwischen Brutto- und Netto-Anzeige gewechselt.// → Session-State refreshen, damit die native UI konsistent bleibt.if(isPricesEndpoint){ref.read(sessionControllerProvider.notifier).refreshSessionState();}}voidhandleWatchlistChanges(LoadedResource resource,WidgetRef ref){// Watchlist-Übersicht: /api/watchlistsfinal isWatchlistOverview = resource.url
.toString()
.endsWith('/api/watchlists');if(isWatchlistOverview){ref.invalidate(watchlistOverviewControllerProvider);}// Einzelne Items: /api/watchlists/{id}/items oder /items/{itemId}final addItemRegex = RegExp(r'/api/watchlists/(.+)/items$');final deleteItemRegex = RegExp(r'/api/watchlists/(.+)/items/(.+)$');
final isItemChange = resource.url.toString().contains(addItemRegex) ||
resource.url.toString().contains(deleteItemRegex);if(isItemChange){// Watchlist-ID aus der URL extrahieren und gezielt refreshenfinal watchlistId = resource.url
.toString()
.split('/api/watchlists/')[1]
.split('/')[0];ref.read(watchlistControllerFamily(watchlistId).notifier).refresh();ref.invalidate(watchlistOverviewControllerProvider);}}
Das ist gleichzeitig elegant und fragil. Elegant, weil es keine Änderung an der Web-Codebase erfordert — die App beobachtet einfach, was der WebView tut. Fragil, weil jede Änderung an den API-Endpunkten die Pattern-Erkennung brechen kann.
Und es gibt noch ein Subtilität: onLoadResource feuert für alle Ressourcen — Bilder, CSS, JavaScript, Fonts, API-Aufrufe. Die App filtert nach URL-Patterns und reagiert nur auf relevante Endpunkte. Das funktioniert, solange die Endpunkt-Struktur stabil bleibt.
Plattform-Berechtigungen: Kamera, 2FA und User-Agent-Lügen
HeyLight Pay: Wenn der Payment-Provider die Kamera braucht
Ein Payment-Provider namens HeyLight (Buy-Now-Pay-Later) braucht Zugriff auf die Kamera des Geräts — für die Identitätsverifizierung. In einem normalen Browser kein Problem. In einem WebView innerhalb einer App? Ein Alptraum.
Problem 1: HeyLight erkennt den WebView. HeyLight macht Browser-Detection und blockiert alles, was nicht Safari oder Chrome ist. Die Standard-User-Agent-Kennung eines Flutter-WebViews wird abgelehnt.
Die Lösung — wie auch im Shopify-Projekt: User-Agent-Spoofing. Aber hier nur auf iOS, weil HeyLight speziell auf Safari prüft:
// Hardcoded Safari User Agent — nur für iOS// HeyLight prüft Browser-Detection und blockiert nicht-Safari-Browser// TODO: Investigate and replace this workaround
userAgent:Platform.isIOS
? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) ''AppleWebKit/605.1.15 (KHTML, like Gecko) ''Version/26.0 Mobile/15E148 Safari/604.1':null,// Android: Standard-User-Agent reicht aus
// Hardcoded Safari User Agent — nur für iOS// HeyLight prüft Browser-Detection und blockiert nicht-Safari-Browser// TODO: Investigate and replace this workaround
userAgent:Platform.isIOS
? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) ''AppleWebKit/605.1.15 (KHTML, like Gecko) ''Version/26.0 Mobile/15E148 Safari/604.1':null,// Android: Standard-User-Agent reicht aus
// Hardcoded Safari User Agent — nur für iOS// HeyLight prüft Browser-Detection und blockiert nicht-Safari-Browser// TODO: Investigate and replace this workaround
userAgent:Platform.isIOS
? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) ''AppleWebKit/605.1.15 (KHTML, like Gecko) ''Version/26.0 Mobile/15E148 Safari/604.1':null,// Android: Standard-User-Agent reicht aus
Problem 2: iOS-Kamera-Berechtigungen doppelt anfragen crasht. Wenn die WebView auf iOS eine Kamera-Berechtigung anfragt, zeigt iOS automatisch den System-Dialog. Wenn die App zusätzlichPermission.camera.request() aufruft, gibt es Probleme — der Dialog erscheint doppelt, oder die Berechtigung wird stillschweigend verweigert.
onPermissionRequest:(controller,request) async {final resources = request.resources;if(resources.contains(PermissionResourceType.CAMERA)){final isGranted = awaitPermission.camera.isGranted;// iOS: Die WebView zeigt den Permission-Dialog selbst.// Nochmals Permission.camera.request() aufrufen verursacht Probleme.if(Platform.isIOS){returnPermissionResponse(resources: resources,action: isGranted
? PermissionResponseAction.GRANT
: PermissionResponseAction.DENY,);}// Android: Permission-Dialog manuell triggernvarstatus = awaitPermission.camera.request();if(!status.isGranted){// Dialog zeigen: "Bitte gehe in die Einstellungen..."awaitshowDialog(context: context,builder:(_)=>constCameraPermissionDeniedDialog(),);}status = awaitPermission.camera.status;returnPermissionResponse(resources: resources,action: status.isGranted
? PermissionResponseAction.GRANT
: PermissionResponseAction.DENY,);}returnPermissionResponse(resources: resources);},
onPermissionRequest:(controller,request) async {final resources = request.resources;if(resources.contains(PermissionResourceType.CAMERA)){final isGranted = awaitPermission.camera.isGranted;// iOS: Die WebView zeigt den Permission-Dialog selbst.// Nochmals Permission.camera.request() aufrufen verursacht Probleme.if(Platform.isIOS){returnPermissionResponse(resources: resources,action: isGranted
? PermissionResponseAction.GRANT
: PermissionResponseAction.DENY,);}// Android: Permission-Dialog manuell triggernvarstatus = awaitPermission.camera.request();if(!status.isGranted){// Dialog zeigen: "Bitte gehe in die Einstellungen..."awaitshowDialog(context: context,builder:(_)=>constCameraPermissionDeniedDialog(),);}status = awaitPermission.camera.status;returnPermissionResponse(resources: resources,action: status.isGranted
? PermissionResponseAction.GRANT
: PermissionResponseAction.DENY,);}returnPermissionResponse(resources: resources);},
onPermissionRequest:(controller,request) async {final resources = request.resources;if(resources.contains(PermissionResourceType.CAMERA)){final isGranted = awaitPermission.camera.isGranted;// iOS: Die WebView zeigt den Permission-Dialog selbst.// Nochmals Permission.camera.request() aufrufen verursacht Probleme.if(Platform.isIOS){returnPermissionResponse(resources: resources,action: isGranted
? PermissionResponseAction.GRANT
: PermissionResponseAction.DENY,);}// Android: Permission-Dialog manuell triggernvarstatus = awaitPermission.camera.request();if(!status.isGranted){// Dialog zeigen: "Bitte gehe in die Einstellungen..."awaitshowDialog(context: context,builder:(_)=>constCameraPermissionDeniedDialog(),);}status = awaitPermission.camera.status;returnPermissionResponse(resources: resources,action: status.isGranted
? PermissionResponseAction.GRANT
: PermissionResponseAction.DENY,);}returnPermissionResponse(resources: resources);},
Drei verschiedene Verhaltensweisen: iOS zeigt den Dialog selbst, Android muss ihn manuell triggern, und wenn die Berechtigung permanent verweigert wurde, braucht der Nutzer einen Hinweis, dass er in die Geräteeinstellungen gehen muss. Alles für einen Payment-Provider, der die Kamera braucht.
TOTP/2FA: Wenn die WebView eine andere App öffnen muss
Die Web-Authentifizierung unterstützt Zwei-Faktor-Authentifizierung über TOTP (Time-Based One-Time Passwords). Wenn der Nutzer 2FA einrichtet, generiert die Webseite einen otpauth://-Link, den eine Authenticator-App (Google Authenticator, Authy, etc.) öffnen soll.
In einem normalen Browser öffnet sich die Authenticator-App automatisch. In einem WebView? Der Link wird als Navigation behandelt — und schlägt fehl.
// shouldOverrideUrlLoading: otpauth:// abfangen und extern öffnenif(url.startsWith('otpauth:')){awaithandleTotpUrl(url);returnNavigationActionPolicy.CANCEL;}
// shouldOverrideUrlLoading: otpauth:// abfangen und extern öffnenif(url.startsWith('otpauth:')){awaithandleTotpUrl(url);returnNavigationActionPolicy.CANCEL;}
// shouldOverrideUrlLoading: otpauth:// abfangen und extern öffnenif(url.startsWith('otpauth:')){awaithandleTotpUrl(url);returnNavigationActionPolicy.CANCEL;}
Future<void> handleTotpUrl(String totpUrl) async {try{final uri = Uri.parse(totpUrl);final canLaunch = awaitcanLaunchUrl(uri);if(canLaunch){awaitlaunchUrl(uri,mode: LaunchMode.externalApplication);}else{// Keine Authenticator-App installiertshowWarning(title:'Keine Authenticator-App gefunden',subtitle:'Bitte installiere eine Authenticator-App...',);}}catch(e){showWarning(title:'Fehler',subtitle:'Der QR-Code konnte nicht verarbeitet werden.',);}}
Future<void> handleTotpUrl(String totpUrl) async {try{final uri = Uri.parse(totpUrl);final canLaunch = awaitcanLaunchUrl(uri);if(canLaunch){awaitlaunchUrl(uri,mode: LaunchMode.externalApplication);}else{// Keine Authenticator-App installiertshowWarning(title:'Keine Authenticator-App gefunden',subtitle:'Bitte installiere eine Authenticator-App...',);}}catch(e){showWarning(title:'Fehler',subtitle:'Der QR-Code konnte nicht verarbeitet werden.',);}}
Future<void> handleTotpUrl(String totpUrl) async {try{final uri = Uri.parse(totpUrl);final canLaunch = awaitcanLaunchUrl(uri);if(canLaunch){awaitlaunchUrl(uri,mode: LaunchMode.externalApplication);}else{// Keine Authenticator-App installiertshowWarning(title:'Keine Authenticator-App gefunden',subtitle:'Bitte installiere eine Authenticator-App...',);}}catch(e){showWarning(title:'Fehler',subtitle:'Der QR-Code konnte nicht verarbeitet werden.',);}}
Ein otpauth://-Link ist technisch eine URL, aber keine Webseite. Er enthält den Secret Key und Accountnamen im URL-Format: otpauth://totp/Account?secret=ABCDEF&issuer=MyApp. Die App muss erkennen, dass das kein normaler Link ist, die Navigation abbrechen, und den Link an das Betriebssystem weiterleiten.
Und was passiert, wenn der Nutzer keine Authenticator-App installiert hat? Dann muss die App eine verständliche Fehlermeldung zeigen — statt eines kryptischen "URL konnte nicht geöffnet werden"-Fehlers.
CSS-Injection und andere kreative Lösungen
Pull-to-Refresh: 101vh
Pull-to-Refresh im WebView hat einen Bug: Wenn der Seiteninhalt nicht höher als der Viewport ist, funktioniert die Geste nicht. Die Lösung? CSS-Injection, die den Content künstlich 1% höher macht:
101% der Viewport-Höhe. Weil 100% nicht reicht, um dem WebView zu signalisieren, dass gescrollt werden kann — und ohne Scroll-Möglichkeit feuert Pull-to-Refresh nicht.
PDF-Downloads: Abfangen und teilen
WebViews können keine PDFs herunterladen — sie versuchen, sie zu rendern oder navigieren zu einer leeren Seite. Die Lösung: den Download abfangen, die Datei mit dem authentifizierten HTTP-Client herunterladen, und über den nativen Share-Sheet teilen.
onDownloadStartRequest:(controller,request) async {if(request.mimeType == 'application/pdf'){awaitcontroller.stopLoading();// Navigation stoppenfinal pdfUrl = request.url.toString();// Mit authentifiziertem Client herunterladenawaitdownloadAndSharePdf(pdfUrl,ref,context: context);// Warten, bis der Share-Dialog geschlossen istawaitFuture.delayed(constDuration(milliseconds:300));awaitcontext.maybePop();}},
onDownloadStartRequest:(controller,request) async {if(request.mimeType == 'application/pdf'){awaitcontroller.stopLoading();// Navigation stoppenfinal pdfUrl = request.url.toString();// Mit authentifiziertem Client herunterladenawaitdownloadAndSharePdf(pdfUrl,ref,context: context);// Warten, bis der Share-Dialog geschlossen istawaitFuture.delayed(constDuration(milliseconds:300));awaitcontext.maybePop();}},
onDownloadStartRequest:(controller,request) async {if(request.mimeType == 'application/pdf'){awaitcontroller.stopLoading();// Navigation stoppenfinal pdfUrl = request.url.toString();// Mit authentifiziertem Client herunterladenawaitdownloadAndSharePdf(pdfUrl,ref,context: context);// Warten, bis der Share-Dialog geschlossen istawaitFuture.delayed(constDuration(milliseconds:300));awaitcontext.maybePop();}},
Drei Zeilen wären es, wenn WebViews PDFs nativ unterstützen würden. Stattdessen: Download abfangen, Loading stoppen, separaten HTTP-Request mit Cookies, temporäre Datei erstellen, Share-Sheet öffnen, temporäre Datei löschen, und eine künstliche Verzögerung, weil sonst die Seite navigiert, bevor der Share-Dialog erscheint.
Bot-Detection: Ein Cookie für die Entwicklung
Analytics-Dienste wie Tealium haben Bot-Detection. In der Entwicklungsumgebung — wo ein Entwickler hunderte Mal pro Tag dieselbe Seite lädt — wird der WebView als Bot erkannt und blockiert.
Ein einziges Cookie. Aber ohne es funktioniert die App in der Entwicklung nicht.
Das Muster erkennen
Zwei Projekte. Unterschiedliche Branchen (Schmuck vs. Elektronik). Unterschiedliche Backends (Shopify vs. Custom). Unterschiedliche Payment-Provider (Shop Pay vs. Datatrans/TWINT). Und trotzdem dieselben Muster:
Muster
Projekt 1 (Schmuck/Shopify)
Projekt 2 (Elektronik/Custom)
User-Agent-Spoofing
Shopify blockiert WebView-UA
HeyLight blockiert nicht-Safari-UA
iOS vs. Android Navigation
NavigationType.BACK_FORWARD vs. isRedirect
onDidReceiveServerRedirectForProvisionalNavigation vs. isRedirect
WebView zerstören & neu erstellen
ValueKey mit Auth-Token
rebuildIndex mit Cookie-Changes
Künstliche Verzögerungen
700ms für Back-Navigation-Reload
300ms für Share-Dialog-Timing
CSS/JS-Injection
Consent-Daten, Height-Messung
Pull-to-Refresh 101vh, Padding-Fix
Remote Config
Whitelisted Domains
Checkout-Blacklist, Payment-URLs
Das sind keine zufälligen Überschneidungen. Das sind strukturelle Konsequenzen des WebView-Ansatzes. Jedes Projekt, das WebViews in einer nativen App einsetzt, wird früher oder später auf diese Muster stoßen:
Plattform-Divergenz: iOS und Android behandeln WebView-Events grundlegend anders
Grenz-Management: Jeder Übergang zwischen nativ und Web erfordert Synchronisation
Workaround-Kultur: Jeder dritte Fix ist ein Hack mit einem TODO-Kommentar
Externe Abhängigkeiten: Payment-Provider, Analytics-Dienste und andere Dritte haben ihre eigenen Regeln, die im WebView-Kontext brechen
Fazit
Wenn ich eines aus diesen beiden Projekten gelernt habe, dann das: Die Komplexität von WebViews ist nicht projektspezifisch — sie ist strukturell.
Es spielt keine Rolle, ob das Backend Shopify oder custom ist. Es spielt keine Rolle, ob der Payment-Provider Shop Pay oder Datatrans heißt. Es spielt keine Rolle, ob die Branche Schmuck oder Elektronik ist. Sobald man eine Website in eine native App einbettet, entstehen dieselben Probleme an denselben Stellen:
Auth-Synchronisation über Cookie-Management
Plattformspezifische Navigation-Workarounds
JavaScript-Bridges für die Kommunikation
User-Agent-Spoofing gegen Server-Blocking
Und — wie dieses Projekt zeigt — Payment-Flows, die über die Grenzen der App hinausgehen
Das bedeutet nicht, dass WebViews die falsche Wahl sind. Es bedeutet, dass man die Komplexität einplanen muss. Nicht als "machen wir dann, wenn es soweit ist", sondern als fester Bestandteil der Architektur-Planung.
Die Frage an Entscheidungsträger bleibt dieselbe wie in Teil 1: Nicht ob, sondern wo. Welche Screens nativ, welche WebView, und wie kommunizieren sie? Wer diese Frage mit dem Wissen aus zwei realen Projekten beantwortet, trifft bessere Entscheidungen.
Häufig gestellte Fragen (FAQ)
Kann man Payment-Flows wie TWINT oder Google Pay direkt in einem WebView abwickeln?
Nein — zumindest nicht zuverlässig. Payment-Provider wie TWINT verwenden Custom URL-Schemes (twint://, twint-issuer1:// bis twint-issuer39://), die aus dem WebView heraus nicht direkt geöffnet werden können. Die Lösung ist ein separater InAppBrowser, der diese Schemes ans Betriebssystem weiterleiten kann. Auf Android kommen zusätzlich intent://-URLs hinzu, die eine eigene Behandlung erfordern.
Was ist der Unterschied zwischen InAppWebView und InAppBrowser in Flutter?
Ein InAppWebView ist ein Widget, das in den Flutter-Widget-Tree eingebettet wird — vergleichbar mit einem <iframe> im Web. Ein InAppBrowser dagegen öffnet ein eigenständiges Browserfenster mit eigener Toolbar über der App. Für Payment-Flows wird oft der InAppBrowser benötigt, weil Payment-Provider URLs aufrufen, die aus dem normalen WebView herausnavigieren müssen.
Warum braucht man User-Agent-Spoofing in WebView-Apps?
Viele Server und Dienste erkennen den Standard-User-Agent eines WebViews und verweigern den Zugriff — entweder aus Sicherheitsgründen oder weil sie nur "echte" Browser unterstützen wollen. In der Praxis muss man den User-Agent pro Plattform hardcoden: Shopify blockiert Standard-WebView-UAs, und Payment-Provider wie HeyLight prüfen explizit auf Safari oder Chrome.
Wie synchronisiert man nativen App-State mit einem WebView?
Es gibt zwei Ansätze: aktiv und passiv. Beim aktiven Ansatz injiziert man JavaScript-Code in den WebView, der über eine Bridge (window.mobileApp.functionName) die native App benachrichtigt. Beim passiven Ansatz — wie in diesem Artikel beschrieben — beobachtet die App über onLoadResource die API-Aufrufe des WebViews und aktualisiert den nativen State, wenn bestimmte Endpunkte aufgerufen werden. Beide Ansätze haben Trade-offs: aktiv erfordert Änderungen an der Webseite, passiv ist fragil bei API-Änderungen.
Sind WebView-Probleme framework-spezifisch oder plattformübergreifend?
Die Probleme sind plattformübergreifend und strukturell. Ob Flutter, React Native oder native Entwicklung — die fundamentalen Herausforderungen (Auth-Synchronisation, plattformspezifische Navigation, Payment-Flow-Management, User-Agent-Spoofing) treten in jedem Framework auf, das WebViews einsetzt. Das Framework bestimmt nur die API, über die man die Workarounds implementiert.
Dieser Artikel ist Teil 2 einer Serie über WebView-Komplexität in mobilen Apps. Teil 1 lesen | Du planst eine hybride App und willst die Fallstricke vermeiden? Termin buchen — ich berate bei genau diesen Architektur-Entscheidungen.