15、OTA更新机制:OTA包结构、Recovery模式下的OTA流程、增量更新与全量更新
说到Android系统升级,OTA(Over-The-Air)更新绝对是咱们日常开发中最常打交道的模块之一。我记得刚入行那会儿,第一次看到OTA包解压后的目录结构,说实话有点懵——怎么这么多脚本和校验文件?后来自己动手做过几次系统升级方案,才慢慢摸清楚这里面的门道。
今天我就把OTA更新的核心机制掰开揉碎了讲给你听。咱们从OTA包的结构说起,再深入到Recovery模式下的执行流程,最后聊聊增量更新和全量更新的区别。嗯,内容不少,但都是干货。
15.1 OTA包结构:一个压缩包里的秘密
OTA包本质上就是一个ZIP文件,但它的内部结构有严格约定。我习惯用unzip -l先看一眼包里的内容,大致就能判断这个包是增量还是全量。
一个典型的全量OTA包结构如下:
ota_update.zip
├── META-INF/
│ ├── MANIFEST.MF # 文件摘要清单
│ ├── CERT.SF # 签名文件
│ ├── CERT.RSA # 公钥证书
│ └── com/
│ └── google/
│ └── android/
│ ├── update-binary # 更新程序二进制
│ └── updater-script # edify脚本(旧版)
│ └── update-engine # 新版A/B分区更新引擎
├── system/
│ ├── build.prop
│ ├── app/
│ └── framework/
├── boot.img
├── recovery.img
├── vendor/
├── radio/
└── file_contexts
这里面最关键的是META-INF目录。你想想看,OTA包要在设备上执行系统级操作,安全性必须拉满。签名和校验都在这里完成。
核心要点:OTA包必须经过签名验证,否则Recovery模式会直接拒绝执行。我曾经遇到过客户拿了一个未签名的测试包刷机,结果Recovery一直报"Signature verification failed",折腾了半天才发现是签名问题。
对于增量OTA包,结构会略有不同:
ota_incremental.zip
├── META-INF/ # 同上
├── patch/
│ ├── system/
│ │ ├── app/Phone.apk.p # 二进制差异补丁
│ │ └── framework/
│ └── boot.img.p
├── system/
│ ├── new_data/
│ └── new_files/
└── recovery/
└── from/
└── build.prop # 源版本标识
增量包不包含完整的镜像文件,而是用.p后缀的补丁文件替代。这些补丁是用bsdiff或imgdiff工具生成的,专门针对二进制差异做了优化。
15.2 Recovery模式下的OTA流程
OTA更新的执行环境是Recovery模式。为什么不在正常系统里直接刷?说白了,正常系统运行时,分区是被挂载的,你没法直接覆盖正在使用的文件。Recovery模式下的文件系统是独立的,可以安全地操作所有分区。
整个流程我画了一张图,你看完就明白了:
流程看起来不复杂,但每个步骤背后都有不少细节。我挑几个关键点说说:
15.2.1 签名验证的细节
Recovery启动后,会先读取OTA包中的CERT.RSA文件,提取公钥。然后验证CERT.SF和MANIFEST.MF的签名链。这里用的是Android的签名方案v1(JAR签名)。
为什么要这么严格?因为Recovery模式下执行的脚本有root权限,可以修改任何分区。如果被恶意包混进来,整个设备就沦陷了。我见过一些厂商为了省事,在测试阶段关闭了签名验证,结果有工程师误刷了错误版本的包,导致设备变砖——嗯,教训深刻。
15.2.2 更新脚本的执行
更新脚本是edify语言写的(Android 7.0之前)或者由update-binary直接处理(新版)。脚本里定义了分区挂载、文件写入、权限设置等操作。
一个典型的edify脚本片段:
mount("ext4", "EMMC", "/dev/block/bootdevice/by-name/system", "/system");
show_progress(0.500000, 0);
package_extract_dir("system", "/system");
set_perm_recursive(0, 0, 0755, 0644, "/system/app");
unmount("/system");
这段脚本的意思是:挂载system分区,把包里的system目录解压进去,设置权限,最后卸载分区。看起来简单,但实际项目中,脚本里往往有几十个这样的操作序列。
避坑指南:我曾经在项目中遇到一个问题——更新脚本里忘记卸载分区,导致Recovery退出时分区状态异常,重启后系统起不来。后来我养成了一个习惯:每个mount操作后面必须跟一个unmount,哪怕脚本最后会自动清理。
15.3 增量更新与全量更新
这两种更新方式,说白了就是「重装系统」和「打补丁」的区别。全量更新是把整个系统镜像刷进去,增量更新只修改有变化的部分。
| 对比维度 | 全量更新 | 增量更新 |
|---|---|---|
| 包大小 | 大(通常1-2GB) | 小(通常几十MB) |
| 更新耗时 | 长(5-15分钟) | 短(1-3分钟) |
| 依赖当前版本 | 不依赖 | 必须基于特定版本 |
| 失败风险 | 低(完整写入) | 中(补丁可能不匹配) |
| 适用场景 | 大版本升级、首次刷机 | 小版本迭代、安全补丁 |
15.3.1 全量更新的实现
全量更新最直接。Recovery模式下,脚本会把OTA包里的system.img、boot.img等镜像文件直接写入对应分区。写入完成后,清除cache分区中的数据,然后重启。
它的优点是可靠性高——不管当前系统是什么状态,全量写入后都是一套全新的系统。缺点是包太大,用户下载费流量,更新也慢。
15.3.2 增量更新的原理
增量更新用的是bsdiff算法。这个算法会对比新旧两个文件的二进制差异,生成一个补丁文件。在设备端,update-binary会把补丁应用到当前文件上,还原出新文件。
举个例子:假设旧版Phone.apk是10MB,新版是10.5MB。bsdiff生成的补丁可能只有200KB。设备端执行:
apply_patch("/system/app/Phone.apk",
"-",
10500000, // 新文件大小
10000000, // 旧文件大小
"SHA1:old_hash",
"SHA1:new_hash",
patch_data);
这里有个关键点:增量更新对当前系统的完整性要求极高。如果用户root后修改了系统文件,或者某个文件被意外损坏,补丁应用就会失败。我遇到过最头疼的情况是,用户刷了第三方Recovery,导致分区布局变了,增量包里的补丁路径对不上——直接报错中止。
注意:增量更新失败后,设备通常会回退到旧系统。但如果回退逻辑有bug,设备可能卡在Recovery循环里。所以,我建议在增量更新前先做一次系统完整性校验,确认所有待修改的文件SHA1值都匹配。
15.3.3 如何选择
在实际项目中,我一般这样决策:
- 大版本升级(比如Android 12 → 13):用全量更新。改动太大,增量补丁可能比全量包还大,而且风险高。
- 月度安全补丁:用增量更新。改动范围小,包体小,用户体验好。
- 首次出货后的第一次OTA:我建议用全量。因为用户手里的设备可能被各种折腾过,增量更新容易翻车。
另外,现在Google推的A/B分区(无缝更新)方案,其实结合了全量和增量的思路。系统在后台下载增量包,应用到备用分区,下次重启直接切换。用户几乎感觉不到更新过程——这个我们后面章节会细讲。
好了,OTA更新的核心机制就这些。从包结构到Recovery流程,再到全量和增量的取舍,每个环节都有不少坑。我个人觉得,理解这些底层逻辑比单纯会配一个OTA脚本要重要得多——毕竟,系统升级出问题的时候,能快速定位到根因,才是真本事。