返回列表

2026年7月22日 · 约 12 分钟读完

Flutter 宿主与 Vue H5:从 Bridge 到合同的一次混合开发实践

记录一次 Flutter 宿主、合同 SDK 和 Vue H5 的混合开发实践:怎样划分三端职责,并处理支付、返回、生命周期、启动和更新。

从一条 JavaScript Channel 开始

最近,我把一个 Flutter App 的主要业务页面迁到了 Vue H5。Flutter 继续负责 WebView 和原生能力,页面、路由、接口状态都交给 H5。

刚开始时,我觉得这件事不会太复杂。H5 发一段 JSON,Flutter 根据 action 调插件,再把结果回传,基本就能跑起来。

switch (message['action']) {
  case 'getDeviceInfo':
    return getDeviceInfo();
  case 'buy':
    return buy(message['productId']);
  case 'save':
    return saveToGallery(message['url']);
}

第一个版本确实很快。但功能一多,问题也跟着来了:

  • 新 H5 调用了旧宿主没有的接口,直到运行时才报错。
  • 用户取消购买后,有时被当成失败,有时一直停在处理中。
  • 页面刷新了,Flutter 还在等待旧页面的回调。
  • Android 按返回键,H5 明明有上一级,整个 App 却直接退出。
  • 宿主和 H5 都能购买时,两边的会员状态开始互相影响。

这时我才意识到,难点不是把消息传过去,而是让两个独立运行的系统长期说同一种“语言”。

Bridge 解决的是“消息怎么过去”,合同解决的是“过去以后双方应该怎么理解”。

开发桌面上的 Flutter 宿主、合同 SDK 与 Vue H5 混合架构草图
Flutter 宿主、合同 SDK 与 Vue H5,不是三个独立项目,而是一条完整的调用链。

我最后把它拆成了三层

调整后的结构不复杂:Flutter 宿主、合同 SDK、Vue H5,各自只负责一类事情。

业务体验 Vue H5 页面、路由、接口和业务状态
沟通规则 合同 SDK 会话、消息格式和能力清单
平台能力 Flutter 宿主 商店、权限、设备和系统接口
遇到的问题由谁处理原因
页面怎么跳转、错误怎么展示H5它最了解当前业务状态
一条请求是什么格式SDK双方必须遵守同一规则
怎样拉起系统购买宿主只有宿主能调用平台商店
购买后加多少权益H5 服务端这是业务规则,不是平台能力
当前宿主支持哪些功能握手结果运行时事实比版本号推测可靠

我给 SDK 定了一个很重要的限制:它不认识具体页面,不认识具体商品,也不替任何一端做业务决定。

它只负责让 H5 能够问一句:“宿主有没有这个能力?”然后把请求交给宿主真正注册的实现。

把 Bridge 改成一份合同

先握手,再调用

之前,H5 默认认为所有宿主接口都存在。这个假设在开发机上没问题,一旦 H5 和 App 分开发布,就很容易遇到新旧版本错位。

我后来增加了一个握手过程:

1页面加载等待 Bridge 就绪
2发起握手带上协议和 SDK 版本
3宿主回应返回会话与能力清单
4按需调用不支持时主动降级

H5 侧只需要这样使用:

const hybrid = window.HybridSdk.create({ sdkVersion: '1.0.0' })

await hybrid.ready()

if (hybrid.supports('media.library.save')) {
  await hybrid.invoke('media.library.save', payload)
}

宿主在握手中返回当前真正注册的能力,还会附带平台、App 版本、语言和主题等基础信息。

这样,新 H5 遇到旧宿主时,不需要猜版本号,也不用先调用再等异常。能力存在就用,不存在就隐藏入口或回退到其他方案。

页面刷新时,会话也会失效。新页面必须重新握手,旧请求即使迟到,也不能继续操作当前页面。

所有消息使用同一种结构

