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,各自只负责一类事情。
| 遇到的问题 | 由谁处理 | 原因 |
|---|---|---|
| 页面怎么跳转、错误怎么展示 | H5 | 它最了解当前业务状态 |
| 一条请求是什么格式 | SDK | 双方必须遵守同一规则 |
| 怎样拉起系统购买 | 宿主 | 只有宿主能调用平台商店 |
| 购买后加多少权益 | H5 服务端 | 这是业务规则,不是平台能力 |
| 当前宿主支持哪些功能 | 握手结果 | 运行时事实比版本号推测可靠 |
我给 SDK 定了一个很重要的限制:它不认识具体页面,不认识具体商品,也不替任何一端做业务决定。
它只负责让 H5 能够问一句:“宿主有没有这个能力?”然后把请求交给宿主真正注册的实现。
把 Bridge 改成一份合同
先握手,再调用
之前,H5 默认认为所有宿主接口都存在。这个假设在开发机上没问题,一旦 H5 和 App 分开发布,就很容易遇到新旧版本错位。
我后来增加了一个握手过程:
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"]
}
}
}
}
开发顺序也变得清楚:
这套流程没有让开发变慢。相反,参数先说清楚以后,联调时少了很多“我以为你会这样返回”。
支付是这次最难理顺的部分
支付同时经过 H5 页面、Flutter、App Store 或 Google Play、业务服务端。只要其中一层多做了一点事,边界就会变得模糊。
不再根据商品 ID 猜类型
最初的接口只有 buy(productId)。但商品 ID 本身无法说明这是消耗品、一次性商品还是订阅。用名称前缀推断,迟早会遇到例外。
后来我要求 H5 明确传交易类型:
| 合同取值 | 表示什么 | 常见场景 |
|---|---|---|
consumable | 可以重复购买和消耗 | 积分、虚拟道具 |
nonConsumable | 一次购买,长期拥有 | 永久解锁功能 |
subscription | 按周期续订 | 周订阅、年订阅 |
这个值只告诉宿主该走哪种购买流程,不会改变商店后台的商品配置。商品是否真实存在,仍然由平台商店决定。
价格也不再使用 H5 配置里的静态数字。H5 先把商品 ID 交给宿主查询,页面最终展示商店返回的本地价格和货币。
平台成功后,还不能马上发权益
我把一次购买拆成了四段:
流程图里的顺序不能打乱:先完成平台购买,再让服务端验证,验证通过后才能结束交易。用户取消、平台失败、验证失败和完成交易失败是四种不同情况。它们不应该都变成一句“购买失败”,更不能在验证前直接增加余额。
宿主购买和 H5 购买按“谁发起”隔离
项目里还有一个更容易忽略的问题:宿主自己也有购买入口。
我最初想按商品 ID 区分来源,后来发现恢复购买和平台回调不一定符合这套静态列表。真正稳定的信息不是“买了什么”,而是“谁发起了这次行为”。因此每次商店操作都会记录归属:宿主发起,或者 H5 发起。
宿主与 H5 的购买、恢复、完成交易会进入同一个串行队列:
| 当前行为 | 可以调用平台商店 | 可以修改宿主本地权益 |
|---|---|---|
| 宿主发起 | 是 | 是 |
| H5 发起 | 是 | 否 |
H5 购买只是借用了宿主的平台能力,结果仍回到 H5 的服务端和状态中。宿主购买则完全留在宿主自己的流程里。
这一步解决了我在混合支付里最担心的问题:共享同一个底层购买流,却意外共享了两套业务状态。
让 WebView 真正像 App 的一部分
页面能打开以后,我花了不少时间处理一些看似零碎、实际上很影响体验的问题。
Android 返回键先问 H5
Android 用户按返回键时,Flutter 并不知道 H5 当前有没有弹窗或二级页面。直接退出当然最简单,但体验很差。
现在的返回流程是:
这是一次宿主主动发请求、H5 返回结果的调用。做到这里以后,Bridge 才真正变成双向,而不是只有 H5 能调用 Flutter。
H5 路由也同步写入 window.history,这样页面按钮、浏览器历史和系统返回最终都落到同一份路由状态。
生命周期以宿主事件为准
普通网页会监听 document.visibilityState。放进 WebView 后,我发现它不能完整代表 App 的前后台状态。
Flutter 本来就知道 App 何时进入后台,因此会发送 app.lifecycle.changed:
| 状态 | H5 怎么理解 |
|---|---|
resumed | App 回到前台,可以恢复检查和刷新 |
inactive | 正在失去焦点,先避免启动新任务 |
paused | 已进入后台,暂停轮询和定时检查 |
detached | 页面即将脱离宿主,清理当前监听 |
H5 收到宿主生命周期后,用它控制版本检查和定时任务;只有本地浏览器开发时,才回退到 visibilitychange。
这个调整很小,却让 H5 不再像一个独立浏览器页面,而是开始遵守 App 的真实运行状态。
启动体验是逐步改出来的
混合页面第一次打开,要经过 WebView、静态资源、Vue、握手、设备信息、业务会话和首页数据。最开始我把它们串行等待,结果就是一个全屏 Loading 停很久。
后来我把启动拆成了两条线:
几个优化对体感帮助很明显:
- 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 打开一个页面,不代表这个页面就应该拥有支付、权限或设备信息能力。
本地开发则可以保留轻量降级:没有宿主时使用浏览器下载、测试设备信息和本地代理。它只是为了方便调页面,正式构建不会把这些降级当成真实平台能力。
我现在会重点测试这些边界
页面测试当然有用,但这套结构更容易坏在三层交界处。
- 未注册能力明确返回不支持
- 敏感能力要求可信网页来源
- 交易类型和错误码原样传递
- H5 购买不会修改宿主权益
- Android 返回先交给 H5
- 进入 H5 时跳过无关初始化
- 初始化请求只合并为一次
- 缺少能力时可以正常降级
- 优先使用宿主生命周期
- 更新提示避开关键操作
这些测试不关心某个按钮是什么颜色,它们保证的是后续继续加功能时,三层不会重新黏在一起。
回头看这次调整
这次重构最开始只是为了让 Flutter 和 Vue 能互相调用,最后真正解决的却是边界问题。
我踩过的几个坑,现在可以归纳成六条:
现在我更愿意把 Hybrid 理解成两个运行环境之间的合作,而不是“Flutter 外面套一个壳,里面放个网页”。
H5 可以独立更新页面和业务,宿主可以独立升级平台实现,合同 SDK 则保证双方仍然理解同一种请求。只要这三部分没有越过各自边界,混合开发才会从一个临时方案,慢慢变成可以长期维护的工程结构。
H5 负责业务体验,宿主负责平台能力,合同 SDK 负责让双方始终说同一种语言。