Android 差分升级再折腾:先解压再 diff,差分包从 46% 压到 22%

17 年写过一套 bsdiff 的差分升级,最近又碰上了,这次的包 300 多 MB,直接 diff 出来的差分包快到全量的一半。
折腾了一圈,记录一下怎么把它压到 22%,以及怎么让手机合成不卡死。

起因

之前那套差分升级(上:apk 差分、中:差分包合并、部署和下载)放在当年的 30MB 小包上还挺好用。

这次手上的 App 是 300 多 MB 的大家伙,arm64 单架构包都有 320MB 左右。

产品想上差分升级,我先拿相邻两个版本直接跑了一下 hdiffz(HDiffPatch,比 bsdiff 快很多,差分也更小)。

结果:差分包 148MB,是全量的 46%。

两个相邻版本,改动没多少,省下的流量才一半。这显然不对劲。

为什么直接 diff APK 会这么大

APK 本质就是个 zip。dex、so、xml 这些大部分都是 deflate 压缩存的。

deflate 有个特点:原文哪怕只改了几个字节,压缩后的字节流从那个位置往后基本全变。

我拿一个 so 试了一下,解压后两个版本只有 36 个字节不同,压缩后的数据却整段都对不上。

diff 工具看到的是压缩后的字节,自然找不到能复用的地方,只能当新数据整段塞进差分包。

思路:先解压再 diff

这个问题 Google 早就碰到过,Play 商店的增量更新用的 archive-patcher 就是干这个的:

  1. 生成端:把新旧 APK 里所有 deflate 条目解压展开,对展开后的数据做 diff,同时记下每个条目当初是用什么参数压缩的。
  2. 合成端:同样把旧 APK 展开,打上 patch 得到新包的展开数据,再按记下的参数把每个条目重新压缩回去。

重新压缩回去之后,得到的 APK 要和官方新包逐字节一致,这样 v2/v3 签名原样有效,不用重签。

听起来很美,但是有个关键前提:

deflate 必须能逐字节复现。

同一段数据,换个 level、换个 memLevel、换个 zlib 版本,压出来的字节都可能不一样。复现不了,就只能退回去用压缩后的字节参与 diff。

先验证 deflate 能不能复现

直接暴力试。对新包每个 deflate 条目,拿标准 zlib 的 raw deflate(windowBits=-15)挨个试 level 和 memLevel,看哪个组合压出来和原数据一模一样:

1
2
3
4
5
6
7
8
9
10
import zlib

def probe(raw: bytes, compressed: bytes):
"""返回能逐字节复现的 (level, memLevel),找不到返回 None"""
for level in (6, 9, 1, 2, 3, 4, 5, 7, 8):
for mem_level in (8, 9):
c = zlib.compressobj(level, zlib.DEFLATED, -15, mem_level)
if c.compress(raw) + c.flush() == compressed:
return level, mem_level
return None

新包差不多 1.9 万个 deflate 条目,结果出乎意料的好:

  • level 6 / memLevel 8:7700 多个,解压后约 200MB
  • level 9 / memLevel 8:1.1 万多个,约 13MB
  • level 6 / memLevel 9:1 个

100% 都能复现。 打包工具基本就是 zlib 默认参数,一部分资源用了 9。

那就好办了,每个条目记下 (偏移, 解压长度, 压缩长度, level, memLevel) 一共 26 个字节,1.9 万条用 lzma 压完才 110KB,在差分包里可以忽略不计。

zip 头、central directory、签名块这些不解压,原样参与 diff。

第一版:差分包 70MB

生成端流程:

  1. 展开旧包:把每个 deflate 条目的压缩数据原地替换成解压后的内容,其余字节原样保留。300 多 MB 的包展开后 650MB 左右。
  2. 展开新包:同样处理,只展开能复现的条目,同时生成上面那张参数表。
  3. hdiffz -m-6 -c-lzma2-9-64m 旧展开 新展开 out
  4. 参数表和 hdiff 数据拼成一个文件,完事。

合成端反过来:展开旧包(结果是确定的,不需要额外信息)→ hpatchz → 按参数表逐个条目重新 deflate 写回原位。每个条目都核对一下压缩后的长度,对不上直接报错。

结果:

方案 差分包 占全量 还原后签名
直接 hdiff 整个 APK 148MB 46% ✅
ApkDiffPatch 一类 72MB 22.5% ❌ 新包要先规范化再重签
先解压再 diff 70MB 21.8% ✅ 逐字节一致

ApkDiffPatch 那类方案差分包也很小,但前提是新包打包时先过一遍它的规范化再签名,已经发出去的包用不了。我们这边不想动打包流程,只能选后者。

70MB 里面都是什么

把展开后的数据按类型分开估了一下:

  • dex:200 多 MB,差分占了 62MB 左右,大头里的大头
  • 体积最大的那个业务 so:4.7MB
  • resources.arsc:4.1MB
  • 其他 so:3.9MB
  • res、assets 和其他:1.7MB

dex 两个版本之间大概有 22% 对不上。推测是混淆加固导致的,每次构建类名、方法名、字符串加密结果、类的排列顺序都可能整体变掉。

要再往下压只能动构建:混淆用 -applymapping 沿用上个版本的映射、字符串加密的密钥固定、或者对加固前的产物做 dex 级 diff(Tinker 那种)。这些都要动打包,先不搞了。

问题来了:手机上合成太慢

第一版合成我是 Python 写的原型,在 Mac 上跑一次:20 秒、1.5GB 内存。