只做能力清单还不够。请求多起来以后,我还需要知道响应属于哪一次调用,以及一次失败究竟是什么失败。

所以请求、响应和事件都使用同一种消息外壳。

{
  "kind": "request",
  "protocolVersion": "1.0",
  "id": "request-42",
  "sessionId": "session-current",
  "capability": "store.product.query",
  "payload": {
    "productIds": ["example.credit.small"]
  }
}

这里真正有用的不是 JSON 长什么样,而是几条固定规则:

字段解决的问题
id并发请求能够找到各自的响应
sessionId页面刷新后,旧页面不能继续调用
protocolVersion消息格式将来可以升级
capability每项能力有清楚、稳定的名字
payload参数始终是对象,不再临时变形

成功只返回 data,失败只返回 error

{
  "kind": "response",
  "id": "request-42",
  "success": false,
  "error": {
    "code": "UNSUPPORTED",
    "message": "This capability is not available."
  }
}

错误码负责程序判断,文案只辅助排查。H5 不再通过匹配错误文字决定下一步。

App 前后台变化这类信息,则使用 event,因为它不是某次 H5 请求的结果。例如宿主发送 app.lifecycle.changed,并在数据中带上当前状态 resumed

这个改动看起来只是统一字段,但它把原来散落在各个 action 里的约定集中到了一处。

SDK 不实现业务,只提供插槽

Flutter 侧的每项能力都实现同一个接口:

abstract interface class HybridCapability {
  String get name;
  String get version;

  Future<Map<String, Object?>> invoke(
    HybridCapabilityRequest request,
  );
}

宿主再把当前产品真正支持的实现装进去:

final registry = HybridCapabilityRegistry([
  AppInfoCapability(appInfoProvider, appId: businessAppId),
  BrowserExternalOpenCapability(externalBrowser),
  AppPermissionRequestCapability(permissionService),
  StoreProductQueryCapability(storeService),
  StorePurchaseStartCapability(storeService),
]);

这让我可以按 App 选择能力,而不是让 SDK 强制依赖一整套权限、推送、评分和支付插件。

例如宿主只允许 H5 保存结果到相册,那么它可以实现“写入相册”,其他相册或相机请求明确返回 unsupported。返回不支持比伪造成功更重要,因为 H5 可以据此给用户正确反馈。

文档之外,再加一份机器可读合同

我一开始只写了 Markdown 接入文档。后来发现,文档适合解释“为什么”,却很难保证每个人都记住字段是否必填、字符串能有多长、枚举到底有哪些。

现在每项能力还会有一份 JSON Schema:

{
  "name": "store.purchase.start",
  "version": "1.1.0",
  "requestSchema": {
    "type": "object",
    "required": ["productId", "productType"],
    "properties": {
      "productId": { "type": "string", "minLength": 1 },
      "productType": {
        "enum": ["consumable", "nonConsumable", "subscription"]
      }
    }
  }
}

开发顺序也变得清楚:

1写合同
2实现 SDK 能力
3宿主接平台
4H5 封装调用
5补边界测试

这套流程没有让开发变慢。相反,参数先说清楚以后,联调时少了很多“我以为你会这样返回”。

支付是这次最难理顺的部分

支付同时经过 H5 页面、Flutter、App Store 或 Google Play、业务服务端。只要其中一层多做了一点事,边界就会变得模糊。

不再根据商品 ID 猜类型

最初的接口只有 buy(productId)。但商品 ID 本身无法说明这是消耗品、一次性商品还是订阅。用名称前缀推断,迟早会遇到例外。

后来我要求 H5 明确传交易类型:

合同取值表示什么常见场景
consumable可以重复购买和消耗积分、虚拟道具
nonConsumable一次购买,长期拥有永久解锁功能
subscription按周期续订周订阅、年订阅

这个值只告诉宿主该走哪种购买流程,不会改变商店后台的商品配置。商品是否真实存在,仍然由平台商店决定。

