背景

Hybrid App 开发中,WebView 内嵌 H5 页面是很常见的架构。H5 需要告诉 Flutter”我要跳转某个页面”,Flutter 需要告诉 H5”用户已登录”或”支付完成”。这就需要一个双向通信通道

通信原理

Flutter 和 WebView 的通信分两个方向:

方向 机制
H5 → Flutter addJavaScriptChannel + postMessage
Flutter → H5 runJavaScript 执行 JS 代码
1
2
3
4
5
6
7
8
9
H5 (JavaScript)                      Flutter (Dart)
┌────────────────────┐ ┌──────────────────────────┐
│ window │ postMessage │ WebViewController │
│ .MyChannel │ ────────────→ │ .addJavaScriptChannel() │
│ .postMessage(json) │ │ ↓ │
└────────────────────┘ │ onMessageReceived │
│ ↓ │
runJavaScript ←───────│ _handleMessage(json) │
('window.xxx=123') └──────────────────────────┘

环境搭建

依赖

1
2
3
4
# pubspec.yaml
dependencies:
webview_flutter: ^4.13.1
url_launcher: ^6.3.2 # 如果需要外部浏览器跳转
1
flutter pub add webview_flutter url_launcher

平台注册

webview_flutter 4.x 需要显式注册平台实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// main.dart 或启动入口
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
import 'dart:io';

void main() {
// 注册对应平台的 WebView 实现
if (Platform.isAndroid) {
WebView.platform = AndroidWebViewPlatform();
} else if (Platform.isIOS) {
WebView.platform = WebKitWebViewPlatform();
}
runApp(MyApp());
}

核心实现

一、Flutter 接收 H5 消息(H5 → Flutter)

1. Flutter 端:注册 JavaScript Channel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import 'dart:convert';
import 'package:webview_flutter/webview_flutter.dart';

class WebViewPage extends StatefulWidget {
final String url;
const WebViewPage({super.key, required this.url});

@override
State<WebViewPage> createState() => _WebViewPageState();
}

class _WebViewPageState extends State<WebViewPage> {
WebViewController? _controller;

Future<void> _initWebView() async {
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted) // 允许执行 JS
..setNavigationDelegate(_buildNavigationDelegate());

// 注册 JS Channel:H5 调用 MyAppChannel.postMessage(jsonStr)
await _controller!.addJavaScriptChannel(
'MyAppChannel',
onMessageReceived: (JavaScriptMessage msg) {
_handleMessage(msg.message);
},
);

await _controller!.loadRequest(Uri.parse(widget.url));
}

/// 解析并分发 H5 消息
void _handleMessage(String rawMessage) {
Map<String, dynamic>? payload;
try {
payload = jsonDecode(rawMessage) as Map<String, dynamic>;
} catch (e) {
debugPrint('消息格式错误: $rawMessage');
return;
}

final String action = payload['action']?.toString() ?? '';
final dynamic data = payload['data'];

switch (action) {
case 'navigateTo':
// H5 请求跳转到 Flutter 页面
final String page = data?['page'] ?? '';
// GoRouter.of(context).push(page);
debugPrint('H5 请求跳转: $page');
case 'goBack':
Navigator.of(context).pop();
case 'openExternal':
// 外部浏览器打开链接
final String url = data?['url'] ?? '';
// launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
debugPrint('H5 请求打开外部链接: $url');
}
}

NavigationDelegate _buildNavigationDelegate() {
return NavigationDelegate(
// 页面加载时注入环境标识,确保 H5 知道自己在 Flutter 中
onPageFinished: (String url) async {
await _controller?.runJavaScript(
'window.__APP_FLUTTER_WEBVIEW__=true;',
);
},
);
}
}

2. H5 端:发送消息

1
2
3
4
5
6
7
8
9
// H5 页面中调用
const message = {
action: 'navigateTo',
data: {
page: '/user/123',
params: { from: 'h5' },
},
};
MyAppChannel.postMessage(JSON.stringify(message));

注意:JS Channel 名称 'MyAppChannel' 对应 Flutter 端 addJavaScriptChannel 的第一个参数,必须一致。

二、Flutter 向 H5 发送消息(Flutter → H5)

通过 runJavaScript 在 WebView 中执行任意 JS 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// 通知 H5:支付完成
Future<void> notifyPayResult({required bool success}) async {
await _controller?.runJavaScript(
'window.dispatchEvent(new CustomEvent("onPayResult", {'
' detail: { success: ${success ? 'true' : 'false'} }'
'}));',
);
}

