WebView-Kommunikations-Patterns: Aufbau einer typsicheren Bridge zwischen Native und Web

Tiefgreifende Analyse von WebView-Native-Kommunikations-Patterns, Nachrichten-Übertragungssystemen und Service-Integration. Echter Production-Code, Performance-Benchmarks und Debugging-Geschichten aus 10M+ verarbeiteten Nachrichten.

Drei Monate nach dem Launch unserer mobilen Micro Frontend-Architektur stießen wir an eine Wand. Unsere WebView-Native-Kommunikation wurde zu einem Debugging-Albtraum. Nachrichten gingen verloren, Typen stimmten zwischen Plattformen nicht überein, und wir hatten keine Möglichkeit zu verfolgen, was in WebViews in der Produktion passierte.

Der Wendepunkt kam während eines kritischen Produktlaunches, als unser Zahlungsablauf - geteilt zwischen nativer Authentifizierung und WebView-Checkout - für 15% der Android-Nutzer zu versagen begann. Die Grundursache? Eine Race Condition in unserem Nachrichten-Übertragungssystem, die nur auf Geräten mit langsameren JavaScript-Engines auftrat.

Hier ist, wie wir unsere Kommunikationsschicht von Grund auf neu aufgebaut haben und was wir beim Verarbeiten von über 10 Millionen WebView-Nachrichten in der Produktion gelernt haben.

Mobile Micro Frontend Serie#

Dies ist Teil 2 unserer mobilen Micro Frontend-Serie:

Teil 1 nicht gelesen? Fang dort an für Architektur-Grundlagen.

Bereit für die Produktion? Spring zu Teil 3 für Optimierungsstrategien.

Alternative Kommunikations-Ansätze#

Bevor ich in unsere WebView-Kommunikationslösung eintauche, lass mich die alternativen Ansätze teilen, die wir evaluiert haben und warum wir unser aktuelles System gewählt haben.

Option 1: Re.Pack Module Federation Kommunikation#

Re.Pack bietet ein anderes Kommunikationsmodell durch Module Federation. Anstelle von Nachrichten-Passing ermöglicht es direktes Modul-Sharing zwischen nativen und Web-Kontexten.

Was wir experimentierten:

TypeScript
// Re.Pack Module Federation Setup
// webpack.config.js (host app)
const { ModuleFederationPlugin } = require('@module-federation/nextjs-mf');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        payment: 'payment@http://localhost:3001/remoteEntry.js',
        booking: 'booking@http://localhost:3002/remoteEntry.js',
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true },
        // Geteilte Kommunikationsschicht
        '@shared/bridge': { singleton: true }
      }
    })
  ]
};

// Geteiltes Bridge-Modul
// @shared/bridge/index.ts
export interface NativeBridge {
  getAuthToken(): Promise<string>;
  openCamera(): Promise<string>;
  navigate(screen: string, params?: any): void;
}

// Im Micro Frontend
import { NativeBridge } from '@shared/bridge';

const PaymentComponent = () => {
  const handlePayment = async () => {
    // Direkter Funktionsaufruf statt Nachrichten-Passing
    const token = await NativeBridge.getAuthToken();
    const photo = await NativeBridge.openCamera();

    // Zahlung mit nativen Daten verarbeiten
    await processPayment(token, photo);
  };

  return <button onClick={handlePayment}>Bezahlen</button>;
};

Warum wir es nicht gewählt haben:

  • Komplexität: Erforderte, dass alle Teams gleichzeitig Module Federation adoptierten
  • Type Safety: Geteilte Module mussten in einem separaten Package sein
  • Versionierung: Modul-Versionskonflikte waren schwer zu debuggen
  • Performance: Initiale Bundle-Größe stieg erheblich
  • Debugging: Stack Traces erstreckten sich über mehrere Kontexte

Wann Re.Pack Module Federation verwenden:

  • Du baust eine echte Super App mit mehreren Teams
  • Alle Teams können sich auf geteilte Module koordinieren
  • Du brauchst direkte Funktionsaufrufe zwischen Kontexten
  • Performance-Overhead ist akzeptabel

Option 2: Rspack Module Federation#

Wir experimentierten auch mit Rspack's Module Federation für die Kommunikation:

TypeScript
// rspack.config.mjs
export default {
  entry: './src/index.tsx',
  plugins: [
    new ModuleFederationPlugin({
      name: 'micro-frontend',
      filename: 'remoteEntry.js',
      exposes: {
        './App': './src/App.tsx',
        './Bridge': './src/bridge.ts'
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true }
      }
    })
  ]
};

// Bridge-Implementierung
// src/bridge.ts
export class RspackBridge {
  private static instance: RspackBridge;

  static getInstance(): RspackBridge {
    if (!RspackBridge.instance) {
      RspackBridge.instance = new RspackBridge();
    }
    return RspackBridge.instance;
  }

