Feb 18, 2026

WebView in App einbetten — Warum es komplizierter ist, als du denkst

TL;DR: WebViews klingen nach der günstigen Lösung —bestehende Website einbetten, fertig. In der Praxis führen sie aber zu versteckter Komplexität in Authentifizierung, Navigation und Native-Web-Kommunikation. Anhand eines echten E-Commerce-Projekts mit Shopify-Backend zeige ich, wo die Fallen liegen —und wo WebViews trotzdem die richtige Wahl sind.

"Können wir nicht einfach unsere Website in die App einbetten?"

Diesen Satz hört jeder Mobile-Entwickler früher oder später. Und ehrlich gesagt: Er klingt erstmal völlig logisch. Da ist ein Onlineshop, der funktioniert. Die Produktseiten sind gepflegt, der Checkout läuft, die Suche ist integriert. Warum das alles nochmal nativ bauen, wenn man es doch einfach in einem WebView anzeigen könnte?

Ein WebView ist eine Browserkomponente, die innerhalb einer nativen App läuft —im Grunde ein eingebetteter Browser ohne Adressleiste. Die Idee: bestehende Webseiten in der App anzeigen, ohne sie nativ nachzubauen. Laut einer Analyse von AppBrain (2025) nutzen über 60% aller Android-Apps eine Form von WebView-Technologie.

Die Antwort auf die Eingangsfrage ist: Man kann. Aber "einfach" ist es nicht.

In diesem Artikel zeige ich anhand eines echten Projekts —einer Schmuck-E-Commerce-Brand mit Shopify-Backend —was hinter der einen Zeile Code steckt, die eine Website in eine App einbettet. Kein theoretisches Geplänkel, sondern echte Code-Beispiele, echte Bugs, echte Workarounds. Ehrlich und datenbasiert.

Das Projekt ist eine Flutter-App, die einen hybriden Ansatz verfolgt: Manche Screens sind nativ gebaut, andere sind WebViews. Und genau an der Grenze zwischen diesen beiden Welten passieren die Dinge, die man als Kunde erstmal nicht auf dem Schirm hat.

Was folgt, ist kein Argument gegen WebViews. Es ist ein Argument für ehrliche Planung. Denn wer die Komplexität kennt, kann sie managen. Wer sie ignoriert, bezahlt später —mit Budget, mit Bugs, oder mit beidem.

Hybrid-Architektur: Wo nativ, wo WebView?

Bevor wir in die Komplexität eintauchen, ein kurzer Überblick über die Architektur. Nicht alles in der App ist ein WebView —und nicht alles ist nativ. Die Aufteilung sieht so aus:

Nativ gebaut:

  • Authentifizierung (Login, Registrierung)

  • Onboarding

  • Home Page mit Produkt-Highlights

  • Account-Verwaltung

  • Settings & Einstellungen

WebView:

  • Shop-Browsing (Kollektionen, Kategorien)

  • Produktseiten

  • Warenkorb

  • Checkout

  • Suche

  • Bestellübersicht

Warum diese Aufteilung? Drei Gründe:

  1. CMS-Flexibilität: Produktseiten und Kollektionen ändern sich ständig. Das Marketing-Team pflegt sie über Shopify —ein nativer Nachbau würde bedeuten, jede CMS-Änderung manuell in der App nachzuziehen.

  2. PCI-Compliance: Den Checkout nativ zu bauen würde bedeuten, selbst PCI-compliant zu werden. Bei gehosteten Lösungen wie Shopify Checkout übernimmt das der Anbieter.

  3. Kosten-Nutzen: Nicht jeder Screen rechtfertigt eine native Implementierung. Manchmal ist pragmatisch besser als perfekt.

Und so sieht die Shop-Seite im Code aus:

// So einfach sieht es aus...
WebviewPageWidget(initialUrl: shopConfig.baseUrl);
// So einfach sieht es aus...
WebviewPageWidget(initialUrl: shopConfig.baseUrl);
// So einfach sieht es aus...
WebviewPageWidget(initialUrl: shopConfig.baseUrl);

Eine Zeile. Das wars. Zumindest auf den ersten Blick.

Aber hinter dieser einen Zeile steckt ein 476-Zeilen Container, fünf Plugins mit insgesamt über 800 Zeilen Code, eine JavaScript-Bridge, Cookie-Management, plattformspezifische Navigation-Hacks und eine State Machine für den initialen Page-Load.

Schauen wir uns an, was genau da passiert.

Komplexität #1: Wenn der Nutzer sich einloggt, muss die WebView es auch wissen

Das erste große Problem bei hybriden Apps: Authentifizierung lebt in zwei Welten.

Der Login passiert nativ. Die App spricht direkt mit der Shopify Storefront API, speichert Tokens sicher über flutter_secure_storage, verwaltet den Auth-State über einen zentralen Controller. Alles sauber, alles unter Kontrolle.

Aber dann muss der Nutzer seinen Warenkorb sehen. Und der Warenkorb ist ein WebView. Und der WebView weiß erstmal gar nicht, dass der Nutzer eingeloggt ist.

Tokens zu Cookies konvertieren

Die Lösung: Ein TokenBag-Model, das native Tokens in WebView-Cookies konvertiert.

// TokenBag: Konvertiert API-Tokens in WebView-Cookies
class TokenBag {
  final String? cartId;
  final String? shopifyCustomerAccessToken;

  // Cart-ID kommt als "gid://shopify/Cart/xxx?key=xxx"
  // Wir brauchen nur "xxx?key=xxx"
  String? get cartToken => cartId?.split('/').last;

  Map<String, String> get cookies {
    return {
      if (cartToken != null) 'cart': cartToken!,
      if (shopifyCustomerAccessToken != null)
        'shopifyCustomerAccessToken': shopifyCustomerAccessToken!,
    };
  }
}
// TokenBag: Konvertiert API-Tokens in WebView-Cookies
class TokenBag {
  final String? cartId;
  final String? shopifyCustomerAccessToken;

  // Cart-ID kommt als "gid://shopify/Cart/xxx?key=xxx"
  // Wir brauchen nur "xxx?key=xxx"
  String? get cartToken => cartId?.split('/').last;

