MMHXYZ 技术笔记

Android WebView 热更新:三源回退与增量包设计

2026-10-29 · Android · 约 5 分钟

APK 一旦装到别人手机上,改一行 JS 就要重新打包、重新安装,这条路径在迭代期基本不可用。所以我在 WebView 里做了一套热更新:壳子不动,只换里面的前端资源。

加载顺序

启动时不是直接读 assets/index.html,而是先看私有目录里有没有下载过的版本,有就从那儿加载,没有才回落到 assets。私有目录在 getFilesDir() 下,本身随应用卸载一起消失,不会污染外部存储。

这样一来,热更新失败最坏的结果只是"用回打包时的那一版",不会白屏。

更新协议

服务端只放一个 manifest.json 和若干 zip:

{
  "version": "2026.10.29-1",
  "url": "https://example.com/updates/pack-20261029.zip",
  "sha256": "…",
  "notes": "阵容界面修复"
}

客户端拿本地版本号和它比,用不等于而不是大于:

if (manifest.version !== current) {
  await download(manifest.url);
}

判断写成 > 就会出问题——万一新版本有严重 bug 想推回旧版,用 > 永远收不到。用 !== 才能双向流转。

三源回退

只配一个公网源的话,国内手机在弱网下等待时间很难看。我把更新源做成三个,按顺序试:

顺序源场景
1家里局域网我在家调试
2公司局域网我在公司调试
3公网域名都不通时兜底

规则上有个细节:内网源一旦返回清单,就以它为准,不要继续往后问。 否则会出现"内网说有 v5,公网说已最新"的冲突,客户端不知道该信谁。

只有前面所有源都连不上,才认为"检查失败",此时保持当前版本不动。

for (const base of SOURCES) {
  try {
    const res = await fetch(`${base}/updates/manifest.json`, { cache: 'no-store' });
    if (!res.ok) continue;
    return await res.json();      // 拿到就返回,不再往下试
  } catch (_) { /* 超时就试下一个 */ }
}
return null;                      // 全失败 = 不更新

增量包:基线要取最老的那一版

增量包本质是"覆盖合并",不是补丁。所以基线越老,覆盖的文件越全。

我一开始用最新版做基线,结果只在最新版上升级的人没问题,手机停在三个月前那版的用户升上来之后缺文件——因为三个月的改动从来没进过这个 zip。

后来改成:基线固定取最老的现役版本。这样不管对方停在哪一版,一次下载就能补齐。代价是包大一点,一个前端项目也就 1~2MB,能接受。

覆盖式更新的回退陷阱

这里是真正踩疼的地方。

安装逻辑是 mergeTree:把 zip 里的文件逐个覆盖到目标目录,不删除多余文件。这样设计是为了安全,万一新包少带了某个文件,老文件还在,不至于直接把应用搞挂。

但反过来就有个后果:推了一个新版本之后再想退回旧版本,被覆盖过的文件回不来。 旧包里没包含的文件仍然留在磁盘上。

如果真需要可靠回退,只有两条路:

  • 每个包都带完整文件集(放弃增量,包变大)
  • 版本目录隔离,加载时按版本号选目录,回退只是切换指针

第二条更干净,但要改加载逻辑,暂时没做。目前的取舍是:增量包出问题时,我发一个"完整包"作为新版本号推下去,等于用一次全量覆盖把状态洗回来。

校验不能省

下载完必须校验,否则半截文件会让应用直接起不来:

const buf = await res.arrayBuffer();
const digest = await crypto.subtle.digest('SHA-256', buf);
const hex = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('');
if (hex !== manifest.sha256) throw new Error('checksum mismatch');

crypto.subtle 在 WebView 里可用,前提是页面跑在安全上下文(HTTPS 或 localhost)。这也是为什么后来一定要上域名和证书,纯 IP 的 HTTP 页面拿不到 subtle。

小结

这套东西没有多复杂,但每个细节都是被坑出来的:比较用 !==、基线取最老、失败必须静默回落、下载必须校验。真正难的不是写下载逻辑,是想清楚出问题的时候用户看到什么。

← 返回全部笔记