价格也不再使用 H5 配置里的静态数字。H5 先把商品 ID 交给宿主查询,页面最终展示商店返回的本地价格和货币。

平台成功后,还不能马上发权益

我把一次购买拆成了四段:

H5发起购买商品 ID + 类型
宿主平台交易返回交易与票据
服务端验证票据确认后发放权益
宿主结束交易验证成功后 finish

流程图里的顺序不能打乱:先完成平台购买,再让服务端验证,验证通过后才能结束交易。用户取消、平台失败、验证失败和完成交易失败是四种不同情况。它们不应该都变成一句“购买失败”,更不能在验证前直接增加余额。

宿主购买和 H5 购买按“谁发起”隔离

项目里还有一个更容易忽略的问题:宿主自己也有购买入口。

我最初想按商品 ID 区分来源,后来发现恢复购买和平台回调不一定符合这套静态列表。真正稳定的信息不是“买了什么”,而是“谁发起了这次行为”。因此每次商店操作都会记录归属:宿主发起,或者 H5 发起。

宿主与 H5 的购买、恢复、完成交易会进入同一个串行队列:

当前行为可以调用平台商店可以修改宿主本地权益
宿主发起
H5 发起

H5 购买只是借用了宿主的平台能力,结果仍回到 H5 的服务端和状态中。宿主购买则完全留在宿主自己的流程里。

这一步解决了我在混合支付里最担心的问题:共享同一个底层购买流,却意外共享了两套业务状态。

宿主与 H5 共用底层购买能力并将结果分别交给各自状态空间的示意图
宿主操作与 H5 操作共用底层购买能力,但状态更新和结果归属彼此隔离。

让 WebView 真正像 App 的一部分

页面能打开以后,我花了不少时间处理一些看似零碎、实际上很影响体验的问题。

Android 返回键先问 H5

Android 用户按返回键时,Flutter 并不知道 H5 当前有没有弹窗或二级页面。直接退出当然最简单,但体验很差。

现在的返回流程是:

Android按下返回
宿主询问 H5能否消费这次返回
H5弹窗 / 路由 / Tab按优先级处理
根页面再次返回退出仅 H5 未处理时

这是一次宿主主动发请求、H5 返回结果的调用。做到这里以后,Bridge 才真正变成双向,而不是只有 H5 能调用 Flutter。

H5 路由也同步写入 window.history,这样页面按钮、浏览器历史和系统返回最终都落到同一份路由状态。

生命周期以宿主事件为准

普通网页会监听 document.visibilityState。放进 WebView 后,我发现它不能完整代表 App 的前后台状态。

Flutter 本来就知道 App 何时进入后台,因此会发送 app.lifecycle.changed

状态H5 怎么理解
resumedApp 回到前台,可以恢复检查和刷新
inactive正在失去焦点,先避免启动新任务
paused已进入后台,暂停轮询和定时检查
detached页面即将脱离宿主,清理当前监听

H5 收到宿主生命周期后,用它控制版本检查和定时任务;只有本地浏览器开发时,才回退到 visibilitychange

这个调整很小,却让 H5 不再像一个独立浏览器页面,而是开始遵守 App 的真实运行状态。

启动体验是逐步改出来的

混合页面第一次打开,要经过 WebView、静态资源、Vue、握手、设备信息、业务会话和首页数据。最开始我把它们串行等待,结果就是一个全屏 Loading 停很久。

后来我把启动拆成了两条线:

用户先看到的内容
Vue立即挂载
页面显示骨架
局部区域逐步填充
界面先出现,连接和数据不再阻塞第一帧
后台同时进行的初始化
Bridge完成握手
宿主读取环境
接口建立会话

