常量引用分析
开发者修改 Java static final 常量或 Kotlin const val 后,定义方可以正常编译,但未修改的使用方仍可能携带旧字面量。普通结构传播无法从 DEX 的方法调用或字段访问中找回这些使用方,Jugg 因此在源码侧记录常量定义和引用候选,并在常量真实变化后把命中的使用方加入下一轮编译。
常量内联为什么让使用方保留旧值
Java 的编译期常量和 Kotlin const val 会在编译时内联进使用方字节码。引用 Config.VERSION 的源码编译后,使用方保存的是常量值本身,而不是每次运行时读取 Config.VERSION。
修改 Config.VERSION
-> 只重新编译 Config
-> 未修改的调用方仍携带旧字面量
-> 本轮编译成功
-> App 继续显示或使用旧值普通重编译传播依赖方法调用、字段访问和继承关系。常量被内联后,这些运行时引用已经不在使用方字节码中,因此只分析新旧 class 结构无法发现需要补编译的源码。
Jugg 如何在字节码之外找回使用方
Jugg 为编译期常量维护独立的源码索引,记录两类事实:
| 索引内容 | 记录的信息 |
|---|---|
| 常量定义 | 所在文件、包、类、常量名、类型和值。 |
| 引用候选 | 源码中形态上可能引用某个常量的位置,以及相关的类名、包和 import 信息。 |
引用候选不要求对应的常量定义已经被扫描。这样扫描顺序不会隐藏较早出现的使用方,后台全量扫描尚未完成时,也可以先使用已经落盘的候选结果。
这套索引不做完整语义解析,而是根据源码形态保守匹配。它可能额外编译候选文件,但不会为了等待整个工程完成语义分析而阻塞每次增量编译。
只有真实常量变化才触发补编译
Jugg 会比较同一文件分析前后的常量定义。常量名对应的类型和值组成签名;只有定义新增、签名变化或定义消失时,才会产生需要查询的变化键。空白、格式或其他不改变常量定义的修改不会触发常量引用补编译。
删除常量,或者把 const val 改成普通 val,会产生被移除键。使用方此前已经内联了旧值,因此 Jugg 仍会使用旧的引用候选查找并补编译这些源码。
命中的使用方如何进入下一轮编译
编译前,Jugg 会优先完成本轮变化源码的常量分析,再查询可能受影响的使用方:
分析本轮变化源码
-> 找出新增、变化或移除的常量
-> 匹配已经记录的源码引用候选
-> 与普通结构传播命中的源码合并
-> 加入下一轮源码编译查询会排除本轮已经编译的变化源码和已经删除的文件,并对重复结果去重。匹配规则偏向减少漏编,因此一次常量修改也可能追加编译多个候选使用方。
失败轮为什么不会丢掉同一批影响
影响查询只读取本轮常量变化,不会立即清除这些变化记录。只有补编译和部署全部成功后,Jugg 才确认消费对应记录。
如果后续源码编译、影响传播或部署失败,常量变化仍会保留。下一次运行可以重新查询同一批使用方,避免失败轮提前推进状态,导致旧字面量永久漏过后续编译。
分析范围与 Best-effort 边界
常量引用分析有明确的收录范围:
- Java 只记录可参与内联的
static final字段。 - Kotlin 记录顶层、object、companion,以及嵌套 class / object 中的
const val。 private const val和private static final不进入索引。它们只能影响声明所在源码文件,而该文件已经在首轮编译中。- 注释和字符串文本中的相似写法不会被视为引用候选。
索引会按文件内容复用落盘结果,同一仓库的多个 worktree 也可以共享文件指纹。这些缓存只减少重复解析,不改变常量变化和引用候选的判断来源。
常量引用分析采用 Best-effort 方式接入编译流程。编译前等待分析有超时上限;超时后会记录 warning,并使用已经完成的缓存继续查询。查询或缓存初始化失败时,本轮返回空结果,不会仅因为常量引用分析失败就中断编译或触发 Gradle 回退。
这意味着降级时本轮可能无法补齐全部使用方,设备上的代码仍可能保留旧值。出现常量修改未生效时,应执行 Gradle 构建恢复完整编译结果,并从编译日志中的 ConstRef warning 开始排查。