部署数据与影响分析
增量编译产出变化产物后,部署前还要回答两个问题:哪些产物下发给设备,哪些源码必须再编译一轮。这一步决定了部署结果是否与一次完整构建等价。
部署阶段需要同时处理两类事实:旧 APK 中仍然引用本轮变化的调用方,以及不同产物在设备上的生效路径。DEX、resource overlay、Manifest 和 native lib 的生效方式不同,Jugg 先做影响分析和产物归类,再决定继续补编译、在线替换、热修复或写回 APK。
只部署改动文件为什么不安全
最直觉的增量做法是“改了哪个 class 就只下发哪个 class”。但 class 之间存在引用关系:删掉一个方法、改掉一个字段签名,被改 class 自身能编译通过,可旧的调用方仍留在设备上的 APK 或已部署历史里,运行时继续按旧签名调用,于是抛出 NoSuchMethodError、AbstractMethodError 这类问题。
编译期只看到“被直接修改的文件”,运行时崩溃却发生在“依赖这些文件的调用方”。增量部署要正确,就必须在下发前把受影响的调用方一并找回来重新编译。Jugg 把这一步拆成两件事:把产物按生效方式分组,以及沿引用关系做防御性扩散补编译。
把产物按生效方式分组
Jugg 不按“文件类型”分组,而按“在设备上如何生效”分组。每一组对应一条部署路径,避免用错误的方式应用产物。
| 产物分组 | 包含什么 | 生效方式 |
|---|---|---|
| 新增 class | 旧索引里不存在的 class | 随热修复或重启路径生效 |
| 可在线替换的 class | 新旧结构对比后判定结构未变的 class | 走 Apply Changes / JVMTI 在线替换 |
| 需重启生效的 class | 结构变化、跨 dex 或库 dex 归属更复杂的 class | 走热修复,重启后生效 |
| 资源与 assets overlay | 资源、assets 编译产物 | 以 overlay 下发;首轮会补齐全量资源 |
| 写回 APK 的文件 | Manifest、配套 resources.arsc、native lib | 写进 APK、重签名后安装 |
| 受影响调用方 | 影响分析找回的源码 | 加入下一轮源码编译 |
| 常量引用命中源码 | 常量引用分析单独命中的源码 | 单独入口,加入下一轮源码编译 |
本轮走在线替换、热修复还是写回 APK,由这些分组反推:有写回 APK 的文件就先更新 APK,有需重启生效的 class 就本轮重启 App,其余可在线替换的部分尽量在线生效。
NOTE
资源首次以 overlay 下发时,会补齐一份全量资源,避免设备端 overlay 缺文件。所以不能只用“本轮改了几个资源”来判断设备端资源是否完整。
引用扩散:把受影响调用方找回来
影响分析的核心是新旧结构对比加引用查询。Jugg 先比较被改 class 的新旧结构,把变化压缩成几类信号,再用 APK 解析索引和增量部署索引查出引用方,最终落到需要重编译的源码文件。
变化 class
-> 对比方法、字段、抽象方法和类级泛型签名
-> 沿引用索引查调用方、字段访问方、子类
-> 把受影响源码加入下一轮编译会触发调用方重编译的结构信号包括:
- 方法删除、方法签名变化、
private与非private之间切换,或其他会改变调用约定的访问标志变化。 - 字段删除。
- 抽象父类或接口新增抽象方法。
- 类级泛型签名变化。
扩散有明确的传播规则:实例方法的变化会沿子类继续传播,因为虚方法分发可能落到子类;静态方法只查直接引用方,不启动子类遍历,否则编译器生成的静态合成方法会误触发整棵子类级联重编译。仅方法体内部变化(结构不变)不触发调用方重编译,因为它能安全在线替换。
release/minify 下的字节码补偿
release 构建经过 R8/ProGuard 的内联与裁剪,普通源码引用分析不足以覆盖两类隐患,需要额外补偿:
- 被移除的成员:增量 DEX 引用了 APK 中已被裁剪掉的类或成员,需要补回。
- 被内联的实现:某个被改方法曾经被内联进调用方,改了定义却没改内联副本,需要把持有旧副本的调用方一起补偿。
这两类补偿是字节码层面的修正。合并结果时有一条优先级:如果某个 class 已经被判定需要源码重编译,就保留源码重编译,不降级为字节码补偿。源码重编译覆盖面更大,反向不成立。
常量引用为什么单独走一条入口
常量引用是一个容易被漏掉的边界。const val 这类常量在编译期会被内联进所有使用方,改了定义方,使用方的字节码里却还是旧值。普通的类引用扩散追踪的是“运行时引用”,看不到这种“编译期已固化”的使用点。
因此常量引用有独立的分析入口,命中的源码单独加入下一轮编译,最后再和普通受影响源码合并。这样改一个常量不会只更新定义方而漏掉全部内联使用方。
影响分析的边界
这套分析依赖几个前提,也有清晰边界。它为了正确会把受影响调用方都拉进来,扩散范围越大,本轮要编译的文件就越多;当范围过大时,继续增量未必比直接 Gradle 更快。泛型扩散也不是无所不包:它只覆盖子类声明链和对变化类的直接引用方,DEX 擦除后那些没有直接成员引用的间接泛型约束变化,不保证一定命中。
历史索引也必须可信。引用查询基于上一次基线建立的 APK 与部署索引,一旦基线过期或被旁路改动,分析结果也会失真,这时需要回到 Gradle 重建基线。还要分清职责:产物分组只决定“怎么发”,不决定“一定成功”;能否在设备上真正生效,仍取决于设备状态和运行时兼容性,见部署策略与部署状态与恢复。