Files
2026-08-17 15:29:55 +08:00

362 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 10. Embedded H5 容器与 JSBridge
## 为什么单独一篇
PRD §7 的 Embedded H5 承载了 App 最核心的几条业务链路(报价开单、施工查车、结算收银),它不是"顺带加个 WebView",而是一个有票据换取、双向桥接、生命周期管理和安全边界的完整子系统。这些内容放不进 01-09 的任何一篇,所以单独成篇。
**适用范围**Embedded H5 **仅用于承载 F6 页面**,不做通用外链容器(PRD §7.1)。任何"能不能顺便用它打开某个网页"的需求,默认答案是不能。
## 决策
| 项 | 决策 |
|---|---|
| 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 的 `<input type="file">`)有官方解法,用平台特定 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.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)
```
```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<String> _allowedHosts; // 来自 env/{flavor}.json,各环境不同
bool isAllowed(Uri uri) {
if (uri.scheme != 'https') return false; // 只允许 HTTPSPRD §7.6
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.4
| 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 §7.6
**JavaScript Channel 会注入到 WebView 的所有 frame,包括 iframe。** 如果 F6 页面里嵌了第三方 iframe,那个 iframe 里的脚本也能调 `ContiBridge`。所以每条消息进来都要校验:
```dart
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:
```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.5)
| 场景 | 处理 |
|---|---|
| **标题同步** | `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.5 的默认策略是硬要求:
- **门店切换后,当前 H5 页面必须失效并提示用户重新进入。**
- **用户退出登录后,所有 H5 会话必须同步失效。**
```dart
// 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 侧明确约定,不对齐会在联调阶段集中爆发:
1. `ContiBridge` 的 12 项能力,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](../../conti-backend/docs/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 MASVSWebView 安全](https://mas.owasp.org/MASVS/)
- [PRD §7 Embedded H5 接入规范](../../conti-docs/Architecture-Diagram/202606-Continental-Retail-APP-PRD.md)