  Map<String, String> get cookies {
    return {
      if (cartToken != null) 'cart': cartToken!,
      if (shopifyCustomerAccessToken != null)
        'shopifyCustomerAccessToken': shopifyCustomerAccessToken!,
    };
  }
}
// TokenBag: Konvertiert API-Tokens in WebView-Cookies
class TokenBag {
  final String? cartId;
  final String? shopifyCustomerAccessToken;

  // Cart-ID kommt als "gid://shopify/Cart/xxx?key=xxx"
  // Wir brauchen nur "xxx?key=xxx"
  String? get cartToken => cartId?.split('/').last;

  Map<String, String> get cookies {
    return {
      if (cartToken != null) 'cart': cartToken!,
      if (shopifyCustomerAccessToken != null)
        'shopifyCustomerAccessToken': shopifyCustomerAccessToken!,
    };
  }
}

Klingt überschaubar. Aber das Setzen dieser Cookies ist alles andere als trivial. Bei jedem Auth-State-Wechsel müssen:

  1. Alte Session-Cookies gelöscht werden

  2. Neue Cookies gesetzt werden

  3. Zusätzliche App-spezifische Cookies konfiguriert werden

Und dann gibt es die "nukleare Option".

Die nukleare Option: WebView zerstören und neu erstellen

Cookies ändern reicht nicht immer. Manchmal muss die gesamte WebView zerstört und neu erstellt werden. Der Trick: ein ValueKey, der sich ändert, wenn sich der Auth-Token ändert.

// WebView wird komplett zerstört und neu erstellt,
// wenn sich der Shopify Access Token ändert
final rebuildKey = ValueKey(
  (tokenBag.shopifyCustomerAccessToken ?? 'guest') +
      rebuildIndex.value.toString(),
);

// Später im Widget-Tree:
InAppWebView(
  key: rebuildKey,  // Neuer Key = neues Widget = neuer WebView
  // ...
);
// WebView wird komplett zerstört und neu erstellt,
// wenn sich der Shopify Access Token ändert
final rebuildKey = ValueKey(
  (tokenBag.shopifyCustomerAccessToken ?? 'guest') +
      rebuildIndex.value.toString(),
);

// Später im Widget-Tree:
InAppWebView(
  key: rebuildKey,  // Neuer Key = neues Widget = neuer WebView
  // ...
);
// WebView wird komplett zerstört und neu erstellt,
// wenn sich der Shopify Access Token ändert
final rebuildKey = ValueKey(
  (tokenBag.shopifyCustomerAccessToken ?? 'guest') +
      rebuildIndex.value.toString(),
);

// Später im Widget-Tree:
InAppWebView(
  key: rebuildKey,  // Neuer Key = neues Widget = neuer WebView
  // ...
);

Flutter erkennt den neuen Key, wirft den alten WebView weg und erstellt einen komplett neuen. Mitsamt neuem Page-Load, neuem JavaScript-Context, neuen Cookie-Sessions. Kein sanfter Übergang —ein harter Reset.

Und die Warenkorb-Seite braucht sogar einen zusätzlichen Rebuild-Trigger:

// Cart-Page: Rebuild bei JEDEM Token-Wechsel,
// nicht nur bei Guest <-> Authenticated
WebviewPageWidget(
  key: Key('cart_page_${authState?.accessToken ?? 'no_token'}'),
  initialUrl: '${shopConfig.baseUrl}/cart',
);
// Cart-Page: Rebuild bei JEDEM Token-Wechsel,
// nicht nur bei Guest <-> Authenticated
WebviewPageWidget(
  key: Key('cart_page_${authState?.accessToken ?? 'no_token'}'),
  initialUrl: '${shopConfig.baseUrl}/cart',
);
// Cart-Page: Rebuild bei JEDEM Token-Wechsel,
// nicht nur bei Guest <-> Authenticated
WebviewPageWidget(
  key: Key('cart_page_${authState?.accessToken ?? 'no_token'}'),
  initialUrl: '${shopConfig.baseUrl}/cart',
);

Warum? Weil der Warenkorb an den konkreten Access Token gebunden ist. Wenn der Nutzer sich ausloggt und als anderer Nutzer wieder einloggt, muss der Warenkorb komplett neu geladen werden —nicht einfach nur "refreshed".

Web triggert nativen Login

Es gibt noch eine weitere Richtung: Die WebView kann einen nativen Login auslösen. Wenn der Nutzer im Web-Checkout auf "Einloggen" klickt, ruft das eine JavaScript-Bridge-Funktion auf, die in der App ein natives Login-Bottom-Sheet öffnet. Der Nutzer loggt sich nativ ein, die App aktualisiert den Auth-State, setzt neue Cookies, und die WebView muss den neuen Zustand reflektieren —idealerweise ohne dass der Nutzer es bemerkt.

Das klingt in der Theorie wie ein sauberer Flow. In der Praxis gibt es Timing-Probleme (Cookies müssen gesetzt sein bevor die WebView refreshed), State-Probleme (welcher Controller erfährt zuerst vom Login?), und UX-Probleme (was passiert, wenn der Nutzer den Login-Sheet dismissed?).

Und der aktuelle Branch in der Git-History heißt buchstäblich fix/SHOP-74-login-in-cart. Ein Branch, der sich nur damit beschäftigt, dass der Login aus dem Warenkorb heraus korrekt funktioniert. Nicht der Login generell —nur der Login aus dem Warenkorb heraus. So spezifisch sind die Edge Cases.

Komplexität #2: Navigation —Ein Labyrinth aus Sonderfällen

Die shouldOverrideUrlLoading-Methode im WebView-Container ist 100 Zeilen plattformspezifische Logik. Und jede einzelne Zeile hat einen Grund.

Die State Machine für den initialen Request

Bevor wir überhaupt über Navigation reden können, brauchen wir eine State Machine:

enum InitialRequestState {
  none,      // WebView frisch erstellt, noch kein Request
  loading,   // Erster Request läuft
  loaded,    // Erster Request abgeschlossen
  cancelled, // Client-side Redirect erkannt
}
enum InitialRequestState {
  none,      // WebView frisch erstellt, noch kein Request
  loading,   // Erster Request läuft
  loaded,    // Erster Request abgeschlossen
  cancelled, // Client-side Redirect erkannt
}
enum InitialRequestState {
  none,      // WebView frisch erstellt, noch kein Request
  loading,   // Erster Request läuft
  loaded,    // Erster Request abgeschlossen
  cancelled, // Client-side Redirect erkannt
}

