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模式下的文件系统是独立的,可以安全地操作所有分区。

整个流程我画了一张图,你看完就明白了:

Recovery模式OTA更新流程 1. 下载OTA包 2. 重启进入Recovery 3. 签名验证 验证失败 → 中止 验证成功 → 继续 4. 执行更新脚本 5. 重启进入新系统

流程看起来不复杂,但每个步骤背后都有不少细节。我挑几个关键点说说:

15.2.1 签名验证的细节

Recovery启动后,会先读取OTA包中的CERT.RSA文件,提取公钥。然后验证CERT.SFMANIFEST.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.imgboot.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脚本要重要得多——毕竟,系统升级出问题的时候,能快速定位到根因,才是真本事。