  async request<T>(action: string, payload?: any): Promise<T> {
    // Verwende Rspack's eingebaute Kommunikation
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        reject(new Error(`Request ${action} timed out`));
      }, 10000);

      // Rspack-spezifische Kommunikation
      window.__RSPACK_MODULE_FEDERATION__.request(action, payload)
        .then((result: T) => {
          clearTimeout(timeout);
          resolve(result);
        })
        .catch((error: any) => {
          clearTimeout(timeout);
          reject(error);
        });
    });
  }
}

Warum wir es nicht gewählt haben:

  • React Native-Kompatibilität: Rspack hat keine native React Native-Unterstützung
  • Ökosystem-Reife: Weniger Debugging-Tools und Beispiele
  • Team-Adoption: Hätte erhebliche Umschulung erfordert
  • Production-Stabilität: Zu neu für unseren Zeitplan

Wann Rspack Module Federation verwenden:

  • Du baust reine Web-Micro-Frontends
  • Build-Performance ist kritisch
  • Du kannst es dir leisten, ein Early Adopter zu sein
  • Deine Teams sind mit Rust-basierten Tools vertraut

Option 3: Web Workers + SharedArrayBuffer#

Für Hochleistungs-Kommunikation evaluierten wir Web Workers mit SharedArrayBuffer:

TypeScript
// Hochleistungs-Kommunikation mit SharedArrayBuffer
class SharedArrayBridge {
  private sharedBuffer: SharedArrayBuffer;
  private int32Array: Int32Array;
  private messageQueue: ArrayBuffer;

  constructor() {
    this.sharedBuffer = new SharedArrayBuffer(1024);
    this.int32Array = new Int32Array(this.sharedBuffer);
    this.messageQueue = new ArrayBuffer(8192);
  }

  async request<T>(action: string, payload: any): Promise<T> {
    const messageId = this.generateId();

    // In Shared Buffer schreiben
    const encoder = new TextEncoder();
    const message = JSON.stringify({ id: messageId, action, payload });
    const bytes = encoder.encode(message);

    // In Shared Buffer kopieren
    const uint8Array = new Uint8Array(this.sharedBuffer);
    uint8Array.set(bytes, 0);

    // Native Seite signalisieren
    Atomics.notify(this.int32Array, 0);

    // Auf Antwort warten
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        reject(new Error('Request timed out'));
      }, 10000);

      // Auf Antwort pollen
      const checkResponse = () => {
        const responseBytes = new Uint8Array(this.sharedBuffer, 512, 512);
        const responseText = new TextDecoder().decode(responseBytes);

        try {
          const response = JSON.parse(responseText);
          if (response.id === messageId) {
            clearTimeout(timeout);
            resolve(response.data);
          } else {
            setTimeout(checkResponse, 10);
          }
        } catch (error) {
          setTimeout(checkResponse, 10);
        }
      };

      checkResponse();
    });
  }
}

Warum wir es nicht gewählt haben:

  • Browser-Unterstützung: SharedArrayBuffer benötigt spezifische Headers
  • Komplexität: Viel komplexer zu implementieren und zu debuggen
  • Sicherheit: Erfordert sorgfältiges Speichermanagement
  • Plattformunterschiede: iOS WebView hat unterschiedliches SharedArrayBuffer-Verhalten

Wann SharedArrayBuffer verwenden:

  • Du brauchst extrem hochleistungsfähige Kommunikation
  • Du zielst nur auf moderne Browser ab
  • Du kannst die Komplexität handhaben
  • Performance ist wichtiger als Einfachheit

Option 4: WebSocket Bridge#

Für Echtzeit-Kommunikation überlegten wir WebSockets:

TypeScript
// WebSocket-basierte Bridge
class WebSocketBridge {
  private ws: WebSocket;
  private pendingRequests = new Map<string, {
    resolve: (value: any) => void;
    reject: (error: any) => void;
  }>();

  constructor(url: string) {
    this.ws = new WebSocket(url);
    this.ws.onmessage = this.handleMessage.bind(this);
  }

  async request<T>(action: string, payload: any): Promise<T> {
    const id = this.generateId();

    return new Promise((resolve, reject) => {
      this.pendingRequests.set(id, { resolve, reject });

      this.ws.send(JSON.stringify({
        id,
        action,
        payload,
        timestamp: Date.now()
      }));
    });
  }

  private handleMessage(event: MessageEvent) {
    const message = JSON.parse(event.data);
    const pending = this.pendingRequests.get(message.id);

    if (pending) {
      this.pendingRequests.delete(message.id);

      if (message.success) {
        pending.resolve(message.data);
      } else {
        pending.reject(new Error(message.error));
      }
    }
  }
}

Warum wir es nicht gewählt haben:

  • Netzwerk-Abhängigkeit: Erfordert Netzwerkverbindung
  • Latenz: Zusätzlicher Netzwerk-Hop
  • Komplexität: Muss WebSocket-Lebenszyklus verwalten
  • Sicherheit: Zusätzliche Angriffsfläche

Wann WebSocket Bridge verwenden:

  • Du brauchst Echtzeit-Kommunikation
  • Netzwerk-Latenz ist akzeptabel
  • Du baust ein verteiltes System
  • Du brauchst bidirektionales Streaming

