文章

Hybrid 架构与离线包方案

从容器接入方式到离线包的拦截、增量更新、灰度与降级链路:为什么"不发版改页面"这句承诺, 真正难的不是桥,而是资源分发。

Hybrid 架构与离线包方案

一句话概括

Hybrid 说白了就是原生壳子套一个 WebView,页面用 H5 写。它唯一的杀手级卖点是:改页面不用发版,服务端一更新就生效。

但这句话在面试里只是开场白。真正的追问是——”用户网络差的时候,你怎么让 H5 首屏跟原生一样快?“答案是离线包:把 HTML/CSS/JS 预先放到手机本地,WebView 发起请求时直接从本地返回,根本不走网络。

所以这篇不聊 JSBridge(那是另一篇的事),只聊 Hybrid 的资源分发体系:离线包怎么拦、怎么更、怎么灰、怎么降级。

核心知识点

1. H5 接入 App 的三种方式,先选对容器

方式资源在哪首屏更新适合
在线 H5CDN依赖网络,容易白屏改完即生效帮助页、协议页、活动落地页
内置包(打进 App)App 安装包内最快必须发版极少数绝对稳定的基础页
离线包(可下发)本地存储 + 平台分发接近内置包平台下发,不用发版绝大多数业务主链路

内置包和离线包经常一起用:安装包里预置一份(叫”预置包”“基础包”),保证 App 装完第一次打开就有东西;离线包平台负责后续更新。

面试口径建议是”我们用的是离线包 + 预置包兜底,在线 H5 只留给低频页面“。一上来就说”我们用 WebView 加载 URL”,等于告诉面试官你没做过优化。

2. 离线包的第一件事:把网络请求截下来

离线包的原理一句话讲完:WebView 发起的资源请求,先查本地有没有,有就直接返回,没有才走网络。难点全在”怎么截”。

Android:官方给了正规方案 WebViewAssetLoader

1
2
3
4
5
6
7
8
9
10
11
12
13
val assetLoader = WebViewAssetLoader.Builder()
    .addPathHandler("/assets/", AssetsPathHandler(this))   // 本地 assets 目录
    .addPathHandler("/offline/", MyOfflinePackageHandler()) // 离线包目录,自己实现 PathHandler
    .build()

webView.webViewClient = object : WebViewClientCompat() {
    override fun shouldInterceptRequest(
        view: WebView,
        request: WebResourceRequest
    ): WebResourceResponse? = assetLoader.shouldInterceptRequest(request.url)
}
// 命中本地资源返回文件,返回 null 则 WebView 自动回退到网络请求
webView.loadUrl("https://appassets.androidplatform.net/assets/index.html")

关于 shouldInterceptRequest,有五条官方明说(或官方论坛确认)的边界,很好记也很好用:

  • 它对 http(s):、data:、file: 等多种 scheme 都会回调,但不会对 javascript:、blob:、以及 file:///android_asset/、file:///android_res/ 的资源回调;
  • 重定向只回调最初那一次,302 之后的目标地址不会再回调;
  • 它跑在非 UI 线程,KitKat 以上还可能并发多个线程同时回调 → 里面别做重活、别碰 View 体系;
  • 拿不到 POST body,所以别指望用它来 mock 接口 POST;
  • 它看不到 Service Worker 发出的请求(Chromium 团队的确认口径)——所以 H5 页面自己再注册一个 Service Worker 做缓存,和离线包会是两套并行的资源体系,容易互相打架,线上别叠着用。

还有一条官方态度值得单独说:file:///android_asset/ 是官方不推荐的,因为它和同源策略不兼容——file:// 被视为不透明源,fetch/XHR 直接受限。于是官方给了 WebViewAssetLoader,把本地资源映射成 https://appassets.androidplatform.net/assets/... 这类虚拟域名:文件还是本地的,但源是正常的。这就是”为什么不要用 file://”的标准答案。

iOS:踩坑重灾区,三个方案都有硬伤

方案结论
WKURLSchemeHandler(iOS 11+)只能处理自定义 scheme;注册 http/https 会直接报错,大意是 “https is a URL scheme that WKWebView handles natively”
NSURLProtocolUIWebView 时代的方案。WKWebView 的网络在独立进程,请求根本不经过 App 进程的 NSURLProtocol;强行用私有 API 接还会丢 POST body,并有审核风险
本地起 HTTP Server能接到,但启动成本、端口占用、能耗都是负担

所以 iOS 上的主流做法是改 scheme:把页面及其资源统一成 myapp:// 这类自定义协议,交给 WKURLSchemeHandler 从离线包读文件。代价是自定义 scheme 下的 XHR 受同源策略限制,需要额外处理。

面试要点:Android 有官方正规方案,iOS 得自己绕。正因为两端差异这么大,成熟的离线包方案都会把”资源拦截”做成容器层的能力,让 H5 业务侧完全无感。

3. 离线包的更新链路:一次完整的分发

