Android WebView 热更新:三源回退与增量包设计
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。
小结
这套东西没有多复杂,但每个细节都是被坑出来的:比较用 !==、基线取最老、失败必须静默回落、下载必须校验。真正难的不是写下载逻辑,是想清楚出问题的时候用户看到什么。