第3章 缓存键与输入确定性:Task输入快照、文件内容指纹、注解输入的影响
3.1 概述
Gradle 的构建缓存(Build Cache)采用 键值对存储 机制:缓存的 Key 由 Task 的输入快照(Input Snapshot)通过哈希算法计算得出。任何输入的变化——无论是文件内容、属性值还是注解触发的副作用——都直接改变 Key,从而导致缓存 Miss。本章将深入剖析缓存的 Key 构成要素、文件内容指纹算法、注解对输入快照的影响,以及如何通过工程化手段确保输入确定性,最大化缓存复用率。
3.2 缓存键的构成原理
Gradle 为每个 Task 自动生成一个 CacheKey,其计算过程包含以下三类输入的快照摘要:
- Task 类名与属性:Task 实现类的全限定名(包含服务端和分版本唯一标识),以及通过
@Input、@InputFile、@InputDirectory等注解声明的属性值。 - 文件内容指纹:对
@InputFile和@InputDirectory引用的文件内容进行 Hash 计算(默认使用 MD5 或 SHA-256,可通过CacheConfig配置)。 - Task 内部注解输入:如
@OutputFile路径快照(虽然输出不参与 Key,但路径的归一化会影响缓存的边界)、@Nested输入对象的递归快照。
最终 Key = HASH( HASH(TaskClass + InputProperties) + HASH(InputFiles) + HASH(InputDirectories) + HASH(NestedInputs) )。
| 输入类型 | 注解/声明 | 参与 Key 的方式 | 影响范围 |
|---|---|---|---|
| 属性值 | @Input | 序列化后计算 Hash | 任意字符串、布尔、数字 |
| 单个文件 | @InputFile | 文件的完整内容指纹 | 文件内容变化触发缓存失效 |
| 文件目录 | @InputDirectory | 目录下所有文件的路径+内容指纹合并 | 新增/删除/修改文件均影响 Key |
| 嵌套对象 | @Nested | 递归计算嵌套对象的所有输入快照 | 深层属性变化也会传播 |
| 文件路径 | @OutputFile / @OutputDirectory | 路径归一化后参与快照(不直接参与内容) | 输出路径变更导致缓存分离 |
3.3 文件内容指纹算法与确定性
Gradle 使用 增量文件哈希(Incremental File Hasher) 计算文件内容指纹。默认策略:
- 对于小于 64 KiB 的文件:读取完整内容,计算 MD5。
- 对于更大的文件:以块(默认 4 KiB)为单位逐步读取,构建 Merkle Tree 摘要,最终合并为 128 bit 的 Hash。
这种设计保证了 确定性和稳定性:
- 同一文件在不同操作系统、不同时间戳下始终产生相同指纹(除非内容被修改)。
- 新增或删除文件时,目录快照会重新生成完全不同的 Hash(避免 Hash 碰撞)。
工程经验: 如果构建环境中存在无关的文件变更(如 CI 机器上的 IDE 元数据目录 .idea/),务必使用
@InputDirectory 的 includeEmptyDirs = false 和 filter 属性排除噪声文件,否则会降低缓存命中率。
// 示例:精确控制输入文件的确定性
abstract class MyTask extends DefaultTask {
@InputFile
@PathSensitive(PathSensitivity.RELATIVE)
final RegularFileProperty inputFile = project.objects.fileProperty()
@InputFiles
@PathSensitive(PathSensitivity.RELATIVE)
@SkipWhenEmpty
final ConfigurableFileCollection sourceFiles = project.objects.fileCollection()
@TaskAction
void doSomething() {
// 实际处理逻辑
}
}
3.4 注解输入对缓存键的影响
Gradle 提供一组 缓存相关注解,开发者必须正确使用才能确保输入确定性:
@Input:标记普通输入属性。如果属性值是File或List<File>,必须使用@InputFile或@InputFiles来让 Gradle 感知文件内容。@InputFile/@InputFiles/@InputDirectory:分别标记单个文件、文件集合、整个目录作为输入。@Nested:当输入是一个复杂对象时,使用@Nested让 Gradle 递归检测该对象内部的所有输入注解。注意不支持循环引用。@Internal:标记不会影响输出的中间变量,不参与 Key 计算。@Classpath:类似@InputFiles,但专门用于类路径输入,并启用类文件快照优化(只比较类名和接口,而不依赖全部字节码)。
常见错误: 误将文件路径声明为
@Input 而非 @InputFile。此时 Gradle 仅将路径字符串参与 Hash,文件内容变更不会触发缓存失效,导致构建使用过期的缓存输出——这是 缓存污染 的主要原因。
以下对比展示了正确与错误注解的影响:
| 声明方式 | Key 是否感知文件内容 | 典型后果 |
|---|---|---|
@InputFile | 是(依赖完整文件指纹) | 文件变动 => 缓存 Miss => 正确重新构建 |
@Input (但值为 File 类型) | 否(仅序列化 File.toString()) | 文件内容变化 => 缓存 Hit(用旧输出) => 构建结果错误 |
@InputDirectory | 是(递归全部文件指纹) | 目录内任意文件变化 => 缓存失效 |
3.5 调试缓存键:可用工具与日志分析
Gradle 提供 --scan 和 --build-cache-debug 参数来输出详细的缓存键计算过程。常用调试步骤:
- 执行
gradle assemble --build-cache-debug,日志中会打印每个 Task 输入快照的 Hash 值。 - 通过
--scan生成 HTML 报告,在 Performance → Task execution 面板查看每个 Task 的缓存 ID(Cache Key)。 - 如果两次构建同一个 Task,但缓存 Key 不同,使用
diff比较 Key 的生成树(./gradlew :project:task --build-cache-debug 2>&1 | grep "snapshot")。
# 命令行示例:开启缓存调试,输出 Key 构成
./gradlew compileJava --build-cache-debug --info 2>&1 | grep "cache key"
# 输出示例:
# Cache key for task ':app:compileJava': f3c4a68b9e1d2c7b5a0f...
# Input file snapshot for 'src/main/java/com/example/App.java': MD5 8a2b3c4d5e6f...
# Input file snapshot for 'src/main/resources/application.properties': MD5 1a2b3c4d...
3.6 性能优化:最小化输入快照计算范围
文件指纹计算是 I/O 密集型操作,优化方向是 缩小扫描范围:
- 使用
@PathSensitive:指定相对路径还是路径。推荐PathSensitivity.RELATIVE,避免路径在不同构建机导致不同 Key。 - 使用
@SkipWhenEmpty:如果文件集合可能为空,加上此注解让 Gradle 跳过空集合的 Hash 计算。 - 排除无关文件:通过
exclude方法过滤源文件集合中的测试资源、生成文件。 - 启用 Classpath 快照优化:使用
@Classpath而非@InputFiles处理 jar 包输入,避免对整个 jar 做内容指纹(只校验类名和结构摘要)。
// 最佳实践:精确控制输入范围
abstract class CodeGenerator extends DefaultTask {
// 只关心 src/main/resources 下的 .properties 文件
@InputFiles
@PathSensitive(PathSensitivity.RELATIVE)
@SkipWhenEmpty
ConfigurableFileCollection templateFiles = project.objects.fileCollection()
.from(project.fileTree("src/main/resources") {
include "**/*.properties"
exclude "**/dev.properties" // 排除开发环境配置文件
})
@Input
Property version = project.objects.property(String)
@TaskAction
void generate() { /* ... */ }
}
3.7 常见问题排查
问题1:构建两次但缓存始终 Miss
- 检查是否使用了
@OutputDirectory的路径中含有临时目录(如/tmp/),导致路径归一化后不同。 - 检查是否存在 不可靠的输入属性,例如将当前时间戳或随机数作为
@Input属性。 - 通过
--build-cache-debug比较两次的 Key 日志,定位变化的组件。
问题2:测试发现缓存命中但输出内容过时
- 很可能将
@InputFile@Input,如上文常见错误。 - 使用
@Nested的对象内部可能存在未标注@Input的字段,导致深层属性变化不触发缓存失效。 - 解决办法:为所有可能影响输出的字段添加正确的注解,并通过单元测试验证缓存行为(模拟文件变更)。
3.8 缓存键确定性流程图
下图展示了 Gradle 计算 Task 缓存 Key 的核心流程,包含输入快照、指纹合并、注解执行路径:
3.9 小结
缓存键的确定性是构建缓存复用的基石。开发者必须:
- 正确使用
@Input、@InputFile、@InputDirectory、@Nested等注解,避免将文件路径误标为普通输入。 - 利用
@PathSensitive、@SkipWhenEmpty、exclude等方法缩小快照计算范围,提升性能。 - 结合
--build-cache-debug和--scan分析 Key 的变化根因,快速定位缓存失效源头。