Die Kommunikations-Herausforderung#

WebView-Kommunikation scheint zuerst einfach. Du hast postMessage auf der Web-Seite und onMessage auf der nativen Seite. Was könnte schiefgehen?

Alles, wie sich herausstellte:

  • Nachrichten sind Strings, also verlierst du Type Safety
  • Kein eingebautes Request/Response-Pattern
  • Keine Zustellungsgarantien oder Bestätigungen
  • Unterschiedliches Verhalten zwischen iOS und Android
  • Keine Möglichkeit, Timeouts oder Wiederholungen zu handhaben
  • Performance-Verschlechterung bei großen Payloads

Aufbau eines robusten Nachrichten-Protokolls#

Nach mehreren Iterationen entwickelten wir ein Protokoll, das diese Probleme löste:

TypeScript
// Geteilte Typen zwischen Native und Web
interface BridgeMessage<T = unknown> {
  id: string;
  type: MessageType;
  action: string;
  payload: T;
  timestamp: number;
  version: string;
}

enum MessageType {
  REQUEST = 'REQUEST',
  RESPONSE = 'RESPONSE',
  EVENT = 'EVENT',
  ERROR = 'ERROR'
}

interface BridgeResponse<T = unknown> {
  id: string;
  success: boolean;
  data?: T;
  error?: {
    code: string;
    message: string;
    details?: unknown;
  };
}

Native-Seiten-Implementierung#

Hier ist unsere Production-Bridge-Implementierung auf der React Native-Seite:

TypeScript
import { WebView } from 'react-native-webview';
import { EventEmitter } from 'events';

class NativeWebViewBridge extends EventEmitter {
  private webViewRef: React.RefObject<WebView>;
  private pendingRequests = new Map<string, {
    resolve: (value: any) => void;
    reject: (error: any) => void;
    timeout: NodeJS.Timeout;
  }>();
  private messageQueue: BridgeMessage[] = [];
  private isReady = false;

  constructor(webViewRef: React.RefObject<WebView>) {
    super();
    this.webViewRef = webViewRef;
  }

  // Sende Request und warte auf Antwort
  async request<TRequest, TResponse>(
    action: string,
    payload: TRequest,
    timeoutMs = 10000
  ): Promise<TResponse> {
    const id = this.generateId();
    const message: BridgeMessage<TRequest> = {
      id,
      type: MessageType.REQUEST,
      action,
      payload,
      timestamp: Date.now(),
      version: '1.0'
    };

    return new Promise((resolve, reject) => {
      // Timeout setzen
      const timeout = setTimeout(() => {
        this.pendingRequests.delete(id);
        reject(new Error(`Request ${action} timed out after ${timeoutMs}ms`));
      }, timeoutMs);

      // Pending Request speichern
      this.pendingRequests.set(id, { resolve, reject, timeout });

      // Nachricht senden
      this.sendMessage(message);
    });
  }

  // Sende einseitiges Event
  emit(action: string, payload?: unknown): void {
    const message: BridgeMessage = {
      id: this.generateId(),
      type: MessageType.EVENT,
      action,
      payload,
      timestamp: Date.now(),
      version: '1.0'
    };

    this.sendMessage(message);
  }

  private sendMessage(message: BridgeMessage): void {
    if (!this.isReady) {
      // Nachrichten in Warteschlange bis WebView bereit ist
      this.messageQueue.push(message);
      return;
    }

    const serialized = JSON.stringify(message);

    // Kritisch: verwende postMessage, nicht injectJavaScript für Zuverlässigkeit
    this.webViewRef.current?.postMessage(serialized);

    // Log für Debugging
    if (__DEV__) {
      console.log(`[Bridge] Gesendet: ${message.action}`, message);
    }
  }

  handleMessage(event: WebViewMessageEvent): void {
    try {
      const message: BridgeMessage = JSON.parse(event.nativeEvent.data);

      switch (message.type) {
        case MessageType.RESPONSE:
          this.handleResponse(message as BridgeResponse);
          break;

        case MessageType.REQUEST:
          this.handleRequest(message);
          break;

        case MessageType.EVENT:
          this.handleEvent(message);
          break;

        case MessageType.ERROR:
          this.handleError(message);
          break;
      }
    } catch (error) {
      console.error('[Bridge] Nachricht parsen fehlgeschlagen:', error);
    }
  }

  private handleResponse(response: BridgeResponse): void {
    const pending = this.pendingRequests.get(response.id);
    if (!pending) return;

    clearTimeout(pending.timeout);
    this.pendingRequests.delete(response.id);

    if (response.success) {
      pending.resolve(response.data);
    } else {
      pending.reject(new Error(response.error?.message || 'Unbekannter Fehler'));
    }
  }

  private handleRequest(message: BridgeMessage): void {
    // Event für native Handler zum Verarbeiten emittieren
    this.emit(`request:${message.action}`, message);
  }

  private handleEvent(message: BridgeMessage): void {
    this.emit(`event:${message.action}`, message.payload);
  }

  private generateId(): string {
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }

  // Aufgerufen wenn WebView signalisiert, dass es bereit ist
  onBridgeReady(): void {
    this.isReady = true;

    // Warteschlangen-Nachrichten leeren
    while (this.messageQueue.length > 0) {
      const message = this.messageQueue.shift();
      if (message) this.sendMessage(message);
    }
  }
}

Web-Seiten-Implementierung#

Die Web-Seite muss Nachrichten verarbeiten und eine ähnliche API bereitstellen:

TypeScript
// WebViewBridge.ts - in WebView injiziert
class WebViewBridge {
  private handlers = new Map<string, (payload: any) => Promise<any>>();
  private eventListeners = new Map<string, Set<(payload: any) => void>>();

  constructor() {
    // Höre auf Nachrichten von Native
    window.addEventListener('message', this.handleMessage.bind(this));

    // Überschreibe window.ReactNativeWebView für Android
    if (!window.ReactNativeWebView) {
      window.ReactNativeWebView = {
        postMessage: (message: string) => {
          window.postMessage(message, '*');
        }
      };
    }

    // Signalisiere, dass Bridge bereit ist
    this.emit('BRIDGE_READY');
  }

  // Registriere Request-Handler
  handle<TRequest, TResponse>(
    action: string,
    handler: (payload: TRequest) => Promise<TResponse>
  ): void {
    this.handlers.set(action, handler);
  }

  // Sende Request an Native
  async request<TRequest, TResponse>(
    action: string,
    payload: TRequest
  ): Promise<TResponse> {
    const id = this.generateId();
    const message: BridgeMessage<TRequest> = {
      id,
      type: MessageType.REQUEST,
      action,
      payload,
      timestamp: Date.now(),
      version: '1.0'
    };

    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        reject(new Error(`Request ${action} timed out`));
      }, 10000);

      const responseHandler = (event: MessageEvent) => {
        try {
          const response: BridgeMessage = JSON.parse(event.data);

          if (response.type === MessageType.RESPONSE && response.id === id) {
            clearTimeout(timeout);
            window.removeEventListener('message', responseHandler);

            if ((response as BridgeResponse).success) {
              resolve((response as BridgeResponse).data);
            } else {
              reject(new Error((response as BridgeResponse).error?.message));
            }
          }
        } catch (error) {
          // Parsing-Fehler von anderen Nachrichten ignorieren
        }
      };

      window.addEventListener('message', responseHandler);
      this.sendMessage(message);
    });
  }

  // Sende Event an Native
  emit(action: string, payload?: unknown): void {
    const message: BridgeMessage = {
      id: this.generateId(),
      type: MessageType.EVENT,
      action,
      payload,
      timestamp: Date.now(),
      version: '1.0'
    };

    this.sendMessage(message);
  }

  // Abonniere Events von Native
  on(action: string, listener: (payload: any) => void): () => void {
    if (!this.eventListeners.has(action)) {
      this.eventListeners.set(action, new Set());
    }

    this.eventListeners.get(action)!.add(listener);

    // Gib Unsubscribe-Funktion zurück
    return () => {
      this.eventListeners.get(action)?.delete(listener);
    };
  }

  private async handleMessage(event: MessageEvent): Promise<void> {
    try {
      const message: BridgeMessage = JSON.parse(event.data);

      if (message.type === MessageType.REQUEST) {
        await this.handleRequest(message);
      } else if (message.type === MessageType.EVENT) {
        this.handleEvent(message);
      }
    } catch (error) {
      // Nicht-Bridge-Nachrichten ignorieren
    }
  }

  private async handleRequest(message: BridgeMessage): Promise<void> {
    const handler = this.handlers.get(message.action);

    if (!handler) {
      this.sendResponse(message.id, false, null, {
        code: 'HANDLER_NOT_FOUND',
        message: `Kein Handler registriert für Aktion: ${message.action}`
      });
      return;
    }

    try {
      const result = await handler(message.payload);
      this.sendResponse(message.id, true, result);
    } catch (error) {
      this.sendResponse(message.id, false, null, {
        code: 'HANDLER_ERROR',
        message: error instanceof Error ? error.message : 'Unbekannter Fehler',
        details: error
      });
    }
  }

  private handleEvent(message: BridgeMessage): void {
    const listeners = this.eventListeners.get(message.action);
    if (listeners) {
      listeners.forEach(listener => {
        try {
          listener(message.payload);
        } catch (error) {
          console.error(`Event-Listener-Fehler für ${message.action}:`, error);
        }
      });
    }
  }

  private sendMessage(message: BridgeMessage): void {
    const serialized = JSON.stringify(message);

    if (window.ReactNativeWebView?.postMessage) {
      window.ReactNativeWebView.postMessage(serialized);
    } else {
      window.parent.postMessage(serialized, '*');
    }
  }

  private sendResponse(
    id: string,
    success: boolean,
    data?: unknown,
    error?: any
  ): void {
    const response: BridgeResponse = {
      id,
      success,
      data,
      error
    };

    this.sendMessage({
      ...response,
      type: MessageType.RESPONSE,
      action: 'response',
      payload: data,
      timestamp: Date.now(),
      version: '1.0'
    });
  }

  private generateId(): string {
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }
}