Warum? Weil shouldOverrideUrlLoading jeden URL-Aufruf abfängt —auch den allerersten, der die initiale Seite lädt. Ohne diese State Machine würde die App ihren eigenen initialen Page-Load blockieren.

iOS vs. Android: Völlig unterschiedliche Events

Hier wird es richtig hässlich. iOS und Android senden völlig unterschiedliche Navigation-Events:

Future<NavigationActionPolicy?> shouldOverrideUrlLoading(
  controller, navigationAction, shopConfig, initialRequestState,
) async {
  final whitelistedDomains = shopConfig.whitelistedDomains;
  final requestUrl = navigationAction.request.url;

  // iOS sendet NavigationType.BACK_FORWARD bei Zurück-Navigation.
  // Android? Sendet gar kein shouldOverrideUrlLoading bei goBack().
  if (navigationAction.navigationType == NavigationType.BACK_FORWARD) {
    return NavigationActionPolicy.ALLOW;
  }

  // Whitelisted Domains durchlassen (z.B. Shop Pay)
  if (requestUrl != null && whitelistedDomains.isNotEmpty) {
    final host = requestUrl.host.toLowerCase();
    if (whitelistedDomains.any(host.contains)) {
      return NavigationActionPolicy.ALLOW;
    }
  }

  // Android markiert Redirects explizit via isRedirect-Flag.
  // iOS? Hat dieses Flag nicht.
  if (Platform.isAndroid && (navigationAction.isRedirect ?? false)) {
    // Deep-Link-Logik: URL an den nativen Router übergeben
    await router.handleUrl(
      url: navigationAction.request.url.toString(),
      forceReplace: true,
    );
    return NavigationActionPolicy.CANCEL;
  }

  // Subframes nicht anfassen
  if (!navigationAction.isForMainFrame) {
    return NavigationActionPolicy.ALLOW;
  }

  // Initialen Request durchlassen
  if (initialRequestState.value == InitialRequestState.none) {
    initialRequestState.value = InitialRequestState.loading;
    return NavigationActionPolicy.ALLOW;
  }

  // Client-side Redirect erkennen
  if (initialRequestState.value != InitialRequestState.loaded) {
    initialRequestState.value = InitialRequestState.cancelled;
  }

  // Alles andere: an den Deep-Link-Router übergeben
  await router.handleUrl(
    url: navigationAction.request.url.toString(),
    forceReplace: initialRequestState.value == InitialRequestState.cancelled,
  );
  return NavigationActionPolicy.CANCEL;
}
Future<NavigationActionPolicy?> shouldOverrideUrlLoading(
  controller, navigationAction, shopConfig, initialRequestState,
) async {
  final whitelistedDomains = shopConfig.whitelistedDomains;
  final requestUrl = navigationAction.request.url;

  // iOS sendet NavigationType.BACK_FORWARD bei Zurück-Navigation.
  // Android? Sendet gar kein shouldOverrideUrlLoading bei goBack().
  if (navigationAction.navigationType == NavigationType.BACK_FORWARD) {
    return NavigationActionPolicy.ALLOW;
  }

  // Whitelisted Domains durchlassen (z.B. Shop Pay)
  if (requestUrl != null && whitelistedDomains.isNotEmpty) {
    final host = requestUrl.host.toLowerCase();
    if (whitelistedDomains.any(host.contains)) {
      return NavigationActionPolicy.ALLOW;
    }
  }

  // Android markiert Redirects explizit via isRedirect-Flag.
  // iOS? Hat dieses Flag nicht.
  if (Platform.isAndroid && (navigationAction.isRedirect ?? false)) {
    // Deep-Link-Logik: URL an den nativen Router übergeben
    await router.handleUrl(
      url: navigationAction.request.url.toString(),
      forceReplace: true,
    );
    return NavigationActionPolicy.CANCEL;
  }

  // Subframes nicht anfassen
  if (!navigationAction.isForMainFrame) {
    return NavigationActionPolicy.ALLOW;
  }

  // Initialen Request durchlassen
  if (initialRequestState.value == InitialRequestState.none) {
    initialRequestState.value = InitialRequestState.loading;
    return NavigationActionPolicy.ALLOW;
  }

  // Client-side Redirect erkennen
  if (initialRequestState.value != InitialRequestState.loaded) {
    initialRequestState.value = InitialRequestState.cancelled;
  }

  // Alles andere: an den Deep-Link-Router übergeben
  await router.handleUrl(
    url: navigationAction.request.url.toString(),
    forceReplace: initialRequestState.value == InitialRequestState.cancelled,
  );
  return NavigationActionPolicy.CANCEL;
}
Future<NavigationActionPolicy?> shouldOverrideUrlLoading(
  controller, navigationAction, shopConfig, initialRequestState,
) async {
  final whitelistedDomains = shopConfig.whitelistedDomains;
  final requestUrl = navigationAction.request.url;

  // iOS sendet NavigationType.BACK_FORWARD bei Zurück-Navigation.
  // Android? Sendet gar kein shouldOverrideUrlLoading bei goBack().
  if (navigationAction.navigationType == NavigationType.BACK_FORWARD) {
    return NavigationActionPolicy.ALLOW;
  }

  // Whitelisted Domains durchlassen (z.B. Shop Pay)
  if (requestUrl != null && whitelistedDomains.isNotEmpty) {
    final host = requestUrl.host.toLowerCase();
    if (whitelistedDomains.any(host.contains)) {
      return NavigationActionPolicy.ALLOW;
    }
  }

  // Android markiert Redirects explizit via isRedirect-Flag.
  // iOS? Hat dieses Flag nicht.
  if (Platform.isAndroid && (navigationAction.isRedirect ?? false)) {
    // Deep-Link-Logik: URL an den nativen Router übergeben
    await router.handleUrl(
      url: navigationAction.request.url.toString(),
      forceReplace: true,
    );
    return NavigationActionPolicy.CANCEL;
  }

  // Subframes nicht anfassen
  if (!navigationAction.isForMainFrame) {
    return NavigationActionPolicy.ALLOW;
  }

  // Initialen Request durchlassen
  if (initialRequestState.value == InitialRequestState.none) {
    initialRequestState.value = InitialRequestState.loading;
    return NavigationActionPolicy.ALLOW;
  }

  // Client-side Redirect erkennen
  if (initialRequestState.value != InitialRequestState.loaded) {
    initialRequestState.value = InitialRequestState.cancelled;
  }

  // Alles andere: an den Deep-Link-Router übergeben
  await router.handleUrl(
    url: navigationAction.request.url.toString(),
    forceReplace: initialRequestState.value == InitialRequestState.cancelled,
  );
  return NavigationActionPolicy.CANCEL;
}

