返回列表

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 负责让双方始终说同一种语言。