第2章 构建缓存的输入与输出:任务输入、任务输出与缓存键生成规则

构建缓存的核心前提是:Gradle 必须能够精确地判断两个任务是否产生等价的输出。这种判断完全依赖于任务的 输入(Inputs) 与 输出(Outputs) 声明,以及基于这些声明生成的 缓存键(Cache Key)。本章深入解析这三要素的底层机制、配置方法和调试技巧。

核心原则:仅当任务的 所有输入(文件、属性、依赖等)与前一次构建完全相同时,Gradle 才会认定缓存命中,并跳过任务执行,直接复用之前缓存的输出。任何输入变化都会导致缓存键变更,触发重新执行。

2.1 任务输入(Inputs)

任务是 Gradle 构建的最小执行单元。每个任务可以声明多种类型的输入,Gradle 会将这些输入序列化并纳入缓存键计算。

输入类型 注解/API 示例 缓存键作用
文件输入 @InputFile / @InputFiles 源文件、依赖 Jar 文件的路径、内容哈希
属性输入 @Input / @InputProperty 编译器版本、调试标志 属性 toString() 值
目录输入 @InputDirectory 资源目录 目录结构的排序快照 + 文件哈希
嵌套输入 @Nested 自定义输入对象 递归展开所有嵌套输入
类路径输入 @Classpath 编译类路径 只对文件顺序敏感,忽略元数据变化
重要:如果任务没有正确声明所有对输出有影响的输入,Gradle 可能无法正确检测变化,导致 缓存未命中 或更危险的 错误缓存命中(即输出虽然复用但实际已过期)。

声明输入的最佳实践

// build.gradle.kts
abstract class MyTransformTask : DefaultTask() {
    @InputFile
    @get:PathSensitive(PathSensitivity.RELATIVE)
    lateinit var sourceFile: File

    @Input
    var isDebug: Boolean = false

    @Input
    var targetVersion: String = "1.0"

    @OutputFile
    lateinit var outputFile: File

    @TaskAction
    fun transform() {
        // 执行转换
        sourceFile.copyTo(outputFile, overwrite = true)
        if (isDebug) {
            outputFile.appendText("\n// debug mode: $targetVersion")
        }
    }
}

2.2 任务输出(Outputs)

输出是缓存复用的目标。Gradle 必须知道任务会生成哪些文件或目录,才能将构建产物打包进缓存。

输出类型 注解/API 说明
单个文件 @OutputFile 明确的输出文件路径
目录 @OutputDirectory 整个输出目录
多文件 @OutputFiles 多个命名文件(Map)
多目录 @OutputDirectories 多个命名目录
陷阱:如果任务没有声明 @OutputFile 或 @OutputDirectory,Gradle 将不会缓存该任务的输出。即使手动设置了 outputs.cacheIf { true },缓存也只会对空输出进行快照,导致后续构建始终视为“未命中”——因为输出内容从未被记录。

2.3 缓存键的生成规则

缓存键是一个组合哈希,Gradle 在任务执行前计算,用于在缓存中查找匹配的构建结果。缓存键由以下元素组成:

  1. 任务类型唯一标识(类全限定名)
  2. 所有声明输入的哈希值(按规范化顺序排序)
  3. 任务依赖关系(任务之间的序依赖也会影响键)
  4. 构建 Gradle 版本(防止跨版本缓存污染)

文件输入的具体哈希计算规则:

  • 对于 @PathSensitive.RELATIVE 的文件,只哈希文件内容、相对路径、大小、最后修改时间(可配置忽略最后修改时间)。
  • 目录输入会排序所有子文件,递归计算每个文件的哈希。
  • 类路径输入忽略目录元数据(如文件权限),只关心文件内容顺序。

2.4 缓存键生成与命中判断流程

缓存键生成与命中判断流程 任务输入收集 输入规范化与排序 哈希计算生成缓存键 查询本地/远程缓存 命中:复用输出 未命中:执行任务 输出文件内容 + 元数据 → 存入缓存,标记缓存键

2.5 调试缓存键与输入快照

通过以下命令调试任务的具体输入和缓存键:

# 查看任务输入快照(含文件哈希)
gradle -Dorg.gradle.caching.debug=true myTask --info

# 仅查看任务输入输出定义(不执行)
gradle myTask --scan

# 强制重新计算缓存键并打印
gradle clean myTask -Dorg.gradle.internal.caching.verbose=true

日志输出示例:

Property 'sourceFile' file .../src/main.java changed (hash: a1b2c3d4 -> e5f6g7h8)
Cache key for task ':compileJava' computed as: 78a0b1c2d3e4f5...
Cache miss: no entry found with that key
实战练习:在 build.gradle.kts 中添加一个自定义任务,声明 @Input 和 @OutputFile,然后:
  • 执行两次构建,观察第二次是否命中缓存(BUILD SUCCESSFUL in 1s 且任务标记为 FROM-CACHE)。
  • 修改 @Input 属性(如版本号),再次构建,观察缓存未命中并重新执行。
  • 使用 --debug 查看日志中的缓存键变化。

2.6 常见错误与修复动作

症状 根因 修复
缓存总是未命中 任务忘记声明 @OutputFile,或输出路径包含随机值 添加正确的 @OutputFile/@OutputDirectory 注解;使用 Project.file() 固定路径
两次相同输入却未命中 输入声明了时间敏感属性(如 System.currentTimeMillis()) 移除不稳定输入,或将时间戳用 @Internal 排除
缓存错误命中导致过期产物 某个关键输入(如外部脚本)未被声明 使用 @InputFile 声明该脚本,或使用 @Input 捕获其内容哈希
远程缓存存储超时 缓存键过大(文件过多) 减少输入文件数量,使用 @PathSensitive.NAME_ONLY 将大量文件归并
生产警示:当任务使用了 @Nested 注解嵌套自定义对象时,务必实现正确的 equals() 和 hashCode(),否则缓存键计算可能不稳定。建议使用 @Nested 时配合 @Input 确保所有影响输出属性都被快照。

2.7 性能对比:正确声明输入输出来带来的加速

在一个包含 50 个 Java 类的微服务项目中,对比未声明输出和正确声明输入输出后的构建时间(两次构建之间仅修改一个注释):

场景 第一次构建 第二次构建(修改注释) 缓存命中 结果
未声明 @OutputFile 28s 28s(全量编译) 否 无缓存效果
正确声明输入输出 30s(含缓存写入) 3.2s(仅增量 + 缓存命中) 是 加速 90%
输入中包含了时间戳 31s 31s(始终未命中) 否 甚至比全量更慢(额外缓存开销)

从上表可以看出,正确声明输入输出是缓存生效的 前提,而避免不稳定输入是缓存持续命中的 保障。

2.8 本章小结

  • 任务输入决定了缓存键,任何未声明的输入变化都会导致消费者使用过期缓存。
  • 任务输出必须显式声明,否则 Gradle 不会缓存该任务。
  • 缓存键由任务类型 + 所有输入哈希 + 依赖拓扑 + Gradle 版本组成。
  • 使用 --info 和 --debug 调试输入快照和缓存键计算。
  • 常见错误包括未声明输出、不稳定输入、以及嵌套对象的不正确相等实现。