面试高频:”你们离线包怎么更新的?“照着这条链路说就够完整:

  1. 内置:打 App 包时把当前最新的离线包塞进安装包,并记录版本号
  2. 拉索引:App 启动后把本地各离线包的版本号上报,服务端返回一份包索引(每个包的版本、全量包地址、差分包地址、MD5)
  3. 比对:本地版本低于线上才下载;相等就什么都不做
  4. 下载:优先下增量包,失败自动回退全量包
  5. 校验:下载完先验 MD5,不对就丢弃重来(同时也防了篡改)
  6. 合并/解压:增量包用 bsdiff 这类算法和本地旧包二进制作差、patch 出新包,再解压
  7. 原子切换:新包先解压到临时目录,校验通过后整体重命名 / 切换指针,绝不能原地覆盖——覆盖到一半 App 被杀,页面就彻底坏了
  8. 保留旧版本:至少留上一版,出问题能回滚
  9. 清理:新版本稳定运行一段时间后,再删掉更早的历史版本

增量更新的三种算法,面试可以顺手提一句:

  • 文件级 diff:只比对哪些文件变了,变了的整文件下发。实现最简单、效果也够好,业务上最常用
  • 二进制 diff(bsdiff):把文件当二进制做差,省得最多,但依赖严格的版本链——每个历史版本都要生成一份差分包,维护成本高
  • 分块哈希 diff:按内容分块比对,节省量取决于块大小和变化块的分布

结论口径:九成业务用”文件级 diff + 全量兜底”就够了,别一上来就上 bsdiff。

4. 灰度、到达率与降级:面试的加分项

  • 灰度:离线包按「包 + 版本」维度发布,支持按 App 版本 / 用户 ID 哈希 / 白名单 / 百分比放量。因为离线包不走应用商店审核,灰度能力必须自己建——这也是它能快速回滚的底气。
  • 到达率:有多少活跃用户真正用上了新版本离线包。经典事故是”后端说发了 v5,线上还有 20% 的用户跑 v3”,因为这些人 App 没重启或下载一直失败。所以离线包发布必须配到达率监控,否则你以为更新了,实际没更新。
  • 三级降级(背下来):
    1. 本地离线包命中 → 直接返回,最快
    2. 本地没有 / 包损坏 → 走线上 CDN 的 fallback 地址,页面依然可用
    3. CDN 也挂了 → 兜底到 App 内置的基础包或静态降级页

降级链路的坑:fallback 里的资源必须和离线包里的资源路径一致,否则同一个 HTML 在不同来源下引用的 JS 路径对不上,就会出现”更新完白屏”的经典事故。

5. H5 侧要配合改什么

离线包不是客户端一个人能搞定的,前端有几条硬约束:

  • 资源引用一律用相对路径,别写死绝对 CDN 域名——写死了 fallback 就废了
  • 不要靠文件名 hash 强更:离线包是按版本号整体切换的,把 hash 写进 index.html 反而容易和回退版本对不上
  • 动静分离:把框架 runtime、公共库抽成业务无关的公共包,更新时业务包单独下发,省流量也省解压时间
  • 入口 HTML 必须能被容器接管,别做重定向跳转

其实你每天都在用

  • 打开某个购物 App 的活动会场页,秒开、断网也能看到骨架——那是离线包
  • 双十一前 App 悄悄更新了一波资源,你没升级 App,但会场页变了
  • 某次发版后用户反馈”页面样式还是旧的,清缓存也没用”——十有八九是离线包版本没到达
  • App 里”我的 - 设置 - 关于我们”这种页面多半是内置包,永远不更新
  • 测试同学说”我这儿是好的”,你这边还是旧页面——两个人命中的离线包版本不同
  • 弱网地铁里 H5 打不开但 App 原生页正常——离线包没下载成功,fallback 也超时了

常见误解(FAQ)

❌ 误区1:”Hybrid 慢是因为 WebView 内核性能差” 首屏时间的大头是资源下载 + JS 解析执行,不是渲染。这正是离线包存在的理由:把网络这一段砍掉,冷启动首屏能快一个数量级。渲染层的差距(长列表、复杂动画)是另一个问题,靠原生组件或 RN/Flutter 才能解决。

❌ 误区2:”有了离线包就不用 CDN 了” 必须有。用户第一次装 App 时离线包还在下载路上,这时候页面只能走 CDN。没有 fallback,离线包就等于”首次打开必白屏”。

❌ 误区3:”增量包永远优于全量包” 增量包省流量、下载快,但每个历史版本都要维护一份差分包,版本链一长,管理成本和失败率都上去了。工程上的正确做法是:增量包优先、失败自动回退全量包,并对使用量极低的旧版本直接不给差分包。

❌ 误区4:”离线包发出去,用户立刻就用上了” 不会。下载、校验、解压都有时间窗口,用户不重启 App 可能还在用内存里已加载的旧资源。所以离线包一般配合”下次冷启动生效”,别指望实时性。

❌ 误区5:”iOS 上也能像 Android 一样拦 http 请求” WKURLSchemeHandler 只能拦自定义 scheme,注册 http/https 会直接报错;NSURLProtocol 在 WKWebView 上也不生效。所以 iOS 的离线包必须改 scheme或者起本地服务,这也是 Hybrid 方案里最容易被问住的点。

❌ 误区6:”页面白屏一定是前端代码有问题” 离线包场景下,白屏更像是分发问题:包没下载完、MD5 校验失败、解压路径不对、覆盖到一半失败、fallback 路径对不上。排查顺序应该是”先看客户端现在用的是哪个版本的包”,再看代码。

一句话总结

Hybrid 的本质是”用 WebView 换取发布效率“,而离线包是把这份效率兑现成真实体验的工程:拦截是手段,版本管理是核心,降级链路是底线。

本文由作者按照 CC BY 4.0 进行授权