/// 注入环境变量(告诉 H5 当前在 App 内)
Future<void> injectEnvFlag() async {
await _controller?.runJavaScript(
'window.__APP_FLUTTER_WEBVIEW__=true;'
'window.__APP_VERSION__="1.0.0";',
);
}

H5 端接收:

1
2
3
4
5
6
7
8
9
10
11
12
// 监听支付结果
window.addEventListener('onPayResult', (event) => {
console.log('支付结果:', event.detail);
if (event.detail.success) {
// 支付成功,刷新页面
}
});

// 检测是否在 App WebView 中
if (window.__APP_FLUTTER_WEBVIEW__) {
console.log('当前运行在 App WebView 中');
}

三、消息路由器(解耦版)

当业务复杂时,建议将消息解析和分发从页面中抽离:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/// H5 消息路由器
abstract final class H5Bridge {
static void handleMessage(
BuildContext context,
String rawMessage, {
WebViewController? controller,
}) {
Map<String, dynamic>? payload;
try {
payload = jsonDecode(rawMessage) as Map<String, dynamic>;
} catch (e) {
debugPrint('[H5Bridge] 消息解析失败: $e');
return;
}

final String action = payload?['action']?.toString() ?? '';
final dynamic data = payload?['data'];

switch (action) {
case 'navigateTo':
_handleNavigate(context, data);
case 'goBack':
_handleGoBack(context);
case 'openExternal':
_handleOpenExternal(data);
case 'setTitle':
_handleSetTitle(data);
// 后续加新 action 只需在这里加一个 case
}
}

static void _handleNavigate(BuildContext context, dynamic data) {
final String path = data?['path'] ?? '';
// 使用你的路由方案 (GoRouter / Navigator)
// context.push(path);
}

static void _handleGoBack(BuildContext context) {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
}

static void _handleOpenExternal(dynamic data) async {
final String url = data?['url'] ?? '';
final Uri? uri = Uri.tryParse(url);
if (uri == null) return;
await launchUrl(uri, mode: LaunchMode.externalApplication);
}

static void _handleSetTitle(dynamic data) {
// 更新 AppBar 标题
debugPrint('H5 请求设置标题: ${data?['title']}');
}
}

WebView 页面中调用更简洁:

1
2
3
4
5
6
await _controller!.addJavaScriptChannel(
'MyAppChannel',
onMessageReceived: (JavaScriptMessage msg) {
H5Bridge.handleMessage(context, msg.message, controller: _controller);
},
);

进阶技巧

1. 注入时机:onPageStarted vs onPageFinished

时机 特点
onPageStarted 早,但 DOM 可能未就绪,runJavaScript 可能不生效
onPageFinished DOM 已就绪,但 JS 执行会更晚

推荐:两个回调都注入一份,作为兜底。

1
2
3
4
5
6
7
8
NavigationDelegate(
onPageStarted: (url) async {
await controller?.runJavaScript('window.__FLAG__=true;');
},
onPageFinished: (url) async {
await controller?.runJavaScript('window.__FLAG__=true;'); // 兜底
},
);

2. 域名白名单

只允许可信域名加载,防止恶意 H5 利用 JS Channel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool isDomainWhitelisted(Uri uri) {
const whitelist = ['your-domain.com', 'localhost'];
final host = uri.host.trim().toLowerCase();
return whitelist.any((d) => host == d || host.endsWith('.$d'));
}

// 在 onNavigationRequest 中校验
onNavigationRequest: (NavigationRequest request) {
final uri = Uri.parse(request.url);
if (!isDomainWhitelisted(uri)) {
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},

3. User-Agent 注入

有些场景 H5 需要在 JS Channel 注册前就知道自己运行在 App 中(比如页面首屏逻辑),可以通过自定义 UA 让服务端或 JS 识别:

1
2
3
4
5
6
// Flutter 端设置
final userAgent = await controller.getUserAgent();
await controller.setUserAgent('$userAgent MyAppFlutter/1');

// H5 端读取
const isInApp = navigator.userAgent.includes('MyAppFlutter');

完整的通信协议设计建议

定义一个标准的消息格式:

1
2
3
4
5
6
// TypeScript 定义(H5 端)
interface H5Message {
action: string; // 动作类型
data?: any; // 载荷
callbackId?: string; // 可选:用于回传结果
}

Flutter 端对应解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void _handleMessage(String raw) {
final msg = jsonDecode(raw) as Map<String, dynamic>;
final action = msg['action'] as String;
final data = msg['data'];
final callbackId = msg['callbackId'] as String?;

// 执行对应 action...
// 如果需要回传结果给 H5:
if (callbackId != null) {
_controller?.runJavaScript(
'window._nativeCallback("$callbackId", {ok: true});'
);
}
}

参考