Jede if-Abfrage in diesem Code existiert, weil ein spezifischer Bug auf einer spezifischen Plattform aufgetreten ist. Das ist kein Over-Engineering —das ist Schadensbegrenzung.

Der restartWebView()-Hack

Und dann gibt es Dinge, die man als Entwickler nur mit einem Kommentar im Code entschuldigen kann:

// Im Navigate-Plugin, wenn die Webseite eine interne Navigation auslöst:

// TODO(khalit): this feels like a hack. calling restartWebView is needed
// because it prevents shouldOverrideUrl from being called, which would
// cause unexpected behavior.
// lets figure out a better solution for this in the future.
// needs major refactoring of the webview container
restartWebView();
await controller.loadUrl(
  urlRequest: URLRequest(url: WebUri(fullUrl)),
);
// Im Navigate-Plugin, wenn die Webseite eine interne Navigation auslöst:

// TODO(khalit): this feels like a hack. calling restartWebView is needed
// because it prevents shouldOverrideUrl from being called, which would
// cause unexpected behavior.
// lets figure out a better solution for this in the future.
// needs major refactoring of the webview container
restartWebView();
await controller.loadUrl(
  urlRequest: URLRequest(url: WebUri(fullUrl)),
);
// Im Navigate-Plugin, wenn die Webseite eine interne Navigation auslöst:

// TODO(khalit): this feels like a hack. calling restartWebView is needed
// because it prevents shouldOverrideUrl from being called, which would
// cause unexpected behavior.
// lets figure out a better solution for this in the future.
// needs major refactoring of the webview container
restartWebView();
await controller.loadUrl(
  urlRequest: URLRequest(url: WebUri(fullUrl)),
);

Der Kontext: Wenn die Webseite über die JavaScript-Bridge eine Navigation auslöst, würde shouldOverrideUrlLoading den Request abfangen und an den nativen Router weiterleiten —was die falsche Seite öffnen würde. Also setzen wir die State Machine zurück (restartWebView()), damit der nächste loadUrl-Aufruf wie ein initialer Request behandelt wird.

Und für die Zurück-Navigation gibt es eine künstliche Verzögerung:

// Künstliche Verzögerung, weil sofortiges Reload
// die falsche Seite laden würde
Future.delayed(
  Durations.extralong1, // 700ms
  () async {
    await controller.reload();
  },
);
// Künstliche Verzögerung, weil sofortiges Reload
// die falsche Seite laden würde
Future.delayed(
  Durations.extralong1, // 700ms
  () async {
    await controller.reload();
  },
);
// Künstliche Verzögerung, weil sofortiges Reload
// die falsche Seite laden würde
Future.delayed(
  Durations.extralong1, // 700ms
  () async {
    await controller.reload();
  },
);

700 Millisekunden warten, weil der WebView sonst die alte Seite neu lädt statt der neuen. Das ist die Art von Fix, die in keinem Tutorial steht.

Android Back-Gesture

Und dann ist da noch die Android-Zurück-Geste. WebViews haben ihre eigene Navigation-History, die nichts mit der App-Navigation zu tun hat. Der Fix:

PopScope(
  canPop: !canWebViewGoBack.value,
  onPopInvokedWithResult: (didPop, result) async {
    if (didPop) return;
    final controller = webViewController.value;
    if (controller != null) {
      await controller.goBack();
      await updateCanGoBack(controller);
    }
  },
  child: // ... WebView
);
PopScope(
  canPop: !canWebViewGoBack.value,
  onPopInvokedWithResult: (didPop, result) async {
    if (didPop) return;
    final controller = webViewController.value;
    if (controller != null) {
      await controller.goBack();
      await updateCanGoBack(controller);
    }
  },
  child: // ... WebView
);
PopScope(
  canPop: !canWebViewGoBack.value,
  onPopInvokedWithResult: (didPop, result) async {
    if (didPop) return;
    final controller = webViewController.value;
    if (controller != null) {
      await controller.goBack();
      await updateCanGoBack(controller);
    }
  },
  child: // ... WebView
);

Wenn der WebView eine Zurück-History hat, fängt die App die Geste ab und navigiert innerhalb des WebViews zurück. Wenn nicht, lässt sie die Geste durch und navigiert in der App zurück. Klingt einfach, hat aber drei Iterationen und mehrere Commits gebraucht.

Komplexität #3: Fünf Plugins, zwei Welten, ein window.mobileApp

Die vielleicht eleganteste —und gleichzeitig aufwändigste —Lösung im Projekt ist die JavaScript-Bridge. Sie verbindet die native App mit der Webseite über ein Plugin-System.

Das Plugin-Interface

Jedes Plugin implementiert ein einfaches Interface:

abstract class IWebViewPlugin {
  void attach({
    required InAppWebViewController controller,
    required BuildContext context,
    required WidgetRef ref,
    required void Function() restartWebView,
  });

  List<UserScript>? get userScripts;
}
abstract class IWebViewPlugin {
  void attach({
    required InAppWebViewController controller,
    required BuildContext context,
    required WidgetRef ref,
    required void Function() restartWebView,
  });

  List<UserScript>? get userScripts;
}
abstract class IWebViewPlugin {
  void attach({
    required InAppWebViewController controller,
    required BuildContext context,
    required WidgetRef ref,
    required void Function() restartWebView,
  });

  List<UserScript>? get userScripts;
}

Zwei Verantwortlichkeiten: JavaScript bei Page-Load injizieren (userScripts) und sich an den Controller hängen (attach), um auf Callbacks zu reagieren.

Die Bridge: JavaScript -> Dart

Das Pattern ist immer das gleiche:

// Dart-Seite: Handler registrieren
controller.addJavaScriptHandler(
  handlerName: 'updateCart',
  callback: (args) {
    // Warenkorb-State invalidieren -> Refresh
    ref.invalidate(cartProvider);
  },
);
// Dart-Seite: Handler registrieren
controller.addJavaScriptHandler(
  handlerName: 'updateCart',
  callback: (args) {
    // Warenkorb-State invalidieren -> Refresh
    ref.invalidate(cartProvider);
  },
);
// Dart-Seite: Handler registrieren
controller.addJavaScriptHandler(
  handlerName: 'updateCart',
  callback: (args) {
    // Warenkorb-State invalidieren -> Refresh
    ref.invalidate(cartProvider);
  },
);
// JavaScript-Seite: Bei DOCUMENT_START injiziert
window.mobileApp = window.mobileApp ?? {};

window.mobileApp["updateCart"] = function() {
  window.flutter_inappwebview.callHandler('updateCart', ...arguments);
};
// JavaScript-Seite: Bei DOCUMENT_START injiziert
window.mobileApp = window.mobileApp ?? {};

window.mobileApp["updateCart"] = function() {
  window.flutter_inappwebview.callHandler('updateCart', ...arguments);
};
// JavaScript-Seite: Bei DOCUMENT_START injiziert
window.mobileApp = window.mobileApp ?? {};

window.mobileApp["updateCart"] = function() {
  window.flutter_inappwebview.callHandler('updateCart', ...arguments);
};

Die Webseite ruft window.mobileApp.updateCart() auf, das leitet den Call über flutter_inappwebview.callHandler an den Dart-Handler weiter, und der invalidiert den nativen Warenkorb-State. Elegant, aber jede dieser Verbindungen muss korrekt aufgesetzt, getestet und gewartet werden.

Die fünf Plugins

Plugin

Zeilen

Aufgabe

ConsentPlugin

298

Consent-Daten an WebView übermitteln

TrackingPlugin

268

Web-Analytics nach Firebase Analytics übersetzen

NavigatePlugin

127

Web-seitige Navigation verarbeiten

LoginPlugin

68

Web triggert nativen Login-Flow

UpdateCartPlugin

55

Web benachrichtigt App über Warenkorb-Änderungen

Gesamt

~816


816 Zeilen Plugin-Code. Plus der 476-Zeilen Container. Plus die JavaScript-Injection-Logik. Für "einfach einbetten".

Der Consent-Wahnsinn: Ein Triple-Ansatz

Das ConsentPlugin verdient besondere Erwähnung, weil es zeigt, wie weit man gehen muss, wenn man native Consent-Verwaltung (über UserCentrics) mit einer Webseite synchronisieren will.

Es nutzt drei gleichzeitige Ansätze, um sicherzustellen, dass der Consent-Status der Webseite bekannt ist:

Ansatz 1: URL-Parameter

// Consent-Daten direkt in die URL injecten
String modifyUrlWithConsent(String originalUrl, ConsentController controller) {
  final consentData = getConsentData(controller);
  final consentJson = jsonEncode(consentData);
  final uri = Uri.parse(originalUrl);
  final queryParams = Map<String, String>.from(uri.queryParameters);
  queryParams['consent'] = consentJson;
  return uri.replace(queryParameters: queryParams).toString();
}
// Consent-Daten direkt in die URL injecten
String modifyUrlWithConsent(String originalUrl, ConsentController controller) {
  final consentData = getConsentData(controller);
  final consentJson = jsonEncode(consentData);
  final uri = Uri.parse(originalUrl);
  final queryParams = Map<String, String>.from(uri.queryParameters);
  queryParams['consent'] = consentJson;
  return uri.replace(queryParameters: queryParams).toString();
}
// Consent-Daten direkt in die URL injecten
String modifyUrlWithConsent(String originalUrl, ConsentController controller) {
  final consentData = getConsentData(controller);
  final consentJson = jsonEncode(consentData);
  final uri = Uri.parse(originalUrl);
  final queryParams = Map<String, String>.from(uri.queryParameters);
  queryParams['consent'] = consentJson;
  return uri.replace(queryParameters: queryParams).toString();
}

Ansatz 2: JavaScript-Injection bei DOCUMENT_START

// URL-Parameter beim Page-Load parsen und global setzen
(function() {
  window.mobileApp = window.mobileApp || {};

  // Consent aus URL-Parameter lesen (sofort verfügbar)
  function getInitialConsentFromUrl() {
    const urlParams = new URLSearchParams(window.location.search);
    const consentParam = urlParams.get('consent');
    if (consentParam) {
      return JSON.parse(decodeURIComponent(consentParam));
    }
    return null;
  }

  const urlConsent = getInitialConsentFromUrl();
  if (urlConsent) {
    window.consent = urlConsent;
    window.visitorConsent = urlConsent;
  }

  // Handler für spätere Updates von der App
  window.__onAppConsentReceived = function(consentData) {
    window.consent = consentData;
    window.visitorConsent = consentData;
  };
})();
// URL-Parameter beim Page-Load parsen und global setzen
(function() {
  window.mobileApp = window.mobileApp || {};

  // Consent aus URL-Parameter lesen (sofort verfügbar)
  function getInitialConsentFromUrl() {
    const urlParams = new URLSearchParams(window.location.search);
    const consentParam = urlParams.get('consent');
    if (consentParam) {
      return JSON.parse(decodeURIComponent(consentParam));
    }
    return null;
  }

  const urlConsent = getInitialConsentFromUrl();
  if (urlConsent) {
    window.consent = urlConsent;
    window.visitorConsent = urlConsent;
  }

  // Handler für spätere Updates von der App
  window.__onAppConsentReceived = function(consentData) {
    window.consent = consentData;
    window.visitorConsent = consentData;
  };
})();
// URL-Parameter beim Page-Load parsen und global setzen
(function() {
  window.mobileApp = window.mobileApp || {};

  // Consent aus URL-Parameter lesen (sofort verfügbar)
  function getInitialConsentFromUrl() {
    const urlParams = new URLSearchParams(window.location.search);
    const consentParam = urlParams.get('consent');
    if (consentParam) {
      return JSON.parse(decodeURIComponent(consentParam));
    }
    return null;
  }

  const urlConsent = getInitialConsentFromUrl();
  if (urlConsent) {
    window.consent = urlConsent;
    window.visitorConsent = urlConsent;
  }

  // Handler für spätere Updates von der App
  window.__onAppConsentReceived = function(consentData) {
    window.consent = consentData;
    window.visitorConsent = consentData;
  };
})();

Ansatz 3: evaluateJavascript nach dem Laden