// Bridge global initialisieren
declare global {
  interface Window {
    bridge: WebViewBridge;
    ReactNativeWebView?: {
      postMessage: (message: string) => void;
    };
  }
}

window.bridge = new WebViewBridge();

Type-Safe Kommunikation#

Einer unserer größten Erfolge war die Erreichung von Type Safety über die Bridge hinweg. So haben wir es gemacht:

TypeScript
// Geteilte Typen-Datei (verwendet von Native und Web)
export interface BridgeAPI {
  // Authentifizierung
  'auth.getToken': {
    request: void;
    response: { token: string; expiresAt: number };
  };

  'auth.refreshToken': {
    request: { currentToken: string };
    response: { token: string; expiresAt: number };
  };

  // Navigation
  'navigation.navigate': {
    request: { screen: string; params?: Record<string, any> };
    response: void;
  };

  'navigation.goBack': {
    request: void;
    response: boolean;
  };

  // Native Features
  'camera.takePhoto': {
    request: {
      quality?: number;
      allowEdit?: boolean;
    };
    response: {
      uri: string;
      width: number;
      height: number;
    };
  };

  'biometrics.authenticate': {
    request: { reason: string };
    response: { success: boolean };
  };

  // Analytics
  'analytics.track': {
    request: {
      event: string;
      properties?: Record<string, any>;
    };
    response: void;
  };
}

// Type-safe Bridge-Wrapper
export class TypedBridge {
  constructor(private bridge: WebViewBridge | NativeWebViewBridge) {}

  async request<K extends keyof BridgeAPI>(
    action: K,
    payload: BridgeAPI[K]['request']
  ): Promise<BridgeAPI[K]['response']> {
    return this.bridge.request(action, payload);
  }

  on<K extends keyof BridgeAPI>(
    action: K,
    handler: (payload: BridgeAPI[K]['request']) => void
  ): () => void {
    return this.bridge.on(action, handler);
  }
}

Die Nutzung wird vollständig typsicher:

TypeScript
// In WebView
const bridge = new TypedBridge(window.bridge);

// TypeScript kennt die exakten Request/Response-Typen
const { token } = await bridge.request('auth.getToken', undefined);
const photo = await bridge.request('camera.takePhoto', { quality: 0.8 });

// Type-Fehler wenn du falsche Parameter übergibst
// bridge.request('auth.getToken', { wrong: 'param' }); // ❌ TypeScript-Fehler

Service-Integrations-Patterns#

Hier ist, wie wir verschiedene Services über die Bridge integrierten:

Authentifizierungs-Service#

TypeScript
// Native Seite
class AuthBridgeHandler {
  constructor(
    private bridge: NativeWebViewBridge,
    private authService: AuthService
  ) {
    this.setupHandlers();
  }

  private setupHandlers(): void {
    this.bridge.on('request:auth.getToken', async (message) => {
      try {
        const token = await this.authService.getAccessToken();

        if (!token) {
          throw new Error('Keine aktive Session');
        }

        this.bridge.sendResponse(message.id, true, {
          token,
          expiresAt: this.authService.getTokenExpiry()
        });
      } catch (error) {
        this.bridge.sendResponse(message.id, false, null, {
          code: 'AUTH_ERROR',
          message: error.message
        });
      }
    });

    this.bridge.on('request:auth.refreshToken', async (message) => {
      try {
        const newToken = await this.authService.refreshToken();

        this.bridge.sendResponse(message.id, true, {
          token: newToken,
          expiresAt: this.authService.getTokenExpiry()
        });
      } catch (error) {
        // Wenn Refresh fehlschlägt, erzwinge Re-Login
        this.bridge.emit('auth.sessionExpired');

        this.bridge.sendResponse(message.id, false, null, {
          code: 'REFRESH_FAILED',
          message: 'Session abgelaufen'
        });
      }
    });
  }
}

// Web-Seite - Auto-refreshing Fetch-Wrapper
class AuthenticatedFetch {
  constructor(private bridge: TypedBridge) {}

