1. 为什么会有Mainline:Android碎片化这口锅
搞Android系统定制或整机方案时间长了,基本都躲不开一个问题:怎么让系统更新更快、更安全、还不用每次都逼用户刷全量包。以前我们发一个安全补丁,从AOSP拉下来、合代码、过测试、调CTS、发OTA,一整套流程走完,三个月能落地已经算顺利。用户手里的机器八成还在跑着上一个版本,甚至有人还在用老掉牙的内核和系统组件。
Mainline项目就是冲着这个痛点去的。简单说,它把Android系统里的一部分关键组件拆出来,做成可以独立更新的模块,绕过整机OTA,直接通过系统更新通道推到用户设备上。这些模块以APEX或者APK-in-APEX的形式存在,底层原理、打包方式、签名校验、分区挂载全都有讲究。我第一次拿到APEX包拆开看的时候,第一反应是:这不就是一个带文件映射表的签名压缩包吗?后来才发现,它对系统分区的挂载策略、回滚机制、安全校验链路都产生了连锁影响。
如果你接触过Android系统裁剪、ROM定制、设备认证,或者只是好奇为什么某些系统组件不升级整体版本也能更新,这篇文章值得看完。我会把Mainline设计思路、APEX打包原理、分区演进、分发链路、厂商适配注意事项以及Debug经验一次说清,尽量用我们做项目踩坑时总结出来的话讲。
2. Mainline要解决的问题本质
2.1 系统组件更新的两头堵
先说说没有Mainline之前日子怎么过。Android的系统组件大多放在system分区,system分区在设备的出厂阶段就被写入,之后除了整包OTA,几乎没有其他合法手段单独改它。厂商想要推送一个新的媒体解码库、网络协议栈或运行时更新,唯一的正规路径就是发OTA,而OTA从生成到灰度到强制推送,涉及运营商测试、软件兼容性、海外版本规范等一系列环节,周期极长。
我问过不少做方案集成的人:你们项目里最怕什么?答案出奇一致——怕客户问“能不能只更新某一个系统组件”。过去真没法做到,因为system分区的完整性和签名的统一性是绑定死的。你单独换掉其中某个库,整个分区的签名校验就挂了,设备直接进recovery。
Mainline的思路是把这些组件从system分区中拆出来,单独成为“模块”。模块所在的逻辑分区独立于system,它的更新不需要改动system分区的任何字节,也不需要重启进入OTA流程。相当于把之前一个封闭的系统箱,拆成了一排可以独立开关的抽屉,每个抽屉都有自己独立的锁和合页。
2.2 分发方式的根本变化
Mainline的另一个变化在于分发通道。传统OTA必须由设备厂商发起,从服务器下发整包增量包,应用层配合恢复系统进行安装。Mainline模块则交由系统更新服务统一管理,服务器可直接推送模块更新,不再强制依赖厂商发布整包OTA。这样做的好处是,即便设备厂商已经停止整机维护,那些关键模块仍能持续获得修复,在一定程度上延长了设备的安全生命周期。
实际项目中我们看到的结果是:一台两年前上市的设备,厂商已经不再发大版本OTA,但系统自带的媒体组件、网络组件、权限组件每隔几个月还会更新。用户感知不到具体哪个模块变了,但能感觉到部分安全性问题被修补了,兼容性问题变少了。
这事的本质是Google在推进一种“服务化”的系统更新理念——系统不再只是一个发行版本,更多像一套可持续升级的组件集合。Mainline不是某个功能的更新,而是整个更新策略演进的起点。
3. APEX与APK-in-APEX:模块拆分的基石
3.1 APEX到底是什么
APEX格式第一眼看上去像ZIP,实际上也确实兼容ZIP结构,它内部有原生库、可执行文件、配置文件、甚至JAR包,外层再用签名保护。但和普通ZIP最大的区别是:APEX设计出来就是要被内核挂载为只读文件系统的,挂载后内部文件会直接映射到特定的系统路径,比如/apex/com.android.media。
Android系统启动过程中有一个专门的apexd守护进程负责处理这些包。它的工作流程大致是:从/system/apex或/data/apex/session目录扫描合法的APEX文件,校验签名和AVB完整性,然后解析包内的manifest配置,确定这个包应该挂载到哪个路径、是否需要激活、激活后哪些SELinux策略要跟着变化。
我之前第一次尝试手写APEX包的manifest时犯过一个错:把provideNativeLibs写漏了一个平台库名,结果该模块内的组件跑到一半就崩了,logcat里全是一堆linker cannot find。后来才明白APEX的manifest不只是简单描述信息,它决定了挂载后的APEX namespace可见范围,写漏一个库,意味着这个作用域里的进程全部找不到对应依赖。
3.2 APK-in-APEX的存在原因
APK-in-APEX听起来绕口,但拆开理解很简单:APEX本身不是给Java层直接跑的,Java层的APK需要单独的格式。如果一个模块既要提供底层native能力,又要提供上层应用或服务,最简单的方式就是把APK也封装进APEX里。
Android在代码里区分了两种情况:
- APEX里直接放APK文件,和普通APK安装路径完全不同,它不经过PackageManager的安装流程。
- APK Update则是一种特殊形态,允许更新一个已预置在system分区中的APK,而无需整个APEX替换。
这个机制对降低模块体积很重要。不同厂商在其方案中会预置多个APEX包,采用APK-in-APEX方式的好处是可以只更新上层APK那部分,底层库保持不变,数据量明显小很多,这对蜂窝网络下更新尤其友好。
3.3 SDK扩展与API稳定性
Mainline模块更新对上层应用最大的威胁是:模块版本在变,API会不会跟着变了?Android用了一个叫SDK扩展的机制来解决这个问题,模块代码里使用Build.VERSION.SDK_INT之外,多了一个SDK_INT加扩展版本号的概念。
举个例子,某个权限相关的模块更新后,系统暴露给应用的API行为发生变化,但应用本身是通过Build.VERSION_CODES里的扩展版本号来判断能不能用新API的,因此即使底层模块已经更新,旧应用也不会突然崩溃。模块更新由服务器控制,但API的变更节奏受扩展版本约束,两者配合才能做到平稳演进。
这条设计我认为是整个Mainline框架里最值得称道的部分:它没有让模块更新变成天气预报——谁知哪片云会下雨。所有行为变更都有版本号兜底,应用层可以像以前判断系统版本一样判断模块能力。
4. 启动链路与分区演进
4.1 从单一system分区到可叠加分区
Mainline模块要生效,系统光有“更新了模块”这个逻辑还不够,必须在启动路由层面就给模块留位置。假设模块都更新到了/userdata或者某个独立分区,系统启动时如果不挂载它们,等于白更新。
Android的启动流程从Bootloader到Init再到apexd,每一层都需要知道:哪些APEX是合法的、在哪里、以什么顺序挂载。AOSP里引入了一种叫做“APEX激活”的机制,安装在用户数据区的模块包在每次开机时会被检查并挂载,如果校验失败,则回退到出厂内置的系统版本。
这意味着系统分区的顶层多了一层逻辑叠加——出厂的基础分区只负责提供“初始可用环境”,每次开机后实际生效的系统视图是“出厂版本+已激活模块增量版本”的叠加。这种设计对OTA的影响也很大,因为模块可能单独更新,也可能绑定某次OTA一起更新,厂商必须设置好用户数据分区的大小和预留空间,否则用户数据压力大的设备上,模块更新很容易被空间不足卡住。
4.2 根分区验证与可更新分区的协调
Android对系统完整性的保护一直没有放松,AVB(Android Verified Boot)和dm-verity依然是分区的安全基石。Mainline引入了一个问题:如果模块分区可以动态改变,那原本根植于system分区完整性的信任模型还能不能成立?
答案是可以的,但约束变细了。AVB的验证链条并没有被打破,只是多了一条:验证方在启动阶段除了验证system分区,还需要验证激活的模块分区。模块分区有自己独立的签名链,可以跟系统根密钥内部分离,但必须在出厂时预置可信的公钥。
这个逻辑项目中经常被误解。有同事曾问我:如果厂商想替换掉某个Mainline模块的签名密钥,是不是只需要在发布新包时用新签名就行?答案是否定的。密钥更新需要单独的升级链,在老设备上工厂预置的信任公钥不变,别的新签名包根本过不了校验。换签名这种事只能在出厂的信任根里整体加白名单,无法在用户设备上自行追加。
4.3 动态分区与Virtual A/B的配合
Mainline模块的独立更新如果还走传统双分区切换(A/B分区)的老路,成本太高。传统A/B方案意味着设备里必须留两套完整系统分区,中低端存储就吃紧了。Virtual A/B就是为这个场景设计的:设备上只有一份完整属性和一份表示“更新后数据差异”的快照,重启切换时只更改查看视角,而不是真正写一套系统。
和Mainline结合后,模块更新和系统OTA可以各自独立触发,回滚也能分别处理。有一次我们模拟场景:系统OTA包因为内核模块兼容问题失败,但此时某个Mainline模块已经提前更新到新版本了,正常情况下可能造成新模块与旧系统不兼容。得益于快照机制,模块和系统的状态能够分别回滚,不至于出现一个进得去、一个退不出的尴尬局面。
5. 模块分发链路与设备认证
5.1 谁在管理模块的更新
Mainline模块的更新通道和系统OTA是有区别的。在主流方案的实现中,系统更新服务充当了一个角色,你可以把它理解成一个“模块更新调度器”——它定期检查服务器端的模块包是否有新版本,如果有且当前设备条件满足(网络、电量、存储空间),就会自动下载并安装。
这一步在很多开发者眼里是个黑盒:服务器到底推送了什么,什么时候推送,厂商能不能控制?实际上厂商是有配置空间的。AOSP里有个叫apexd的服务负责激活,而具体的更新数据管理以及授权策略是落在整个系统更新框架里的。厂商可以做策略定制,比如只在WiFi下自动更新,或者将所有模块更新收敛到整机OTA一起执行。
我们实测过两种模式:模块独立更新对用户打扰极小,后台静默完成,几乎无感;但如果厂商把所有模块更新都绑定到整机OTA,那Mainline的意义就被削弱了,因为又一次把更新时间拖到了整包OTA的节奏上。建议在项目和方案里尽量保留模块独立更新的灵活性。
5.2 设备认证与模块兼容性
Mainline更新虽然更灵活,但也带来了新的兼容性压力。模块更新之后,如果和某个厂商深度定制的底层驱动不兼容,用户会遇到诡异问题——WiFi打不开、媒体播放异常、或指纹识别失灵。
原因是Mainline模块的版本更新通常在AOSP主线上验证过,但安卓生态里大量芯片厂商和集成厂商会改动到底层驱动或者扩展接口。模块更新时如果覆盖到的代码路径恰好和厂商改动耦合,就会出现只在这个设备上复现的问题。
我建议认证流程中建立一个模块兼容性子用例,重点覆盖网络、媒体和运行时三大块。设备认证不只包含整机OTA升级路径,也包括关键模块单独更新后的稳定性验证。项目实测完成后,能明显减少线上反馈的模块更新兼容性Bug,这是成本低收益高的投入。
6. 厂商适配中的真问题与排查办法
6.1 分区大小与构建配置
厂商适配Mainline的第一步往往是修改分区表。因为模块分区不是凭空出现的,它需要从system或vendor空间里切出一块,或者直接新建一块独立分区。不同方案会选择不同策略,我们见过两种:
- 一是复用系统自带的apex目录所在分区,把增量模块安装在数据区缓存中;
- 二是单独划分一个更小的逻辑分区,专门放已激活的模块包。
方案没有绝对优劣,但要求厂商必须计算清楚:出货策略里要内置多少套基础模块,余量要撑得过未来两年内的模块版本膨胀。我们早期有个项目只预留了300MB给模块目录,结果第8个月就开始频繁出现空间不足的更新失败,后来不得不把数据区再划一部分出来做补偿,等于推翻重来了一次发布规划。
6.2 SELinux策略与模块边界
Mainline模块激活后,它带来的不只是文件路径变化,还有自己的SELinux标签和策略。AOSP中每个模块对应的策略文件都是独立维护的,模块更新时如果引入新的服务或访问新的文件节点,策略不跟着适配,就会出现permission denied。
行业内跟进这个问题的标准做法是:在集成时打开SELinux的permissive日志观察启动阶段和模块激活阶段的avc denial信息,但要清楚这只是调试手段,正式版本中应确保targeted模式无新拒绝日志。常见的陷阱是模块更新后新增了一个property或sysfs节点,系统的property_contexts或file_contexts却没有补齐。
6.3 OTA与模块更新并发
整机OTA和Mainline模块更新同时触发,是我们项目里最需要关注的异常场景。因为系统可能正在升级system分区,而模块同时也在重挂载/apex,两者有极小概率发生I/O或动态链接竞争。
从Android系统的调度设计来看,OTA本身会有staging状态,而模块更新由另一个服务在管理,说两者完全没有冲突是不可能的。要规避,必须从厂商自定义的升级策略层面控制:整机OTA触发时,先暂停模块更新,等OTA完成重启后再恢复。这个策略之前没有考虑,后来遇到一次升级失败的设备取证,日志显示两个更新进程进行了文件读写竞争,当时就意识到必须做全局互斥。
6.4 回滚与设备健康检查
模块更新失败后的回滚路径也是项目上线前容易被忽略的。传统OTA失败大多可以靠A/B分区兜底,但模块更新如果写坏了,回退逻辑是依赖apexd在开机时发现激活校验失败,自动使用出厂版本。
有一个要点:出厂版本是否存在于system分区的内置APEX目录中,这决定了回滚是否可靠。部分厂商为了省空间,在定制时删掉了一些系统内置APEX的出厂副本,只保留用户数据区的“最新版本”,一旦用户数据区版本损坏或校验失败,设备就会陷入模块缺失的状态。这个坑我们在售后中碰到过,处理成本非常高。结论很明确:出厂的“兜底版本”不能省,那点空间省不得。
7. 实测多年后的运维经验
做了几年Mainline项目实施,最深的体会是:这个框架不是把系统更新变简单,而是把问题拆细了。传统OTA是一整锅粥,糊了就是锅糊了,Mainline像是一排抽屉,任何一个抽屉卡住,只会影响局部,但排查时需要你知道哪个抽屉在哪、谁负责打开它、以及钥匙在谁手里。
分享三个实战经验:
第一,日志分析要看apexd和update_engine的日志,如果模块更新卡住,大概率是空间不足或者DM设备映射问题,这两个进程的日志比logcat更直接。
第二,测试适配时一定要做“出厂版本损坏回滚”的压力测试。方法很简单,模拟强制修改激活区数据后用重启脚本观察是否自动回退。很多厂商设备在普通OTA上过了测试,但这道回滚测试没做好,真实设备才会原形毕露。
第三,模块更新必须纳入整机版本管理。不要以为模块是服务器分发就不用像固件一样管理版本号,现场调试时我们遇到过模块版本和系统框架版本不匹配,导致上层服务反复崩溃的情况。建议项目里建立一个模块与系统版本匹配表的自动化检查,每次更新前先核对条目。
Mainline带来的更新策略演进,本质上让Android从一个“按版本换代”的系统,逐步走向了“按模块演进”的架构。这种变化对用户是透明的,但对做系统的人来说,意味着掌握的知识面要更宽了——从分区布局到签名链,从SELinux策略到启动顺序,每一个环节都可能因为一个小小的APEX包而牵动全局。
我在实际项目里吃过亏之后的最大感受是:千万不要在设备出货前才去摸这套机制,那会儿你大概率已经被几个更新失败反馈和一个“系统空间不足”的红灯笼搞到加班到凌晨。真正稳妥的办法是在立项和定制阶段就把模块分区、签名策略、空间预留、版本匹配这些前置条件定清楚,后面才不会天天帮线上擦屁股。
这个方向后续还有不少可以继续挖的空间,感兴趣的可以去研究一下动态系统更新和可组合系统镜像对分区规划带来的进一步变化。有一说一,在Android这条路上折腾久了你会发现,系统软件工程最迷人的地方就在于——每一次你以为已经了解全部,新的版本就会冒出来打脸一次。