// Consent-Daten nach dem Laden nochmal per JS-Execution senden
void sendConsentToWebView(controller, consentData) {
  final consentJson = jsonEncode(consentData);
  controller.evaluateJavascript(source: '''
    if (window.__onAppConsentReceived) {
      window.__onAppConsentReceived($consentJson);
    } else {
      window.__pendingConsent = $consentJson;
    }
  ''');
}
// Consent-Daten nach dem Laden nochmal per JS-Execution senden
void sendConsentToWebView(controller, consentData) {
  final consentJson = jsonEncode(consentData);
  controller.evaluateJavascript(source: '''
    if (window.__onAppConsentReceived) {
      window.__onAppConsentReceived($consentJson);
    } else {
      window.__pendingConsent = $consentJson;
    }
  ''');
}
// Consent-Daten nach dem Laden nochmal per JS-Execution senden
void sendConsentToWebView(controller, consentData) {
  final consentJson = jsonEncode(consentData);
  controller.evaluateJavascript(source: '''
    if (window.__onAppConsentReceived) {
      window.__onAppConsentReceived($consentJson);
    } else {
      window.__pendingConsent = $consentJson;
    }
  ''');
}

Drei Wege, um eine Information zu übermitteln. Warum? Weil keiner der drei alleine zuverlässig genug ist:

  • URL-Parameter können bei Client-side Redirects verloren gehen

  • JavaScript-Injection passiert zu früh für manche Shopify-Scripts

  • evaluateJavascript passiert zu spät für den initialen Render

Also alle drei zusammen. Belt and suspenders and duct tape.

Komplexität #4: Die Details, die den Schlaf rauben

Neben den großen architekturellen Herausforderungen gibt es eine Reihe von Details, die einzeln klein wirken, aber in Summe erheblichen Aufwand bedeuten.

1. User-Agent Spoofing

Shopify gibt 403-Fehler zurück, wenn der Standard-WebView-User-Agent erkannt wird. Die Lösung: hardcodierte Browser-User-Agents pro Plattform.

userAgent: Platform.isIOS
    ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) '
      'AppleWebKit/605.1.15 (KHTML, like Gecko) '
      'Version/16.0 Mobile/15E148 Safari/604.1'
    : 'Mozilla/5.0 (Linux; Android 13; Pixel 7) '
      'AppleWebKit/537.36 (KHTML, like Gecko) '
      'Chrome/120.0.0.0 Mobile Safari/537.36',
userAgent: Platform.isIOS
    ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) '
      'AppleWebKit/605.1.15 (KHTML, like Gecko) '
      'Version/16.0 Mobile/15E148 Safari/604.1'
    : 'Mozilla/5.0 (Linux; Android 13; Pixel 7) '
      'AppleWebKit/537.36 (KHTML, like Gecko) '
      'Chrome/120.0.0.0 Mobile Safari/537.36',
userAgent: Platform.isIOS
    ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) '
      'AppleWebKit/605.1.15 (KHTML, like Gecko) '
      'Version/16.0 Mobile/15E148 Safari/604.1'
    : 'Mozilla/5.0 (Linux; Android 13; Pixel 7) '
      'AppleWebKit/537.36 (KHTML, like Gecko) '
      'Chrome/120.0.0.0 Mobile Safari/537.36',

Ja, das sind hardcodierte Strings. Ja, sie können veralten. Ja, sie müssen manuell aktualisiert werden. Aber ohne sie kommt man nicht an Shopify vorbei.

2. Geforktes WebView-SDK

Die App nutzt einen Custom Fork der flutter_inappwebview-Library. Warum? Weil die Standard-Version keinen Android Payment Request Support hat —nötig für Google Pay im Checkout.

# pubspec.yaml
flutter_inappwebview:
  git:
    url: https://github.com/AzarouAmine/flutter_inappwebview.git
    ref: feat/add_android_payment_request_support
    path

# pubspec.yaml
flutter_inappwebview:
  git:
    url: https://github.com/AzarouAmine/flutter_inappwebview.git
    ref: feat/add_android_payment_request_support
    path

# pubspec.yaml
flutter_inappwebview:
  git:
    url: https://github.com/AzarouAmine/flutter_inappwebview.git
    ref: feat/add_android_payment_request_support
    path

Das bedeutet: eigenen Fork pflegen, Upstream-Änderungen tracken, bei jedem Flutter-Update prüfen, ob der Fork noch kompatibel ist. Für ein Feature, das in der Standard-Library fehlt.

3. Loading-State: Unsichtbar rendern, dann anzeigen

Im Fixed-Height-Modus (wenn ein WebView in einen nativen Tab eingebettet wird) gibt es ein besonderes Problem: Die Höhe des WebView-Inhalts ist unbekannt, bis er gerendert ist.

Die Lösung: Den WebView unsichtbar rendern, die Höhe per JavaScript messen, dann mit der gemessenen Höhe anzeigen.

// Schritt 1: Unsichtbar rendern
Opacity(opacity: 0, child: webView),

// Schritt 2: Höhe messen (nach onLoadStop)
final height = await controller.evaluateJavascript(
  source: 'Math.max(document.body.scrollHeight, '
          'document.documentElement.scrollHeight)',
);
contentHeight.value = height.toDouble();

// Schritt 3: Mit gemessener Höhe anzeigen
SizedBox(height: contentHeight.value, child: webView);
// Schritt 1: Unsichtbar rendern
Opacity(opacity: 0, child: webView),

// Schritt 2: Höhe messen (nach onLoadStop)
final height = await controller.evaluateJavascript(
  source: 'Math.max(document.body.scrollHeight, '
          'document.documentElement.scrollHeight)',
);
contentHeight.value = height.toDouble();

// Schritt 3: Mit gemessener Höhe anzeigen
SizedBox(height: contentHeight.value, child: webView);
// Schritt 1: Unsichtbar rendern
Opacity(opacity: 0, child: webView),

// Schritt 2: Höhe messen (nach onLoadStop)
final height = await controller.evaluateJavascript(
  source: 'Math.max(document.body.scrollHeight, '
          'document.documentElement.scrollHeight)',
);
contentHeight.value = height.toDouble();

// Schritt 3: Mit gemessener Höhe anzeigen
SizedBox(height: contentHeight.value, child: webView);

Und in der Git-History gibt es einen Commit "fix: only show loading widget on initial webview page load" —gefolgt von einem Revert genau dieses Commits. Loading-State in WebViews ist hartnäckig.

