第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 参数来输出详细的缓存键计算过程。常用调试步骤:

  1. 执行 gradle assemble --build-cache-debug,日志中会打印每个 Task 输入快照的 Hash 值。
  2. 通过 --scan 生成 HTML 报告,在 Performance → Task execution 面板查看每个 Task 的缓存 ID(Cache Key)。
  3. 如果两次构建同一个 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 的核心流程,包含输入快照、指纹合并、注解执行路径:

Gradle 缓存键计算流程 Task 类名 + @Input 属性值 序列化 → HASH(A) @InputFile 文件内容指纹 以块读取 → MD5/Merkle → HASH(B) @InputDirectory 目录快照 遍历所有文件 → 合并指纹 → HASH(C) @Nested 嵌套对象递归快照 递归提取所有输入注解 → HASH(D) 最终 CacheKey = HASH(A+B+C+D) 使用 SHA-256 确保低碰撞概率 缓存远端存储 / 本地命中 只要任意子快照 HASH(A/B/C/D) 发生变化,最终 Key 即改变,导致缓存 Miss

3.9 小结

缓存键的确定性是构建缓存复用的基石。开发者必须:

  • 正确使用 @Input、@InputFile、@InputDirectory、@Nested 等注解,避免将文件路径误标为普通输入。
  • 利用 @PathSensitive、@SkipWhenEmpty、exclude 等方法缩小快照计算范围,提升性能。
  • 结合 --build-cache-debug 和 --scan 分析 Key 的变化根因,快速定位缓存失效源头。