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: Architektur-Grundlagen und WebView-Integrations-Patterns
- Teil 2 (Du bist hier): WebView-Kommunikations-Patterns und Service-Integration
- Teil 3: Multi-Channel-Architektur und Production-Optimierung
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:
// 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:
// 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:
// 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:
// 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:
// 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:
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:
// 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:
// 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:
// 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#
// 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:
// 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:
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:
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#
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:
// 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:
- WebView fordert Auth-Token an
- Native App startet Token-Refresh
- WebView läuft beim Warten auf Antwort in Timeout
- Native App schließt Refresh ab und sendet Antwort
- WebView ist bereits weitergegangen, Antwort wird ignoriert
Die Lösung erforderte die Implementierung ordnungsgemäßer Request-Cancellation:
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:
// 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)
-
- 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#
-
Design für Fehler: Jede Nachricht kann fehlschlagen. Bauen Sie Retry- und Timeout-Behandlung vom ersten Tag an.
-
Type Safety ist kritisch: Die Investition in TypeScript-Typen über die Bridge hinweg zahlte sich 10x in reduzierter Debugging-Zeit aus.
-
Performance erfordert Batching: Einzelne Nachrichten sind teuer. Batchen Sie wenn möglich.
-
Plattform-Unterschiede sind wichtig: Teste gründlich auf iOS und Android, besonders ältere Geräte.
-
Debugging-Tools sind essentiell: Du kannst nicht reparieren, was du nicht sehen kannst. Baue umfassendes Logging früh auf.
-
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.
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!
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!