增量编译原理:输入输出快照、up-to-date 检查机制、文件变更检测

增量编译,说白了就是让 Gradle 只重新编译 真正变了 的代码。而不是每次都从头推一遍。我几年前刚接手一个大型 Android 项目时,一次全量构建要 8 分钟,改一行代码也得等那么久。后来理解了这套机制,把增量编译调顺了,改一行代码的构建时间直接降到 20 秒左右。嗯,这才是开发该有的节奏。

那 Gradle 凭什么知道「这东西没变」呢?核心就是三样东西:输入输出快照、up-to-date 检查、文件变更检测。咱们一个一个说。

输入输出快照——给文件拍个身份证

每个 Gradle 任务都有输入(比如源文件、注解、类路径)和输出(比如编译后的 class 文件、jar 包)。Gradle 在任务执行前,会给这些输入输出拍一张「快照」。快照里存的是什么呢?

  • 文件的路径(标准化后)
  • 文件的内容哈希(默认用 MD5,也可以配置成 SHA-256)
  • 文件的大小 和 最后修改时间戳(辅助判断)
  • 文件是否存在(新增或删除都会被标记)

快照会按照某种格式序列化存储到 .gradle/buildOutputCleanup 或构建缓存里。我习惯把快照理解成一张「文件指纹卡」——只要文件内容没变,指纹就一模一样。

举个例子,一个 Java 编译任务,它的输入快照可能长这样:

Task :compileJava
Input Snapshot (file collection):
  src/main/java/com/example/Hello.java -> md5: a1b2c3d4, size: 1024, modified: 2024-01-15 10:00:00
  src/main/java/com/example/World.java -> md5: e5f6g7h8, size: 2048, modified: 2024-01-15 10:00:05
  build/classes/java/main -> (directory snapshot with children info)
...

注意,快照不光记录单个文件,还记录目录结构。我在项目中遇到过一个问题:某次重构把一些公共类移到新包了,但 Gradle 死活说这些类没变,导致编译出来的 jar 缺少类。后来发现是输入快照里漏了目录变化的检测——其实是我自定义任务没正确声明输出目录的 @OutputDirectory 注解。嗯,这里一定要记住:输入输出声明必须完整,否则快照就是错的。

up-to-date 检查机制——快照对上了就跳过

任务执行前,Gradle 会做一次「up-to-date 检查」。流程其实很简单:

  1. 获取当前快照:对当前文件系统重新计算一次快照。
  2. 获取上一次快照:从构建缓存或本地状态中读出来。
  3. 逐个字段比较:路径、哈希、大小、时间戳、是否存在……只要有一个不一样,就算「过期」。
  4. 如果全部一致,任务被标记为 UP-TO-DATE,直接跳过;否则重新执行。

你想想看,这个过程不需要额外编译,就是比一比哈希和元数据,速度极快。对于一个中等规模的 Android 模块,快照对比通常只需要几百毫秒。

但有个坑需要注意:时间戳比较不可靠。因为某些文件系统(比如某些 NAS 或 CI 环境下的 Linux)时间戳精度不够,或者文件被 copy 后时间戳变了但内容没变。所以我个人建议:依赖内容哈希(md5/sha256)而不是时间戳。Gradle 5.0 之后默认就是用哈希为主、时间戳为辅的策略了,这点很良心。

up-to-date 检查逻辑简表
快照项比较规则常见导致过期的场景
路径路径是否一致移动源文件到不同目录
内容哈希MD5/SHA-256 是否一致修改了代码、资源文件
文件大小字节数是否一致替换了同名但不同大小的文件
最后修改时间时间戳是否一致(参考)文件被 touch 但内容没变(慎用)
是否存在文件列表是否匹配删除了文件或新增了文件

文件变更检测——不只是「变」和「没变」

上面说的快照比较其实是一种增量的全量比对——每次都得遍历所有输入文件。文件多了还是有点开销。所以 Gradle 还提供了更细粒度的文件变更检测机制:

  • 文件系统观察者(FileWatcher):通过操作系统的文件事件(inotify、ReadDirectoryChanges)监控文件变动。Gradle 6.5+ 默认启用了这个功能,在 IDE 中连续构建时非常高效。只要你没退出 daemon,它甚至不需要重新计算快照,直接通过事件就知道哪个文件变了。
  • 相对路径变化跟踪:对于@InputFiles 注解的集合,Gradle 还会记录每个元素相对于集合根路径的偏移。如果你在根目录下新增了一个子目录,它会把这个子目录当作一个整体变化来处理。
  • 输出目录的脏检测:假如任务输出目录里混入了外部生成的文件(比如 IDE 自动生成的 .class 文件),Gradle 会发现输出快照比预期多了文件,从而认为任务过期。我曾经因为队友在 build 目录下手动复制了一个 jar,导致构建一直重复执行整个模块——排查了半天才找到。

核心要点:文件变更检测是 up-to-date 检查的「细化版」。快照比对解决「变了没有」,文件变更检测解决「哪里变了」。两者结合,让 Gradle 能最小化重新编译的范围。

避坑指南

我踩过的坑不少,挑几个典型的说说:

我曾经在一个多模块项目中,某个公共库的 build.gradle 里把 @InputFiles 声明成了 files(getProjectDir())——也就是把整个项目目录都算作输入。结果每次构建,Gradle 都会检查整个项目 tree 的所有文件,包括 .gradle 缓存文件、.idea 配置。快照惨不忍睹,增量编译形同虚设。后来改成了只声明 src/main/java 和 src/main/resources,构建速度直接翻倍。

一个小技巧:如果你怀疑某个任务没有达到预期增量效果,可以在命令行加 --info 参数,Gradle 会打印出 up-to-date 检查失败的原因。比如:“Input property 'source' file 'src/main/java/Foo.java' has changed. Reason: content hash differs.”。这能帮你快速定位问题。

一张图看懂增量编译判断流程

下面的图展示了一个典型编译任务从开始到决定是否跳过的完整流程。我建议你把它截下来,每次排查增量问题时就对照着看。

增量编译判断流程图 任务开始执行 计算当前输入输出快照 与上次快照逐项对比 不一致 完全一致 标记为过期,执行任务 标记为 UP-TO-DATE,跳过 更新快照并写入缓存 更新上次执行时间戳 任务结束 文件变更检测发生在 「计算当前快照」阶段

这张图把整个判断流程画清楚了。你注意看左边红框那条线——只要快照有差异,任务就要重新跑。右边绿框则是我们最想看到的结果:UP-TO-DATE。实际开发中,大部分修改只涉及几个文件,所以大部分模块都能命中绿框。

如果你想验证某个任务是否真的被跳过了,运行 ./gradlew :module:compileJava --debug,搜索 UP-TO-DATE 或 executing 就能看到详细日志。我个人习惯每次改完 build.gradle 配置后都加个 --info 跑一次,确认增量没被意外破坏。