# 10. Embedded H5 容器与 JSBridge ## 为什么单独一篇 PRD 第 7.3 节(F6 集成边界)定义的 Embedded H5 承载了 App 最核心的几条业务链路(报价开单、施工查车、结算收银),它不是"顺带加个 WebView",而是一个有票据换取、双向桥接、生命周期管理和安全边界的完整子系统。这些内容放不进 01-09 的任何一篇,所以单独成篇。 **适用范围**:Embedded H5 **仅用于承载 F6 页面**,不做通用外链容器(PRD 第 7.3 节)。任何"能不能顺便用它打开某个网页"的需求,默认答案是不能。 ## 决策 | 项 | 决策 | |---|---| | WebView 库 | **[webview_flutter](https://pub.dev/packages/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 的 ``)有官方解法,用平台特定 controller 即可: ```dart 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.3 节的接入流程: ``` 用户点击功能入口(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) ``` ```dart // 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](./05-networking.md)),后端把它带进 H5 URL,这样"用户在 H5 里遇到问题"能一路追到 App 侧的请求。 ## 域名白名单 ```dart // packages/core_webview/lib/src/url_guard.dart class UrlGuard { const UrlGuard(this._allowedHosts); final Set _allowedHosts; // 来自 env/{flavor}.json,各环境不同 bool isAllowed(Uri uri) { if (uri.scheme != 'https') return false; // 只允许 HTTPS(PRD REQ-NFR-016) final host = uri.host.toLowerCase(); return _allowedHosts.any((allowed) => host == allowed || host.endsWith('.$allowed')); } } ``` 白名单在**三个位置**都要生效,缺一不可: 1. **首次加载前**:后端返回的 URL 校验一次(防后端配置错误)。 2. **导航拦截**(`NavigationDelegate.onNavigationRequest`):H5 内部跳转到非白名单域名一律 `NavigationDecision.prevent`,并记一条埋点。 3. **JSBridge 消息处理时**:每条消息都校验当前页面的 host(见下文「来源校验」)。 `endsWith('.$allowed')` 而不是 `contains`:`contains` 会让 `f6.example.com.evil.com` 通过校验,这是白名单实现里最经典的一个洞。 非白名单链接(比如 H5 里的外部帮助文档)不是静默阻止,而是**弹确认框后用系统浏览器打开**,避免用户点了没反应以为坏了。 ## JSBridge 协议 ### 通道与消息格式 只开**一个** JavaScript Channel,所有能力走同一个通道分发。开多个 channel(每个能力一个)会让来源校验、日志、错误处理各写一遍。 ```dart controller.addJavaScriptChannel( 'ContiBridge', onMessageReceived: (message) => _bridge.handle(message.message), ); ``` H5 侧调用: ```js // 由 App 在页面加载完成后注入的一小段 JS 提供(见下文「JS 侧胶水」) const result = await window.ContiBridge.call('scan', { mode: 'barcode' }); ``` **请求**(H5 → App): ```json { "id": "c8f1-...", "method": "scan", "params": { "mode": "barcode" } } ``` **回包**(App → H5): ```json { "id": "c8f1-...", "ok": true, "data": { "value": "6901234567892", "format": "EAN_13" } } { "id": "c8f1-...", "ok": false, "error": { "code": "PERMISSION_DENIED", "message": "未授予相机权限" } } ``` **主动事件**(App → H5,无 `id`): ```json { "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.3 节 JSBridge 能力清单) | method | 说明 | 底层 | 备注 | |---|---|---|---| | `scan` | 打开扫码 | `native_scan` | `params.mode`: `barcode`/`vin`/`plate`(见 [07](./07-native-integration.md)) | | `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 第 8.4 节 安全与合规) **JavaScript Channel 会注入到 WebView 的所有 frame,包括 iframe。** 如果 F6 页面里嵌了第三方 iframe,那个 iframe 里的脚本也能调 `ContiBridge`。所以每条消息进来都要校验: ```dart Future 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 req; try { req = jsonDecode(raw) as Map; } 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? ?? 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 REQ-NFR-018:H5 不传递 token 明文)。它返回的是 `{ loggedIn: true, ticketRefreshed: true }` 这类状态,H5 需要新票据时由 App 重新换票并 `loadRequest` 新 URL,票据始终在 URL 参数里由后端控制,不经 bridge 传递。 - **H5 侧的所有输入都当作不可信**:`params` 里的路径、路由、URL 一律校验后再用。特别是 `uploadFile` 的文件路径,必须限制在 App 沙盒内的临时目录,否则 H5 可以让 App 上传任意本地文件。 - **供应商错误不透传**:F6 返回的原始错误信息转换成用户能懂的提示(PRD REQ-NFR-013 统一错误处理),原始信息只进日志。 ### JS 侧胶水 `window.ContiBridge` 只是一个原始的 `postMessage` 通道,H5 侧直接用很难写。App 在 `onPageFinished` 时注入一段封装,把它包成 Promise: ```dart 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.3 节) | 场景 | 处理 | |---|---| | **标题同步** | `onPageFinished` 后读 `document.title` 写入导航栏;`setTitle` bridge 调用优先级更高 | | **返回 vs 关闭** | 导航栏同时有「返回」和「关闭」。返回:有 H5 历史则 `goBack()`,无历史则退出容器。关闭:直接退出容器,不管 H5 历史 | | **Android 物理返回键** | 与「返回」按钮同语义。**必须拦截**,否则一次返回直接退出整个 H5,用户填了一半的表单就没了 | | **缓存策略** | 默认走 WebView 的 HTTP 缓存(F6 的静态资源应带 `Cache-Control`)。**不做 App 侧的离线包**——首版没有这个必要,且离线包会引入版本管理复杂度 | | **票据过期** | 见下文 | | **白屏/超时** | 见下文 | | **上传中断** | 见下文 | | **门店切换 / 登出** | 见下文 | ### 票据过期后重新换票 票据是短时的(F6 侧决定,通常几分钟到几十分钟)。两种触发路径: 1. **H5 主动发现**:F6 页面收到票据失效的响应,调 `getAuthState` 请求刷新 → App 重新调 `/h5/launch` 拿新 URL → `loadRequest` 新 URL。 2. **App 预判**:进入前台时若距离上次换票已超过 `ttl * 0.8`,主动换票并 reload。 **不要在票据过期时静默 reload**——用户正在填表单,reload 会丢数据。正确做法是弹一个"登录信息已过期,需要重新加载页面"的确认框,让用户决定。如果 H5 侧能保存草稿就更好(这一项要和 F6 对齐)。 ### 白屏、超时、网络失败兜底 WebView 加载失败时用户看到的是一片空白,没有任何提示——这是 H5 容器体验最差的一类问题,必须显式处理: ```dart 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](./13-observability-analytics.md)。**这一类失败后端完全看不到**——换票请求是成功的,页面加载失败发生在 WebView 内部,所以它必须由客户端报。这是 H5 链路健康度最重要的指标。 ### 上传中断与重新提交 `uploadFile` 是耗时最长、最容易被打断的桥接能力(切后台、网络切换、用户误触返回)。约定: - 上传期间**拦截返回和关闭**,弹确认框「上传未完成,确定要离开吗?」。 - 上传进度通过主动事件推给 H5(`{ event: "uploadProgress", payload: { taskId, sent, total } }`),让 H5 自己画进度条——比 App 弹一个盖住页面的 loading 体验好。 - 上传失败的回包里带 `taskId`,H5 可以用同一个 `taskId` 重试,避免重复上传已成功的部分。 - 具体上传实现(压缩、超时、单张重传)复用 [05-networking.md](./05-networking.md) 的 `ApiClient.upload`,`core_webview` 不自己写一套。 ### 门店切换与登出时的会话失效 PRD 第 7.3 节的默认策略是硬要求: - **门店切换后,当前 H5 页面必须失效并提示用户重新进入。** - **用户退出登录后,所有 H5 会话必须同步失效。** ```dart // packages/core_webview/lib/src/webview_session.dart class WebViewSession { /// 门店切换 / 登出时由会话编排调用(见 11-store-context-and-session.md) Future 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 侧明确约定,不对齐会在联调阶段集中爆发: 1. `ContiBridge` 的 13 项能力,H5 侧如何检测可用性(`__contiBridgeReady` 的等待方式)。 2. 票据过期时 F6 页面的表现(返回什么响应,是否能保存草稿)。 3. F6 页面是否嵌第三方 iframe,若有需要哪些域名。 4. F6 静态资源的 `Cache-Control` 策略。 5. `error.code` 枚举表(App 侧定义,F6 侧按 code 分支)。 6. H5 内部跳转是否会离开白名单域名。 ## 待确认项 - 各环境的域名白名单具体值(写进 `env/{flavor}.json`)。 - `/api/v1/h5/launch` 的接口契约(后端侧对应 `bff-orchestration` + `webview-ticket`,见 [backend/05-integration-layer.md](../backend/05-integration-layer.md)),需要与后端一起定。 - 是否需要 H5 离线包(首版不做,若 F6 首屏加载慢再评估)。 ## 参考链接 - [webview_flutter | Dart package](https://pub.dev/packages/webview_flutter) - [webview_flutter: JavaScript Channel](https://pub.dev/packages/webview_flutter#javascript-channels) - [AndroidWebViewController.setOnShowFileSelector](https://pub.dev/documentation/webview_flutter_android/latest/webview_flutter_android/AndroidWebViewController/setOnShowFileSelector.html) - [OWASP MASVS:WebView 安全](https://mas.owasp.org/MASVS/) - [PRD 第 7 章 系统集成与架构边界](../prd/Continental-Retail-APP-PRD.md)