- Introduced a new document outlining SDK version locking, static analysis, formatting, generated artifacts management, branching and commit conventions, and CI gate checks. - Updated README to include the new conventions document. - Modified API design to use numeric error codes instead of strings, with a dedicated ErrorCode object for better maintainability. - Adjusted global exception handling to return numeric error codes. - Updated tests to reflect changes in error code handling.
19 KiB
10. Embedded H5 容器与 JSBridge
为什么单独一篇
PRD §7 的 Embedded H5 承载了 App 最核心的几条业务链路(报价开单、施工查车、结算收银),它不是"顺带加个 WebView",而是一个有票据换取、双向桥接、生命周期管理和安全边界的完整子系统。这些内容放不进 01-09 的任何一篇,所以单独成篇。
适用范围:Embedded H5 仅用于承载 F6 页面,不做通用外链容器(PRD §7.1)。任何"能不能顺便用它打开某个网页"的需求,默认答案是不能。
决策
| 项 | 决策 |
|---|---|
| WebView 库 | webview_flutter ^4.14.1 |
| 归属包 | core_webview(依赖 core_auth、native_scan/native_media/native_device) |
| 桥接通道 | 单一 JavaScript Channel ContiBridge,统一 {id, method, params} 协议 |
| URL 来源 | 只接受 App Backend 换票后下发的 URL,路由里不传裸 URL |
为什么选 webview_flutter 而不是 flutter_inappwebview
| webview_flutter | flutter_inappwebview | |
|---|---|---|
| 维护方 | Flutter 官方(flutter.dev) | 社区个人维护 |
| 最新 stable | 4.14.1,一个月前发布,持续更新 |
6.1.5,距今约 22 个月,新特性都在 6.2.0-beta |
| 能力覆盖 | 基础能力齐全,高级能力走平台特定 controller | 更丰富(拦截请求、Cookie 精细管理、下载) |
| 我们实际需要的 | JS Channel、导航拦截、文件选择、Cookie 清理 | 同 |
flutter_inappwebview 能力更全,但它的 stable 版本已经近两年没发布,新功能和 bugfix 都压在 beta 上。对一个要跑核心交易链路、生命周期以年计的 App 来说,这是不能接受的维护风险——真出问题时我们只能自己 fork。
webview_flutter 的能力缺口(Android 的 <input type="file">)有官方解法,用平台特定 controller 即可:
if (controller.platform is AndroidWebViewController) {
await AndroidWebViewController.enableDebugging(env.enableLog);
(controller.platform as AndroidWebViewController)
.setOnShowFileSelector(_onShowFileSelector); // 交给 native_media 处理
}
如果后续发现 F6 页面用到了 webview_flutter 确实做不了的能力(比如需要拦截并改写请求),再评估切换;届时因为所有 WebView 交互都收在 core_webview 一个包里,切换代价是可控的。这也是不让 feature_* 直接依赖 WebView 库的原因。
H5 启动流程
对应 PRD §7.2:
用户点击功能入口(feature_* 或工作台菜单)
↓
context.push('/webview?target=QUOTE_ORDER') ← 路由里只有 target,没有 URL
↓
core_webview: POST /api/v1/h5/launch { target }
↓
App Backend: 校验登录态 / 门店上下文 / 角色权限
→ 经 F6 Integration Adapter 取票据
↓
返回 { url, ticket, expiresIn, title }
↓
core_webview: 域名白名单校验 → WebViewController.loadRequest(url)
// packages/core_webview/lib/src/h5_launch_repository.dart
class H5LaunchInfo {
final String url; // 已由后端拼好票据和上下文参数
final String title;
final Duration ttl; // 票据有效期,用于判断是否需要换票
}
启动上下文参数(PRD §7.3)由 App Backend 拼进 URL,客户端不参与拼接。 客户端拼参数意味着 userId/storeId/roleCode 这些权限相关字段可以被本地篡改,而后端拼接时这些值都从服务端的会话上下文取,客户端只能说"我要开 QUOTE_ORDER"。
客户端唯一负责传的是 traceId——请求 /h5/launch 时带的 X-Trace-Id(见 05-networking.md),后端把它带进 H5 URL,这样"用户在 H5 里遇到问题"能一路追到 App 侧的请求。
域名白名单
// packages/core_webview/lib/src/url_guard.dart
class UrlGuard {
const UrlGuard(this._allowedHosts);
final Set<String> _allowedHosts; // 来自 env/{flavor}.json,各环境不同
bool isAllowed(Uri uri) {
if (uri.scheme != 'https') return false; // 只允许 HTTPS(PRD §7.6)
final host = uri.host.toLowerCase();
return _allowedHosts.any((allowed) =>
host == allowed || host.endsWith('.$allowed'));
}
}
白名单在三个位置都要生效,缺一不可:
- 首次加载前:后端返回的 URL 校验一次(防后端配置错误)。
- 导航拦截(
NavigationDelegate.onNavigationRequest):H5 内部跳转到非白名单域名一律NavigationDecision.prevent,并记一条埋点。 - JSBridge 消息处理时:每条消息都校验当前页面的 host(见下文「来源校验」)。
endsWith('.$allowed') 而不是 contains:contains 会让 f6.example.com.evil.com 通过校验,这是白名单实现里最经典的一个洞。
非白名单链接(比如 H5 里的外部帮助文档)不是静默阻止,而是弹确认框后用系统浏览器打开,避免用户点了没反应以为坏了。
JSBridge 协议
通道与消息格式
只开一个 JavaScript Channel,所有能力走同一个通道分发。开多个 channel(每个能力一个)会让来源校验、日志、错误处理各写一遍。
controller.addJavaScriptChannel(
'ContiBridge',
onMessageReceived: (message) => _bridge.handle(message.message),
);
H5 侧调用:
// 由 App 在页面加载完成后注入的一小段 JS 提供(见下文「JS 侧胶水」)
const result = await window.ContiBridge.call('scan', { mode: 'barcode' });
请求(H5 → App):
{ "id": "c8f1-...", "method": "scan", "params": { "mode": "barcode" } }
回包(App → H5):
{ "id": "c8f1-...", "ok": true, "data": { "value": "6901234567892", "format": "EAN_13" } }
{ "id": "c8f1-...", "ok": false, "error": { "code": "PERMISSION_DENIED", "message": "未授予相机权限" } }
主动事件(App → H5,无 id):
{ "event": "storeChanged", "payload": { "storeId": 7 } }
约定:
id由 H5 侧生成并原样回传,App 不生成——这样 H5 侧的 Promise 映射表完全由它自己管理。- 所有回包都是异步的,即使是同步能力(如
getStoreContext)。统一异步避免 H5 侧写两套调用方式。 error.code是稳定的字符串枚举,不是数字,也不透传原生错误码。H5 侧按 code 分支处理,message只用于展示。- 未知
method返回{ code: "UNSUPPORTED_METHOD" }而不是静默忽略——H5 版本比 App 新时能明确知道"这个 App 版本不支持这个能力",可以降级而不是卡死。
能力清单(PRD §7.4)
| method | 说明 | 底层 | 备注 |
|---|---|---|---|
scan |
打开扫码 | native_scan |
params.mode: barcode/vin/plate(见 07) |
camera |
打开相机拍照 | native_media |
返回压缩后的本地路径 |
pickImage |
打开相册 | native_media |
支持多选,params.maxCount |
uploadFile |
上传图片/文件 | core_network |
带进度事件,见下文 |
dial |
调起拨号 | native_device |
ACTION_DIAL/tel:,不直接拨出 |
closePage |
关闭当前 H5 页 | core_router |
等价于 context.pop() |
goBack |
H5 内返回上一页 | WebView | 无历史时降级为 closePage |
refresh |
刷新页面 | WebView | |
getAuthState |
获取登录态 / 触发换票 | core_auth |
不返回 token 明文,见安全约定 |
getStoreContext |
获取当前门店上下文 | core_auth |
返回 storeId/storeCode/orgId/roleCode |
toast / dialog / loading |
弹出提示 | core_ui |
用原生控件,保证与 App 其他页面视觉一致 |
navigate |
跳转 App 原生页面 | core_router |
params.route 必须是预定义的路由白名单,不接受任意路径 |
setTitle |
设置导航栏标题 | core_ui |
与自动的 title 同步互补 |
navigate 的路由白名单和 04-routing.md 的「后端动态菜单 → 本地路由」用同一张 menuRouteMap——不允许 H5 拼一个任意路由字符串跳过去(那等于把 App 的所有内部页面都暴露给了 H5)。
来源校验(PRD §7.6)
JavaScript Channel 会注入到 WebView 的所有 frame,包括 iframe。 如果 F6 页面里嵌了第三方 iframe,那个 iframe 里的脚本也能调 ContiBridge。所以每条消息进来都要校验:
Future<void> handle(String raw) async {
// 1. 当前页面必须在白名单内
final current = await _controller.currentUrl();
if (current == null || !_urlGuard.isAllowed(Uri.parse(current))) {
_logger.w('[bridge] 拒绝来自非白名单页面的调用: $current');
return; // 静默丢弃,不回包——不给探测者任何反馈
}
// 2. 解析必须容错:H5 传了畸形 JSON 不能让 App 崩
final Map<String, dynamic> req;
try {
req = jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return _logger.w('[bridge] 无法解析的消息');
}
final id = req['id'] as String?;
final method = req['method'] as String?;
if (id == null || method == null) return;
final handler = _handlers[method];
if (handler == null) {
return _reply(id, error: const BridgeError('UNSUPPORTED_METHOD', '当前 App 版本不支持该能力'));
}
try {
_reply(id, data: await handler(req['params'] as Map<String, dynamic>? ?? const {}));
} on AppException catch (e) {
_reply(id, error: BridgeError(e.bridgeCode, e.message));
} catch (e, st) {
_logger.e('[bridge] $method 未预期异常', error: e, stackTrace: st);
_reply(id, error: const BridgeError('INTERNAL_ERROR', '操作失败,请重试'));
}
}
currentUrl()返回的是主 frame 的 URL,所以这个校验能挡住"整页被导航到恶意站点后调 bridge",但挡不住"白名单页面内的恶意 iframe"。后者的正确解法是不让 F6 页面嵌不受信的 iframe(协议层面约定),以及在导航拦截里限制 iframe 加载的域名。这个限制要在与 F6 的接口评审里明确。
其他安全约定
getAuthState不返回 token 明文(PRD §7.6:"H5 页面不得直接保存 APP 明文 Token")。它返回的是{ loggedIn: true, ticketRefreshed: true }这类状态,H5 需要新票据时由 App 重新换票并loadRequest新 URL,票据始终在 URL 参数里由后端控制,不经 bridge 传递。- H5 侧的所有输入都当作不可信:
params里的路径、路由、URL 一律校验后再用。特别是uploadFile的文件路径,必须限制在 App 沙盒内的临时目录,否则 H5 可以让 App 上传任意本地文件。 - 供应商错误不透传:F6 返回的原始错误信息转换成用户能懂的提示(PRD §7.6),原始信息只进日志。
JS 侧胶水
window.ContiBridge 只是一个原始的 postMessage 通道,H5 侧直接用很难写。App 在 onPageFinished 时注入一段封装,把它包成 Promise:
const _bridgeShim = r'''
(function () {
if (window.__contiBridgeReady) return;
const pending = new Map();
window.__contiBridgeCallback = function (resp) {
const p = pending.get(resp.id);
if (!p) return;
pending.delete(resp.id);
resp.ok ? p.resolve(resp.data) : p.reject(resp.error);
};
window.__contiBridgeEvent = function (evt) {
window.dispatchEvent(new CustomEvent('conti:' + evt.event, { detail: evt.payload }));
};
const raw = window.ContiBridge;
window.ContiBridge = {
call: function (method, params) {
const id = String(Date.now()) + Math.random().toString(36).slice(2);
return new Promise(function (resolve, reject) {
pending.set(id, { resolve: resolve, reject: reject });
raw.postMessage(JSON.stringify({ id: id, method: method, params: params || {} }));
});
},
};
window.__contiBridgeReady = true;
})();
''';
注入时机是 onPageFinished,不是 onPageStarted——onPageStarted 时 H5 的脚本可能还没执行完,重复注入或时序错乱。同时 __contiBridgeReady 做幂等保护,因为 SPA 内部路由变化可能触发多次回调。
H5 侧要处理"bridge 还没就绪"的情况(比如页面脚本跑得比注入早),约定 H5 等待 window.__contiBridgeReady 或监听一个 conti:ready 事件。这条要写进给 F6 的接入文档。
生命周期管理(PRD §7.5)
| 场景 | 处理 |
|---|---|
| 标题同步 | onPageFinished 后读 document.title 写入导航栏;setTitle bridge 调用优先级更高 |
| 返回 vs 关闭 | 导航栏同时有「返回」和「关闭」。返回:有 H5 历史则 goBack(),无历史则退出容器。关闭:直接退出容器,不管 H5 历史 |
| Android 物理返回键 | 与「返回」按钮同语义。必须拦截,否则一次返回直接退出整个 H5,用户填了一半的表单就没了 |
| 缓存策略 | 默认走 WebView 的 HTTP 缓存(F6 的静态资源应带 Cache-Control)。不做 App 侧的离线包——首版没有这个必要,且离线包会引入版本管理复杂度 |
| 票据过期 | 见下文 |
| 白屏/超时 | 见下文 |
| 上传中断 | 见下文 |
| 门店切换 / 登出 | 见下文 |
票据过期后重新换票
票据是短时的(F6 侧决定,通常几分钟到几十分钟)。两种触发路径:
- H5 主动发现:F6 页面收到票据失效的响应,调
getAuthState请求刷新 → App 重新调/h5/launch拿新 URL →loadRequest新 URL。 - App 预判:进入前台时若距离上次换票已超过
ttl * 0.8,主动换票并 reload。
不要在票据过期时静默 reload——用户正在填表单,reload 会丢数据。正确做法是弹一个"登录信息已过期,需要重新加载页面"的确认框,让用户决定。如果 H5 侧能保存草稿就更好(这一项要和 F6 对齐)。
白屏、超时、网络失败兜底
WebView 加载失败时用户看到的是一片空白,没有任何提示——这是 H5 容器体验最差的一类问题,必须显式处理:
NavigationDelegate(
onPageStarted: (_) => _startWatchdog(const Duration(seconds: 15)),
onPageFinished: (_) { _cancelWatchdog(); _injectShim(); },
onWebResourceError: (error) {
// 只处理主文档的错误,子资源(某张图、某个 JS)失败不该整页报错
if (!error.isForMainFrame!) return;
_showErrorState(error);
},
onHttpError: (error) {
if (error.response?.statusCode == 404) _showErrorState(...);
},
)
- 15 秒看门狗:
onPageStarted后 15 秒还没onPageFinished就展示"加载超时,请重试"。WebView 在某些网络状况下既不成功也不报错,只有超时能兜住。 - 错误页给「重试」和「返回」两个按钮,重试重新走完整的换票流程(票据可能已经过期了),不是简单
reload()。 - 每次白屏/超时都上报埋点(
h5_failed,带target、错误码、耗时、traceId),见 13-observability-analytics.md。这一类失败后端完全看不到——换票请求是成功的,页面加载失败发生在 WebView 内部,所以它必须由客户端报。这是 H5 链路健康度最重要的指标。
上传中断与重新提交
uploadFile 是耗时最长、最容易被打断的桥接能力(切后台、网络切换、用户误触返回)。约定:
- 上传期间拦截返回和关闭,弹确认框「上传未完成,确定要离开吗?」。
- 上传进度通过主动事件推给 H5(
{ event: "uploadProgress", payload: { taskId, sent, total } }),让 H5 自己画进度条——比 App 弹一个盖住页面的 loading 体验好。 - 上传失败的回包里带
taskId,H5 可以用同一个taskId重试,避免重复上传已成功的部分。 - 具体上传实现(压缩、超时、单张重传)复用 05-networking.md 的
ApiClient.upload,core_webview不自己写一套。
门店切换与登出时的会话失效
PRD §7.5 的默认策略是硬要求:
- 门店切换后,当前 H5 页面必须失效并提示用户重新进入。
- 用户退出登录后,所有 H5 会话必须同步失效。
// packages/core_webview/lib/src/webview_session.dart
class WebViewSession {
/// 门店切换 / 登出时由会话编排调用(见 11-store-context-and-session.md)
Future<void> invalidateAll({required bool clearCookies}) async {
for (final controller in _openControllers) {
await controller.loadRequest(Uri.parse('about:blank')); // 先停掉页面,防止在途请求继续
}
if (clearCookies) {
await WebViewCookieManager().clearCookies();
await _controller.clearLocalStorage();
await _controller.clearCache();
}
_openControllers.clear();
}
}
区别:
- 门店切换:关闭已打开的 H5 页并提示"门店已切换,请重新进入",不清 Cookie(用户还是同一个人,清了会导致 F6 侧重新走一遍登录)。
- 登出:关闭所有 H5 页 + 清 Cookie / LocalStorage / Cache。不清的话下一个登录的人可能直接进到上一个人的 F6 会话——同一台门店共用设备上这是真实会发生的。
清理动作必须等待完成再让新用户登录,不能 fire-and-forget。
与 F6 的接口对齐清单
以下几项需要和 F6 侧明确约定,不对齐会在联调阶段集中爆发:
ContiBridge的 12 项能力,H5 侧如何检测可用性(__contiBridgeReady的等待方式)。- 票据过期时 F6 页面的表现(返回什么响应,是否能保存草稿)。
- F6 页面是否嵌第三方 iframe,若有需要哪些域名。
- F6 静态资源的
Cache-Control策略。 error.code枚举表(App 侧定义,F6 侧按 code 分支)。- H5 内部跳转是否会离开白名单域名。
待确认项
- 各环境的域名白名单具体值(写进
env/{flavor}.json)。 /api/v1/h5/launch的接口契约(后端侧对应bff-orchestration+webview-ticket,见 backend/05-integration-layer.md),需要与后端一起定。- 是否需要 H5 离线包(首版不做,若 F6 首屏加载慢再评估)。