第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 在任务执行前计算,用于在缓存中查找匹配的构建结果。缓存键由以下元素组成:
- 任务类型唯一标识(类全限定名)
- 所有声明输入的哈希值(按规范化顺序排序)
- 任务依赖关系(任务之间的序依赖也会影响键)
- 构建 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调试输入快照和缓存键计算。 - 常见错误包括未声明输出、不稳定输入、以及嵌套对象的不正确相等实现。