几个优化对体感帮助很明显:

  • Vue 先渲染外壳,不让初始化挡住第一帧。
  • 首页区域使用自己的 Loading,不覆盖整个 App。
  • 多个调用共享同一个正在进行的会话请求,避免重复初始化。
  • WebView 握手失败由宿主提供重试;握手成功后的业务错误由 H5 处理。
  • 进入 H5 后,不初始化无关的宿主页面、模型和状态。
  • H5 稳定连接后,宿主停止只用于启动兜底的监听器。

这里最重要的不是把 Loading 动画做得更精致,而是减少用户真正需要等待的串行步骤。

H5 发布以后,旧页面怎么办

H5 可以独立发布,但已经打开的 WebView 不会自动知道线上发生了变化。

我在构建时额外生成了一份很小的版本文件,内容只有类似 {"version":"build-abcdef12"} 的当前构建标识。

页面每隔几分钟检查一次。发现线上版本不同后,等用户离开支付、登录或任务提交等关键流程,再要求刷新。

检查时机是否检查
App 正在前台
App 已进入后台
从后台重新回来立即检查一次
正在进行关键操作暂缓提示
已发现新版本等待用户刷新,不再重复请求

刷新时在 URL 上带新版本参数,避免入口文件继续命中旧缓存。它没有 Service Worker 那么复杂,但已经足够解决长期停留在旧 H5 的问题。

安全边界不能靠代码压缩

正式 H5 可以关闭 Source Map、删除调试输出、压缩变量名,也可以要求必须在宿主里完成握手后才继续初始化。

这些能减少误用,却不能保护真正的业务安全。

我最后保留的边界是:

  • 敏感能力只对受信任的网页来源开放。
  • WebView 跳到其他来源后,旧会话立即失效。
  • 外部链接只接受允许的协议,必要时再限制域名。
  • SDK 对参数类型、长度和枚举做统一校验。
  • 权益、余额、票据验证和限流仍由服务端决定。
  • token、支付票据和用户数据不进入普通日志或分析事件。

这里的“网页来源”指协议、域名和端口的组合。允许 WebView 打开一个页面,不代表这个页面就应该拥有支付、权限或设备信息能力。

本地开发则可以保留轻量降级:没有宿主时使用浏览器下载、测试设备信息和本地代理。它只是为了方便调页面,正式构建不会把这些降级当成真实平台能力。

我现在会重点测试这些边界

页面测试当然有用,但这套结构更容易坏在三层交界处。

沟通规则 合同 SDK
  • 未注册能力明确返回不支持
  • 敏感能力要求可信网页来源
  • 交易类型和错误码原样传递
平台边界 Flutter 宿主
  • H5 购买不会修改宿主权益
  • Android 返回先交给 H5
  • 进入 H5 时跳过无关初始化
业务体验 Vue H5
  • 初始化请求只合并为一次
  • 缺少能力时可以正常降级
  • 优先使用宿主生命周期
  • 更新提示避开关键操作

这些测试不关心某个按钮是什么颜色,它们保证的是后续继续加功能时,三层不会重新黏在一起。

回头看这次调整

这次重构最开始只是为了让 Flutter 和 Vue 能互相调用,最后真正解决的却是边界问题。

我踩过的几个坑,现在可以归纳成六条:

1先握手公布真实能力,不靠版本号猜测
2统一消息请求、响应和错误遵守同一结构
3明确类型交易类型由调用方直接表达
4隔离行为共享商店能力,不共享业务状态
5接回系统返回键和生命周期以宿主为准
6减少等待先拆并行流程,再考虑 Loading

现在我更愿意把 Hybrid 理解成两个运行环境之间的合作,而不是“Flutter 外面套一个壳,里面放个网页”。

H5 可以独立更新页面和业务,宿主可以独立升级平台实现,合同 SDK 则保证双方仍然理解同一种请求。只要这三部分没有越过各自边界,混合开发才会从一个临时方案,慢慢变成可以长期维护的工程结构。

H5 负责业务体验,宿主负责平台能力,合同 SDK 负责让双方始终说同一种语言。