4. SSL-Zertifikate: Alles durchlassen

onReceivedServerTrustAuthRequest: (controller, challenge) =>
    ServerTrustAuthResponse(
      action: ServerTrustAuthResponseAction.PROCEED,
    ),
onReceivedServerTrustAuthRequest: (controller, challenge) =>
    ServerTrustAuthResponse(
      action: ServerTrustAuthResponseAction.PROCEED,
    ),
onReceivedServerTrustAuthRequest: (controller, challenge) =>
    ServerTrustAuthResponse(
      action: ServerTrustAuthResponseAction.PROCEED,
    ),

Jedes SSL-Zertifikat wird akzeptiert. In Produktions-Apps ein Risiko, aber ohne diesen Handler würden einige Drittanbieter-Redirects im Checkout fehlschlagen.

5. Der aufgegebene native Ansatz

Tief in der Codebase findet sich auskommentierter Code:

//   ref
//       .read(shopifyCartControllerProvider.notifier)
//       .addProductToCart(
//         merchandiseId: selectedVariant.id,
//         product: product,
//         variant: selectedVariant.title,
//       );
//   ref
//       .read(shopifyCartControllerProvider.notifier)
//       .addProductToCart(
//         merchandiseId: selectedVariant.id,
//         product: product,
//         variant: selectedVariant.title,
//       );
//   ref
//       .read(shopifyCartControllerProvider.notifier)
//       .addProductToCart(
//         merchandiseId: selectedVariant.id,
//         product: product,
//         variant: selectedVariant.title,
//       );

Ein Versuch, das Hinzufügen zum Warenkorb nativ zu implementieren. Versucht und aufgegeben —weil die Synchronisation zwischen nativem Cart-State und Web-Cart-State zu fragil war.

6. Die Git-History als Beweis

Ein Blick auf die WebView-bezogenen Commits der letzten Wochen:




Fast jeder Commit ist ein Fix für ein WebView-spezifisches Problem. Kein Feature-Development —Schadensbegrenzung.

Aber WebViews sind nicht nur schlecht

Nach all der Komplexität wäre es unfair, WebViews pauschal zu verteufeln. Es gibt genügend Stellen, an denen sie die richtige Entscheidung sind.

1. Produktseiten & Kollektionen

CMS-verwalteter Content, der sich wöchentlich ändert? Definitiv WebView. Das Marketing-Team pflegt Inhalte über Shopify, und die App zeigt sie sofort an —ohne App-Update, ohne Deployment, ohne Abstimmung mit dem Entwicklerteam. In einem Schmuck-Shop, wo saisonale Kollektionen, limitierte Editionen und Kampagnen-Landingpages zum Alltag gehören, wäre eine native Implementierung ein Flaschenhals. Jede Änderung müsste über den Entwickler laufen, jeder neue Banner ein App-Update erfordern.

2. Checkout

PCI-Compliance, Payment-Provider-Integration, Shop Pay, Apple Pay, Google Pay, Klarna, PayPal —das alles nativ zu implementieren wäre ein eigenes Projekt. Der gehostete Shopify-Checkout erledigt das alles, und man muss "nur" dafür sorgen, dass er im WebView korrekt funktioniert. (Wobei "nur" hier relativ ist —siehe den geforkten SDK-Punkt oben.) Hier kommt auch der wirtschaftliche Faktor ins Spiel: Eine PCI-DSS-Zertifizierung kostet fünfstellig, erfordert jährliche Audits und schränkt ein, wie man mit Zahlungsdaten umgehen darf. Der gehostete Checkout lagert das alles an Shopify aus.

3. Suche

Facettierte Filter, Autocomplete, Suchvorschläge —alles Shopify-Stärken, die nativ nachzubauen kaum Mehrwert bringen würde. Shopify-Themes bieten ausgefeilte Such-UIs mit Produktvorschlägen, Kategorie-Filtern und Sortieroptionen. Das nativ nachzubauen würde bedeuten, die gesamte Storefront API für Suchanfragen, Facetten und Pagination selbst anzusprechen —Aufwand, der sich selten rechnet.

4. Account-Daten als eingebettete WebViews

Bestellübersichten und andere Account-Tabs nutzen einen useFixedHeight-Modus, der WebView-Inhalte nahtlos in native Tabs einbettet. Der Nutzer merkt nicht, dass er gerade eine Webseite sieht. Ein eleganter Hybrid-Ansatz —wenn man die Höhen-Messung (siehe oben) zum Laufen bringt.

5. Rechtliche Seiten

Impressum, AGB, Datenschutzerklärung —werden im externen Browser geöffnet. Kein WebView, kein Custom-Chrome. Das richtige Pattern für Content, der keinen App-Kontext braucht.

6. Catch-All Fallback

Für unbekannte URLs gibt es eine StandaloneWebViewPage, die als Fallback dient. Wenn der Deep-Link-Router eine URL nicht zuordnen kann, wird sie als WebView geöffnet. Graceful Degradation statt Crash.

Eine praktische Entscheidungshilfe

Aus der Erfahrung dieses Projekts ergibt sich eine Matrix, die bei der Entscheidung "nativ oder WebView?" helfen kann:

Kriterium

Eher nativ

Eher WebView

Auth/Login

Ja

Nein

Checkout/Payment

Nur wenn PCI-compliant

Ja (gehostete Lösung)

CMS-Content

Wenn selten aktualisiert

Wenn häufig aktualisiert

Navigation-kritisch

Ja

Vorsicht

Performance-kritisch

Ja

Nein

Offline-Fähigkeit

Ja

Nein

Das 3-Stufen-Framework

In der Praxis sehe ich drei Stufen, wie Unternehmen WebViews einsetzen:

Stufe 1: Reiner WebView-Wrapper
Die gesamte Website in einen WebView verpacken. Funktioniert für MVPs und Marktvalidierung —um schnell zu testen, ob eine App überhaupt Nutzernachfrage hat. Aber: keine Push-Notifications, keine native Navigation, kein Offline-Support. Die Performance fühlt sich an wie eine langsame Webseite, nicht wie eine App. Und sowohl Apple als auch Google lehnen reine WebView-Wrapper in ihren Stores immer häufiger ab. Als kurzfristiges Experiment vertretbar, als langfristige Lösung problematisch.

