Skip to content

重编译 / 扩散编译

日常增量编译的目标是尽量少编文件,但“少编”不能只看本轮改了哪些源码。一次改动可能改变旧 APK 中其他 class 的调用前提:调用方、子类或泛型使用方没有被重新编译时,设备上会同时存在新定义和旧引用。

扩散编译补的就是这个缺口:首轮编译成功后,再判断哪些旧源码会被这次结构变化影响,把它们补进下一轮编译。这样 Jugg 才能在不回到完整 Gradle 构建的前提下,尽量保持增量结果与全量构建一致。

编译成功不等于运行安全

最朴素的增量做法是只编译本轮直接改动的文件。但这在结构性改动下并不安全:当你删除一个方法、修改字段签名,或给抽象父类新增抽象方法时,改动文件自己能编译通过,可它的旧调用方和旧子类还停留在 APK 里,没有被重新编译。

这类不一致不会在编译期暴露,而是等到运行时调用那段旧代码才抛出 NoSuchMethodErrorAbstractMethodError。“编译成功”并不等于“运行安全”,缺的是一轮对受影响范围的补编译。

结构对比与引用索引:补出受影响范围

补回这一轮,先要弄清“谁会受影响”。Jugg 在首轮源码编译成功后,再做一次扩散分析:先看本轮 class 的结构变了什么,再反查谁引用了这些变化,把受影响的旧源码拉进下一轮编译。

text
首轮编译成功
  -> 解析本轮 DEX,新 class 与基线 class 做结构对比
  -> 把差异归成几类结构信号
  -> 用引用索引反查受影响的调用方、子类和源码
  -> 把这些源码当作新输入,递归进入下一轮编译

这里的"受影响范围"还只是本轮的待编译判断,不是已生效状态。只有整轮部署成功后,相关历史才会提交推进;如果后续编译或部署失败,同一批影响在下一次编译中仍然可查。

结构对比产出的信号

新旧 class 对比会把差异压成几类信号,每类信号对应一种反查方向:

结构变化反查方向
方法删除、签名变化、有效访问修饰变化查方法调用方。
字段删除查字段访问方。
抽象父类或接口新增抽象方法查所有子类。
类级泛型签名变化查直接成员调用方,并沿继承链查子类。

仅仅是方法体内部逻辑变化,不会进入上述信号。这类改动可以直接在线替换,无需扩散。

反查如何收敛

反查依据的是对基线 APK 和已部署产物建立的引用索引(谁调用了谁、谁继承了谁)。它按一条固定路径收敛,避免无限展开:

  1. 把变化的方法、字段、抽象方法、泛型类映射到索引中的类标识。
  2. 对非静态的变化方法,沿继承关系补出子类在虚方法分发下也会受影响的调用点。
  3. 查引用关系,找到直接调用变化方法或访问变化字段的类。
  4. 对新增抽象方法的抽象类 / 接口,递归向下找子类。
  5. 对泛型签名变化的类,查它的直接成员调用方,并递归找子类。
  6. 把命中的类反查回源码路径,交给下一轮编译。

传播的防御性约束

收敛路径之外,这套传播还靠几条约束防漏编和误编:

  • R 类不参与方法 / 字段传播。 资源修复会产生大量 R 字段变化,如果直接传播,会把所有引用 R 的源码都拉进重编译。R 类因此整体跳过结构传播。
  • 静态方法不作为子类遍历的起点。 静态方法没有虚方法分发,本不该沿继承链向下扩散;但它仍要保留在"直接调用方"的查询里。Jugg 用访问修饰把这两件事区分开:静态方法能被第 3 步查到直接调用方,但不会触发第 2 步的子类级联,避免 lambda、合成方法这类静态方法引发整棵子类树的误重编译。
  • 下一轮去重。 上一轮已经编译过的源码不会重复编译;同一轮内对相同影响也只跟编一次。只有出现新的触发来源(例如某个定义方结构变化后首次要求重编它的调用方)才会继续下一轮。Kotlin 顶层声明门面是一个例外,它命中时可以突破"上一轮已编译"的过滤再编一次。

release / minify 下的额外补偿

release 变体经过 R8 / ProGuard 处理后,扩散分析还要额外查两类补偿场景:

场景现象处理
引用了已被移除的类或成员本轮 DEX 引用了在 APK 里已被 R8 / ProGuard 裁剪掉的类或成员走 minify 字节码补偿。
内联实现已变化mapping.txt 找到曾经被内联进别处的方法,其调用方持有旧内联副本走 minify 字节码补偿。

源码重编译的修复能力强于字节码补偿:同一个类如果已经被判定为需要源码重编译,合并时保留源码重编译,不再降级为字节码补偿。

与常量引用的关系

编译期常量(const val / static final)的内联不走方法 / 字段 / 子类这套结构传播,而是用一套独立的常量定义与引用索引单独分析,最后再把命中的源码与普通受影响源码合并进下一轮。原因见常量引用分析

相关页面