这放手机上就是找死。慢有三个原因:

  1. 新包所有 deflate 条目都要重新压缩,600MB 左右的原始数据,单线程。
  2. 旧包、新包的展开文件整个读进内存。
  3. hdiff 数据用的 lzma2 + 64MB 字典,光解压就要 160MB 内存。

原则就一条:生成端可以慢、可以吃内存,手机端要快、内存要小。能提前算的全挪到生成端。

没变的条目直接拷贝

生成端先对旧包每个条目的压缩字节算个 hash,新包里压缩字节能在旧包找到的条目(包括改了名的),合成时直接从旧包拷原始字节,不解压也不重新压缩。

这样新 APK 被切成一串操作,按顺序拼起来正好是整个文件:

  • COPY:从旧 APK 拷贝一段原始字节
  • DATA:原样写入(文件头、central directory、签名块、stored 条目、复现不了的 deflate 条目)
  • DEFLATE:拿到解压后的内容,按记录的 level/memLevel 重新压缩后写入

每个操作在新 APK 里的位置,等于前面所有操作输出长度之和,生成端和合成端算出来一样,所以可以多个线程直接 pwrite 到最终位置,谁也不用等谁。

实测下来,能直接拷贝的其实只有 80MB 左右,需要重新压缩的 dex 和 so 有 478MB,这部分绕不开,是合成耗时的下限。

分组并行

DATA 和 DEFLATE 分成 16 组,每组有自己的一段新数据流,生成端对每组单独做一次 hdiff。手机上每组一个线程,各算各的。

分组按合成代价做均衡(每次把最重的条目分给当前最闲的组),DEFLATE 按解压后字节数算,level ≥ 7 的乘个 1.6,DATA 只是写盘,按 5% 算。

组数故意比线程数多,大组先领,快核干完一组接着领下一组,大小核混搭的手机上负载更均匀。

全程流式

  • 旧包里没被 COPY 用掉的条目按顺序展开成一个临时文件,所有组共用,随机读。创建完立刻 unlink,崩溃了也不会留垃圾。
  • hpatch 顺序输出新数据,边输出边压缩,直接写到新 APK 的最终位置,不留中间文件。
  • hdiff 改成 -SD 单一压缩流 + zstd 1MB 窗口。窗口从 8MB 缩到 1MB,差分包只大了 0.4%,每组解压内存从几十 MB 降到 1MB 多一点。

zlib 自己带

这里有个坑:Android 系统自带的 zlib 不保证和标准 zlib 输出一致,不同系统版本、厂商可能有各自的优化,新版本 AOSP 据说用的是 Chromium 的 zlib 分支。

重新压缩要逐字节一致,系统 zlib 靠不住。干脆把标准 zlib 源码编进合成库,符号加 -DZ_PREFIX 前缀,免得和系统的 libz.so 打架。

合成库最后是纯 C 的:HDiffPatch 只用 HPatch/patch.c,zstd 只编解压部分,加上 zlib,arm64 strip 后 230KB 左右。

第二版实测

代价是差分包从 70MB 涨到了 78MB 左右(zstd 压缩率不如 lzma2),换来的是:

测试环境 线程 耗时 内存峰值
Mac,Python 第一版 1 20 秒 1.5GB
Pixel 旗舰,限定 4 个中核 4 6.6 秒 19MB
Pixel 旗舰,全部 8 核 8 5.0 秒 33MB
Pixel 旗舰,限定 2 个小核(模拟低端机) 2 30.5 秒 13MB

每多一个线程只多 3MB 左右内存,所以线程数直接用 Runtime.availableProcessors(),上限 8。

2 个小核 30 秒是极端模拟,真实低端机一般也有 8 个核,按小核单线程每秒压 8MB 多估算,8 线程大概 10 秒,这个还得找真机验证。

另外拿 11 个历史旧版本往最新版本合成也跑了一遍:相邻版本 32% 左右,跨 6 个中版本也才 40%,还是能省一大半流量。合成耗时和内存跟版本跨度基本无关,因为每个版本 dex 和 so 都是整体在变,要重新压缩的数据量都在 500MB 上下。

客户端接入要注意的

  • 先校验旧包:差分包只能把某一个固定的旧包升上去。合成前对 applicationInfo.sourceDir 算 sha256,对不上就别合了。
  • 文件头放前面:差分包开头放一个固定长度、不压缩的头,带上旧包和新包的大小、sha256,可以用 HTTP Range 先下这一小段判断能不能用。
  • 检查空间:临时展开文件 + 新 APK,大概要 0.9GB,再留 10% 余量,不够就走全量。
  • 合成完必须校验新包 sha256,不一致删掉走全量。300 多 MB 的包算一遍 sha256 才 0.3 秒,没理由不做。
  • 任何一步失败都回退全量下载,并且把失败原因上报,灰度时重点盯”重新压缩长度不一致”这类错误。
  • 放 WorkManager 里后台跑,装的时候走 PackageInstaller,签名还是原签名。

最后

还有一条路:打包时 dex 和 so 改成不压缩存储(AGP 的 useLegacyPackaging = false),合成时就几乎不用重新压缩了,模拟出来能快 3~4 倍。

但是全量包会大 85%,走全量下载的用户流量直接翻倍,还得确认加固工具支不支持。权衡了一下,没采用。

想自己试的话,HDiffPatch 在 GitHub 上,原理可以看 Google 的 archive-patcher。