Stufe 2: Hybrid (wie dieses Projekt)
Nativ wo nötig (Auth, Home, kritische UX-Flows), WebView wo pragmatisch (Shop, Checkout, Content). Der Sweet Spot für die meisten E-Commerce-Apps, die bereits eine funktionierende Web-Infrastruktur haben. Die App fühlt sich an den wichtigen Stellen nativ an, nutzt aber die Web-Stärken dort, wo sie Sinn machen. Der Preis: die Komplexität, die dieser Artikel beschreibt. Die Grenze zwischen nativ und Web muss sorgfältig gemanagt werden —Auth-Synchronisation, Cookie-Management, Navigation-Logik.

Stufe 3: Schrittweise Migration
Als Hybrid starten und kritische Flows nach und nach nativ umsetzen. Produktseiten werden zu nativen Screens mit besserer Performance und Offline-Cache. Die Suche bekommt eine native UI mit sofortiger Reaktionszeit. Der Warenkorb wird nativ verwaltet mit optimistischen Updates. Jede Migration reduziert WebView-Komplexität und verbessert die User Experience, erfordert aber eigene Backend-Integration und erhöhten Wartungsaufwand. Der Vorteil: Man kann priorisieren und zuerst die Screens migrieren, die den größten Impact auf Conversion und Nutzerbindung haben.

Ein Rat an Kunden

Wenn ein Entwickler sagt "das ist komplexer als es aussieht", meint er Dinge wie eine 800-Zeilen JavaScript-Bridge für etwas, das auf den ersten Blick trivial erscheint. Oder einen Branch namens fix/SHOP-74-login-in-cart, der sich drei Tage nur damit beschäftigt, dass der Login aus dem Warenkorb heraus korrekt funktioniert.

Die Frage ist nicht ob WebViews komplex sind, sondern ob die Komplexität sich für Ihren Use Case lohnt.

Für eine E-Commerce-App mit bestehendem Shopify-Shop ist der Hybrid-Ansatz oft die wirtschaftlich sinnvollste Lösung. Aber er ist kein Shortcut. Er ist ein bewusstes Trade-off zwischen Entwicklungskosten und technischer Schuld. Und dieses Trade-off sollte man mit offenen Augen eingehen —nicht mit der Illusion, dass "einbetten" gleichbedeutend mit "fertig" ist.

Fazit

"Können wir nicht einfach die Website einbetten?"

Ja, aber.

WebViews sind ein Werkzeug, kein Shortcut. Sie können die richtige Wahl sein —für CMS-Content, gehostete Checkouts, und überall dort, wo die Web-Infrastruktur ausgereifter ist als das, was man in der gegebenen Zeit nativ bauen könnte.

Aber sie bringen ihre eigene Komplexität mit:

  • Authentifizierung muss zwischen zwei Welten synchronisiert werden

  • Navigation braucht plattformspezifische Workarounds für iOS und Android

  • JavaScript-Bridges müssen aufgebaut, getestet und gewartet werden

  • Edge Cases (User-Agent, SSL, Loading-States) fressen Zeit

  • Geforktes SDK und künstliche Verzögerungen sind keine Ausnahme, sondern Alltag

In Zahlen: Dieses Projekt hat 476 Zeilen für den WebView-Container, 816 Zeilen Plugin-Code, und dutzende Bug-Fix-Commits —für etwas, das mit "einfach einbetten" begann.

Die richtige Frage ist nicht "Website einbetten oder nativ bauen?". Die richtige Frage ist: "Welche Screens nativ, welche WebView, und wie kommunizieren sie?"

Wer diese Frage früh im Projekt beantwortet —und sich der versteckten Komplexität bewusst ist —trifft bessere Architektur-Entscheidungen und vermeidet böse Überraschungen im Budget.

Häufig gestellte Fragen (FAQ)

Was ist ein WebView in einer mobilen App?

Ein WebView ist eine eingebettete Browserkomponente innerhalb einer nativen App. Sie rendert Webinhalte (HTML, CSS, JavaScript) direkt in der App —ohne separate Browseranwendung. Auf iOS nutzt sie WKWebView, auf Android das Chromium-basierte Android WebView. Der Nutzer sieht keine Adressleiste und merkt idealerweise nicht, dass er eine Webseite betrachtet.

Ist eine WebView-App günstiger als eine native App?

Kurzfristig oft ja —man spart sich die native Implementierung von Screens, die bereits als Website existieren. Langfristig kann die versteckte Komplexität (Auth-Synchronisation, Navigation-Workarounds, JavaScript-Bridge) den Kostenvorteil auffressen. In dem hier beschriebenen Projekt stecken über 1.290 Zeilen Code allein in der WebView-Infrastruktur. Die ehrliche Antwort: Es kommt auf den Scope an.

Akzeptiert Apple WebView-Apps im App Store?

Apple lehnt Apps ab, die ausschließlich aus einem WebView bestehen (Richtlinie 4.2 —Minimum Functionality). Hybrid-Apps, die native Features mit WebViews kombinieren, werden akzeptiert —solange sie einen Mehrwert gegenüber der mobilen Website bieten (z.B. Push-Notifications, native Navigation, Offline-Funktionalität).

Welche Probleme gibt es mit WebViews in Flutter?

Die häufigsten Probleme sind: unterschiedliches Navigationsverhalten zwischen iOS und Android, Cookie-Synchronisation bei nativer Authentifizierung, User-Agent-Blocking durch Server wie Shopify, Loading-State-Management, und die Notwendigkeit einer JavaScript-Bridge für die Kommunikation zwischen Web und App. Dieses Projekt löst diese Probleme mit fünf spezialisierten Plugins und einer 476-Zeilen Container-Klasse.

Wann sollte ich nativ bauen statt WebView?

Faustregel: Nativ bauen für alles, was (a) Auth/Login betrifft, (b) Offline verfügbar sein muss, (c) Performance-kritisch ist, oder (d) komplexe native Gesten und Animationen braucht. WebView ist sinnvoll für CMS-verwalteten Content, gehostete Checkouts, und Screens, die sich häufig ändern und keine native Performance benötigen.

Du planst eine mobile App und fragst dich, ob ein WebView-Ansatz für dein Projekt passt? Ich berate Unternehmen genau bei dieser Entscheidung —von der Architektur-Analyse bis zur Umsetzung. Termin buchen und lass uns darüber sprechen.