6、任务与任务图:Task定义与配置、任务依赖与排序、任务图API、增量构建、输入输出注解

Gradle 的核心,说白了就是一套任务编排系统。你写的每一个构建动作,最终都会变成一个 Task。我刚开始接触 Gradle 时,总觉得 Task 不就是个「doLast」嘛,后来踩了坑才明白——任务之间的依赖关系、执行顺序、增量构建,才是真正决定构建效率的关键。

这一章,我们就来彻底搞懂 Task 的方方面面。

6.1 Task 的定义与配置

Task 的定义方式有很多种。我个人习惯用 tasks.registertasks.create。区别在哪?register 是延迟创建,只有 Task 被真正需要时才实例化;create 是立即创建。在大型项目中,我强烈建议用 register,能显著减少配置阶段的开销。

tasks.register("hello") {
    doLast {
        println("Hello, Gradle!")
    }
}

你想想看,如果项目里有上百个 Task,每个都用 create,配置阶段会慢得让你怀疑人生。我在一个中型项目中就遇到过这种情况,后来全部改成 register,配置时间从 12 秒降到了 3 秒。

Task 的配置分为两个阶段:

  • 配置阶段:Task 对象被创建,属性被赋值,但 doFirst/doLast 里的代码不会执行。
  • 执行阶段:只有被依赖链命中的 Task 才会执行其动作。

核心要点:配置阶段的代码一定会执行,不管 Task 最终是否运行。所以不要把耗时操作写在配置块里,要写在 doFirstdoLast 中。

6.2 任务依赖与排序

任务之间不是孤立的。你打包 APK 之前,肯定要先编译代码。这就是依赖关系。

Gradle 提供了几种声明依赖的方式:

方式 说明 示例
dependsOn 强依赖,先执行依赖的任务 taskB.dependsOn(taskA)
mustRunAfter 排序约束,不强制依赖 taskB.mustRunAfter(taskA)
shouldRunAfter 软排序,尽量在后 taskB.shouldRunAfter(taskA)

嗯,这里要注意:mustRunAfterdependsOn 不是一回事。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 两件事:

  1. 输入是什么:源文件、配置文件、参数等。
  2. 输出是什么:生成的 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-SOURCEUP-TO-DATE 的提示,来验证你的注解是否完整。

Gradle 任务与增量构建核心逻辑 Task 定义 register / create 任务依赖与排序 dependsOn / mustRunAfter 任务图 API whenReady / beforeTask 增量构建核心机制 @Input / @InputFile 标记输入 @OutputFile / @OutputDirectory 标记输出 @Incremental 细粒度增量 构建结果状态 UP-TO-DATE(跳过) | EXECUTED(执行) | FROM-CACHE(缓存)

这张图把整个流程串起来了:从 Task 定义开始,经过依赖排序和任务图构建,最终落到增量构建的输入输出注解上。每次构建时,Gradle 都会走一遍这个流程,决定哪些 Task 需要重新执行,哪些可以直接跳过。

理解了这些,你就能写出既高效又可靠的构建脚本。下次遇到构建慢的问题,别急着加 --no-build-cache,先检查一下你的 Task 有没有正确声明输入输出注解——八成问题就出在这儿。