  async fetch(url: string, options: RequestInit = {}): Promise<Response> {
    let token = await this.getValidToken();

    const response = await fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${token}`
      }
    });

    // Mit refreshtem Token wiederholen falls unauthorized
    if (response.status === 401) {
      token = await this.refreshToken();

      return fetch(url, {
        ...options,
        headers: {
          ...options.headers,
          'Authorization': `Bearer ${token}`
        }
      });
    }

    return response;
  }

  private async getValidToken(): Promise<string> {
    const cached = this.getCachedToken();

    if (cached && cached.expiresAt > Date.now() + 60000) {
      return cached.token;
    }

    return this.refreshToken();
  }

  private async refreshToken(): Promise<string> {
    const { token, expiresAt } = await this.bridge.request(
      'auth.refreshToken',
      { currentToken: this.getCachedToken()?.token }
    );

    this.cacheToken(token, expiresAt);
    return token;
  }

  private cacheToken(token: string, expiresAt: number): void {
    sessionStorage.setItem('bridge_token', JSON.stringify({
      token,
      expiresAt
    }));
  }

  private getCachedToken(): { token: string; expiresAt: number } | null {
    const cached = sessionStorage.getItem('bridge_token');
    return cached ? JSON.parse(cached) : null;
  }
}

Native Feature-Zugang#

Hier ist, wie wir native Features für WebViews freigaben:

TypeScript
// Kamera-Integration
class CameraBridgeHandler {
  constructor(
    private bridge: NativeWebViewBridge,
    private imagePicker: ImagePicker
  ) {
    this.bridge.on('request:camera.takePhoto', async (message) => {
      try {
        const { quality = 0.8, allowEdit = false } = message.payload || {};

        const result = await this.imagePicker.launchCamera({
          mediaType: 'photo',
          quality,
          allowsEditing: allowEdit,
          // Wichtig: base64 für WebView-Kompatibilität
          includeBase64: true
        });

        if (result.didCancel) {
          throw new Error('Benutzer hat abgebrochen');
        }

        if (result.errorMessage) {
          throw new Error(result.errorMessage);
        }

        const asset = result.assets?.[0];
        if (!asset) {
          throw new Error('Kein Bild ausgewählt');
        }

        // In Data URI für WebView konvertieren
        const dataUri = `data:${asset.type};base64,${asset.base64}`;

        this.bridge.sendResponse(message.id, true, {
          uri: dataUri,
          width: asset.width!,
          height: asset.height!
        });
      } catch (error) {
        this.bridge.sendResponse(message.id, false, null, {
          code: 'CAMERA_ERROR',
          message: error.message
        });
      }
    });
  }
}

// Web-Seiten-Nutzung
async function uploadProfilePhoto() {
  try {
    const photo = await bridge.request('camera.takePhoto', {
      quality: 0.9,
      allowEdit: true
    });

    // Data URI in Blob für Upload konvertieren
    const blob = await dataURItoBlob(photo.uri);

    // Upload mit authenticated fetch
    const formData = new FormData();
    formData.append('photo', blob);

    const response = await authenticatedFetch.fetch('/api/profile/photo', {
      method: 'POST',
      body: formData
    });

    return response.json();
  } catch (error) {
    if (error.message === 'Benutzer hat abgebrochen') {
      // Abbruch behandeln
      return null;
    }
    throw error;
  }
}

Performance-Optimierung#

Nach dem Deployment in die Produktion entdeckten wir mehrere Performance-Engpässe:

Große Payload-Behandlung#

Das Senden großer Payloads (Bilder, Dokumente) über postMessage war langsam und konnte die UI einfrieren. Unsere Lösung war Chunking:

TypeScript
class ChunkedMessageHandler {
  private chunks = new Map<string, {
    chunks: string[];
    receivedCount: number;
    totalChunks: number;
  }>();

  // Teile große Nachrichten in Chunks
  sendChunked(message: BridgeMessage, chunkSize = 50000): void {
    const serialized = JSON.stringify(message);

    if (serialized.length <= chunkSize) {
      // Klein genug zum direkten Senden
      this.send(serialized);
      return;
    }

    // In Chunks aufteilen
    const chunks: string[] = [];
    for (let i = 0; i < serialized.length; i += chunkSize) {
      chunks.push(serialized.slice(i, i + chunkSize));
    }

    const chunkId = this.generateId();

    // Jeden Chunk senden
    chunks.forEach((chunk, index) => {
      this.send(JSON.stringify({
        type: 'CHUNK',
        chunkId,
        chunkIndex: index,
        totalChunks: chunks.length,
        data: chunk
      }));
    });
  }

  handleChunk(message: any): BridgeMessage | null {
    const { chunkId, chunkIndex, totalChunks, data } = message;

    if (!this.chunks.has(chunkId)) {
      this.chunks.set(chunkId, {
        chunks: new Array(totalChunks),
        receivedCount: 0,
        totalChunks
      });
    }

    const chunkData = this.chunks.get(chunkId)!;
    chunkData.chunks[chunkIndex] = data;
    chunkData.receivedCount++;

    // Prüfe ob alle Chunks empfangen
    if (chunkData.receivedCount === chunkData.totalChunks) {
      const complete = chunkData.chunks.join('');
      this.chunks.delete(chunkId);

      try {
        return JSON.parse(complete);
      } catch (error) {
        console.error('Chunked Message parsen fehlgeschlagen:', error);
        return null;
      }
    }

    return null;
  }
}

Nachrichten-Batching#

Für hochfrequente Events wie Analytics implementierten wir Batching:

TypeScript
class BatchedBridge extends NativeWebViewBridge {
  private batch: BridgeMessage[] = [];
  private batchTimeout?: NodeJS.Timeout;
  private batchSize = 10;
  private batchDelay = 100; // ms

  emit(action: string, payload?: unknown): void {
    if (this.shouldBatch(action)) {
      this.addToBatch({
        id: this.generateId(),
        type: MessageType.EVENT,
        action,
        payload,
        timestamp: Date.now(),
        version: '1.0'
      });
    } else {
      super.emit(action, payload);
    }
  }

  private shouldBatch(action: string): boolean {
    // Batch Analytics und nicht-kritische Events
    return action.startsWith('analytics.') ||
           action.startsWith('metrics.');
  }

  private addToBatch(message: BridgeMessage): void {
    this.batch.push(message);

    if (this.batch.length >= this.batchSize) {
      this.flushBatch();
    } else if (!this.batchTimeout) {
      this.batchTimeout = setTimeout(() => {
        this.flushBatch();
      }, this.batchDelay);
    }
  }

  private flushBatch(): void {
    if (this.batch.length === 0) return;

    const batchMessage: BridgeMessage = {
      id: this.generateId(),
      type: MessageType.EVENT,
      action: 'batch',
      payload: this.batch,
      timestamp: Date.now(),
      version: '1.0'
    };

    super.sendMessage(batchMessage);

    this.batch = [];
    if (this.batchTimeout) {
      clearTimeout(this.batchTimeout);
      this.batchTimeout = undefined;
    }
  }
}

Debugging und Monitoring#

Production-Debugging war anfänglich ein Albtraum. So machten wir es handhabbar:

Nachrichten-Logging und Replay#

TypeScript
class BridgeDebugger {
  private messageLog: Array<{
    timestamp: number;
    direction: 'sent' | 'received';
    message: BridgeMessage;
    duration?: number;
  }> = [];

  private maxLogSize = 1000;

  logSent(message: BridgeMessage): void {
    this.addToLog('sent', message);
  }

  logReceived(message: BridgeMessage, duration?: number): void {
    this.addToLog('received', message, duration);
  }

  private addToLog(
    direction: 'sent' | 'received',
    message: BridgeMessage,
    duration?: number
  ): void {
    this.messageLog.push({
      timestamp: Date.now(),
      direction,
      message,
      duration
    });

    // Log-Größe handhabbar halten
    if (this.messageLog.length > this.maxLogSize) {
      this.messageLog.shift();
    }
  }

  // Logs für Debugging exportieren
  exportLogs(): string {
    return JSON.stringify(this.messageLog, null, 2);
  }

  // Performance-Metriken abrufen
  getMetrics(): {
    totalMessages: number;
    averageResponseTime: number;
    slowestActions: Array<{ action: string; duration: number }>;
    errorRate: number;
  } {
    const requests = this.messageLog.filter(
      log => log.message.type === MessageType.REQUEST
    );

    const responses = this.messageLog.filter(
      log => log.message.type === MessageType.RESPONSE && log.duration
    );

    const errors = this.messageLog.filter(
      log => log.message.type === MessageType.ERROR
    );

    const responseTimes = responses
      .map(r => r.duration!)
      .filter(d => d > 0);

    const avgResponseTime = responseTimes.length > 0
      ? responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length
      : 0;

    const slowest = responses
      .filter(r => r.duration)
      .sort((a, b) => b.duration! - a.duration!)
      .slice(0, 10)
      .map(r => ({
        action: r.message.action,
        duration: r.duration!
      }));

    return {
      totalMessages: this.messageLog.length,
      averageResponseTime: Math.round(avgResponseTime),
      slowestActions: slowest,
      errorRate: errors.length / requests.length
    };
  }
}

Remote-Debugging-Setup#

Für Production-Debugging implementierten wir eine Remote-Debugging-Fähigkeit:

TypeScript
// Nur Entwicklung - Remote Debugging
if (__DEV__) {
  const enableRemoteDebugging = () => {
    const ws = new WebSocket('ws://localhost:8080/bridge-debug');

    ws.onopen = () => {
      console.log('[Bridge Debug] Mit Debugger verbunden');
    };

    // Alle Bridge-Nachrichten an Debugger weiterleiten
    bridge.on('*', (message) => {
      ws.send(JSON.stringify({
        type: 'BRIDGE_MESSAGE',
        timestamp: Date.now(),
        message
      }));
    });
  };
}

Reale Herausforderungen und Lösungen#

Der Race Condition Bug#

Erinnerst du dich an den Zahlungsablauf-Bug, den ich erwähnte? Hier ist, was passierte:

  1. WebView fordert Auth-Token an
  2. Native App startet Token-Refresh
  3. WebView läuft beim Warten auf Antwort in Timeout
  4. Native App schließt Refresh ab und sendet Antwort
  5. WebView ist bereits weitergegangen, Antwort wird ignoriert

Die Lösung erforderte die Implementierung ordnungsgemäßer Request-Cancellation:

TypeScript
class CancellableRequest {
  private cancelled = false;
  private cleanupFns: Array<() => void> = [];

  constructor(
    private promise: Promise<any>,
    private onCancel?: () => void
  ) {}

  then(onFulfilled: any, onRejected: any): Promise<any> {
    return this.promise.then(
      (value) => {
        if (this.cancelled) {
          throw new Error('Request cancelled');
        }
        return onFulfilled(value);
      },
      onRejected
    );
  }

  cancel(): void {
    this.cancelled = true;
    this.onCancel?.();
    this.cleanupFns.forEach(fn => fn());
  }

  addCleanup(fn: () => void): void {
    this.cleanupFns.push(fn);
  }
}

// Nutzung in Bridge
request<TRequest, TResponse>(
  action: string,
  payload: TRequest,
  timeoutMs = 10000
): CancellableRequest {
  const id = this.generateId();
  let timeoutId: NodeJS.Timeout;

  const promise = new Promise((resolve, reject) => {
    timeoutId = setTimeout(() => {
      this.pendingRequests.delete(id);
      reject(new Error(`Request ${action} timed out`));
    }, timeoutMs);

    this.pendingRequests.set(id, { resolve, reject, timeout: timeoutId });
    this.sendMessage(message);
  });

  const request = new CancellableRequest(promise, () => {
    clearTimeout(timeoutId);
    this.pendingRequests.delete(id);

    // Native Seite über Cancellation benachrichtigen
    this.emit('request.cancelled', { requestId: id });
  });

  request.addCleanup(() => clearTimeout(timeoutId));

  return request;
}

Plattform-spezifische Eigenarten#

iOS und Android WebViews verhalten sich auf subtile Weise unterschiedlich:

TypeScript
// Plattform-spezifische Nachrichten-Behandlung
class PlatformBridge extends NativeWebViewBridge {
  sendMessage(message: BridgeMessage): void {
    if (Platform.OS === 'ios') {
      // iOS benötigt spezifisches Timing für postMessage
      requestAnimationFrame(() => {
        super.sendMessage(message);
      });
    } else {
      // Android kann sofort senden
      super.sendMessage(message);
    }
  }

  handleMessage(event: WebViewMessageEvent): void {
    // Android sendet Daten als String, iOS manchmal als Objekt
    const data = typeof event.nativeEvent.data === 'string'
      ? event.nativeEvent.data
      : JSON.stringify(event.nativeEvent.data);

    try {
      const message = JSON.parse(data);
      super.handleMessage({
        nativeEvent: { ...event.nativeEvent, data }
      });
    } catch (error) {
      console.error('[Bridge] Plattform-Parsing-Fehler:', error);
    }
  }
}

Performance-Ergebnisse#

Nach der Implementierung dieser Patterns verbesserten sich unsere Metriken erheblich:

Nachrichten-Performance#

  • Durchschnittliche Antwortzeit: 45ms (runter von 180ms)
    1. Perzentil: 200ms (runter von 2s)
  • Fehlgeschlagene Nachrichten: 0.01% (runter von 2.3%)

Speichernutzung#

  • Bridge-Overhead: ~5MB pro WebView
  • Nachrichten-Warteschlangen-Spitze: 15MB (mit Batching)
  • Keine Speicherlecks nach 24h Stress-Test

Akku-Auswirkung#

  • 5% Reduzierung der Akkuentladung
  • Hauptsächlich durch Batching und Reduzierung der Nachrichten-Frequenz

Wichtige Erkenntnisse#

  1. Design für Fehler: Jede Nachricht kann fehlschlagen. Bauen Sie Retry- und Timeout-Behandlung vom ersten Tag an.

  2. Type Safety ist kritisch: Die Investition in TypeScript-Typen über die Bridge hinweg zahlte sich 10x in reduzierter Debugging-Zeit aus.

  3. Performance erfordert Batching: Einzelne Nachrichten sind teuer. Batchen Sie wenn möglich.

  4. Plattform-Unterschiede sind wichtig: Teste gründlich auf iOS und Android, besonders ältere Geräte.

  5. Debugging-Tools sind essentiell: Du kannst nicht reparieren, was du nicht sehen kannst. Baue umfassendes Logging früh auf.

  6. Alternative Ansätze haben Trade-offs: Jede Kommunikationsmethode hat ihre Stärken. Wähle basierend auf deinen spezifischen Bedürfnissen.

Was kommt als Nächstes?#

In Teil 3 erkunden wir:

  • Multi-Channel-Rendering (dasselbe Micro Frontend in App, Web und Desktop)
  • Production-Performance-Optimierungstechniken
  • Offline-Modus und Sync handhaben
  • Sicherheitsüberlegungen und Sandboxing

Die Kommunikationsschicht ist das Herz mobiler Micro Frontends. Machst du es richtig, und alles andere wird handhabbar. Machst du es falsch, und du wirst um 3 Uhr morgens Race Conditions debuggen wie wir.

Nächstes Mal schauen wir uns an, wie diese Architektur über mehrere Plattformen skaliert und die überraschenden Optimierungen, die wir in der Produktion fanden.

Loading...

Kommentare (0)

An der Unterhaltung teilnehmen

Melde dich an, um deine Gedanken zu teilen und mit der Community zu interagieren

Noch keine Kommentare

Sei der erste, der deine Gedanken zu diesem Beitrag teilt!

Related Posts