6、任务与任务图:Task定义与配置、任务依赖与排序、任务图API、增量构建、输入输出注解
Gradle 的核心,说白了就是一套任务编排系统。你写的每一个构建动作,最终都会变成一个 Task。我刚开始接触 Gradle 时,总觉得 Task 不就是个「doLast」嘛,后来踩了坑才明白——任务之间的依赖关系、执行顺序、增量构建,才是真正决定构建效率的关键。
这一章,我们就来彻底搞懂 Task 的方方面面。
6.1 Task 的定义与配置
Task 的定义方式有很多种。我个人习惯用 tasks.register 或 tasks.create。区别在哪?register 是延迟创建,只有 Task 被真正需要时才实例化;create 是立即创建。在大型项目中,我强烈建议用 register,能显著减少配置阶段的开销。
tasks.register("hello") {
doLast {
println("Hello, Gradle!")
}
}
你想想看,如果项目里有上百个 Task,每个都用 create,配置阶段会慢得让你怀疑人生。我在一个中型项目中就遇到过这种情况,后来全部改成 register,配置时间从 12 秒降到了 3 秒。
Task 的配置分为两个阶段:
- 配置阶段:Task 对象被创建,属性被赋值,但
doFirst/doLast里的代码不会执行。 - 执行阶段:只有被依赖链命中的 Task 才会执行其动作。
核心要点:配置阶段的代码一定会执行,不管 Task 最终是否运行。所以不要把耗时操作写在配置块里,要写在 doFirst 或 doLast 中。
6.2 任务依赖与排序
任务之间不是孤立的。你打包 APK 之前,肯定要先编译代码。这就是依赖关系。
Gradle 提供了几种声明依赖的方式:
| 方式 | 说明 | 示例 |
|---|---|---|
dependsOn |
强依赖,先执行依赖的任务 | taskB.dependsOn(taskA) |
mustRunAfter |
排序约束,不强制依赖 | taskB.mustRunAfter(taskA) |
shouldRunAfter |
软排序,尽量在后 | taskB.shouldRunAfter(taskA) |
嗯,这里要注意:mustRunAfter 和 dependsOn 不是一回事。dependsOn 会强制执行依赖的任务,而 mustRunAfter 只保证顺序,如果两个任务都在执行计划中,那 B 一定在 A 之后。但如果 A 不在执行计划里,B 照样能跑。
我曾经在项目中犯过一个错:用 mustRunAfter 代替 dependsOn,结果某个清理任务没被执行,导致构建缓存脏了,排查了半天。所以,如果你需要「先做 A 再做 B」,用 dependsOn;如果你只是「如果 A 存在,请让 B 在它后面」,用 mustRunAfter。
6.3 任务图 API
Gradle 在构建过程中会构建一张「任务执行图」(Task Execution Graph)。这张图决定了哪些 Task 要执行,以及执行的顺序。
我们可以通过 gradle.taskGraph 来监听任务图的生命周期:
gradle.taskGraph.whenReady { graph ->
println("任务图已就绪,包含 ${graph.allTasks.size} 个任务")
graph.allTasks.forEach { task ->
println(" - ${task.path}")
}
}
gradle.taskGraph.beforeTask { task ->
println("即将执行: ${task.name}")
}
gradle.taskGraph.afterTask { task, state ->
if (state.failure != null) {
println("任务 ${task.name} 执行失败: ${state.failure.message}")
} else {
println("任务 ${task.name} 执行成功")
}
}
这个 API 在调试构建问题时特别有用。我记得有一次,构建莫名其妙多跑了一个 Task,我就是在 whenReady 里打印了所有任务,才发现是某个插件偷偷注册了依赖。
小技巧:在 whenReady 回调中,你可以动态修改任务的 enabled 状态,或者调整 mustRunAfter 顺序。但注意,此时不能再添加新的 Task。
6.4 增量构建
增量构建是 Gradle 提升构建速度的杀手锏。说白了就是:如果输入没变,输出就不重新生成。
Gradle 通过比较「输入文件的快照」和「输出文件的快照」来判断是否需要重新执行 Task。如果两者都没变,Task 就会被标记为 UP-TO-DATE,直接跳过。
要实现增量构建,你需要告诉 Gradle 两件事:
- 输入是什么:源文件、配置文件、参数等。
- 输出是什么:生成的 APK、class 文件、报告等。
看个例子:
abstract class MyTransformTask : DefaultTask() {
@get:InputFiles
abstract val inputFiles: ConfigurableFileCollection
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun transform() {
// 处理输入文件,生成输出
inputFiles.forEach { file ->
val outputFile = outputDir.file(file.name).get().asFile
file.copyTo(outputFile, overwrite = true)
}
}
}
这里 @InputFiles 和 @OutputDirectory 就是增量构建的关键注解。Gradle 会记录这些路径下文件的哈希值,下次构建时对比。如果没变化,直接跳过。
注意:如果你忘了声明 @OutputFiles 或 @OutputDirectory,Gradle 会认为这个 Task 没有输出,每次都会重新执行。我曾经在自定义插件中漏掉了输出注解,导致每次构建都全量编译,被同事吐槽了好久。
6.5 输入输出注解详解
Gradle 提供了一套注解体系,用来标记 Task 的输入和输出。下面是我最常用的几个:
| 注解 | 作用 | 适用类型 |
|---|---|---|
@Input |
单个输入值 | Property<String>、int 等 |
@InputFile |
单个输入文件 | RegularFileProperty |
@InputFiles |
多个输入文件 | ConfigurableFileCollection |
@InputDirectory |
输入目录 | DirectoryProperty |
@OutputFile |
单个输出文件 | RegularFileProperty |
@OutputFiles |
多个输出文件 | MapProperty<String, RegularFile> |
@OutputDirectory |
输出目录 | DirectoryProperty |
@Nested |
嵌套的输入对象 | 自定义对象 |
你可能会问:@Input 和 @InputFile 有什么区别?@Input 比较的是值的 toString() 或 equals(),而 @InputFile 比较的是文件内容的哈希值。所以,如果你传的是一个文件路径字符串,用 @Input 的话,路径变了才会触发重新执行;用 @InputFile 的话,文件内容变了就会触发。
我个人习惯:能用 @InputFile 就别用 @Input,因为文件内容变化才是真正需要重新构建的原因。路径变了但内容没变,重新执行就是浪费。
还有一个容易被忽略的注解:@Incremental。它配合 @InputFiles 使用,可以让你的 Task 知道哪些文件发生了变化,从而实现更细粒度的增量处理:
@Incremental
@InputFiles
abstract val inputFiles: ConfigurableFileCollection
@TaskAction
fun process(IncrementalTaskInputs inputs) {
inputs.outOfDate { change ->
println("文件 ${change.file.name} 已变更,需要处理")
}
inputs.removed { change ->
println("文件 ${change.file.name} 已被删除")
}
}
这个 API 在编译型任务中特别有用。比如你的插件只处理新增或修改的文件,而不是全量扫描,构建速度能提升一个数量级。
总结一下:增量构建不是自动生效的,你需要通过注解明确告诉 Gradle 什么是输入、什么是输出。漏掉任何一个注解,都可能导致构建缓存失效。我建议你在写完自定义 Task 后,用 --info 参数跑一次构建,看看有没有 NO-SOURCE 或 UP-TO-DATE 的提示,来验证你的注解是否完整。
这张图把整个流程串起来了:从 Task 定义开始,经过依赖排序和任务图构建,最终落到增量构建的输入输出注解上。每次构建时,Gradle 都会走一遍这个流程,决定哪些 Task 需要重新执行,哪些可以直接跳过。
理解了这些,你就能写出既高效又可靠的构建脚本。下次遇到构建慢的问题,别急着加 --no-build-cache,先检查一下你的 Task 有没有正确声明输入输出注解——八成问题就出在这儿。