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

19 KiB
Raw Permalink Blame History

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_authnative_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;   // 只允许 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') 而不是 containscontains 会让 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 } }

约定:

  • idH5 侧生成并原样回传,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 侧决定,通常几分钟到几十分钟)。两种触发路径:

  1. H5 主动发现F6 页面收到票据失效的响应,调 getAuthState 请求刷新 → App 重新调 /h5/launch 拿新 URL → loadRequest 新 URL。
  2. 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 体验好。
  • 上传失败的回包里带 taskIdH5 可以用同一个 taskId 重试,避免重复上传已成功的部分。
  • 具体上传实现(压缩、超时、单张重传)复用 05-networking.mdApiClient.uploadcore_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 侧明确约定,不对齐会在联调阶段集中爆发:

  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),需要与后端一起定。
  • 是否需要 H5 离线包(首版不做,若 F6 首屏加载慢再评估)。

参考链接