任务依赖与任务图:你的构建骨架

说实话,如果把 Gradle 构建比作一场接力赛,那任务依赖就是交接棒规则。没有规则,大家乱跑一气,构建肯定失败。我自己刚接触 Gradle 时,就曾天真地以为任务会自动按顺序执行……后来被一把编译错误打醒。嗯,今天我们就来聊透这个核心机制。

任务依赖——谁先谁后,你说了算

Gradle 里,任务之间最常用的依赖关系就是 dependsOn。举个例子:你想打包一个 Jar 文件,得先编译好 .class 文件。那么这个打包任务就必须依赖编译任务。

// build.gradle.kts
tasks.register("compile") {
    doLast {
        println("编译完成")
    }
}

tasks.register("jar") {
    dependsOn("compile")
    doLast {
        println("打包成 jar")
    }
}

当你执行 gradle jar,Gradle 会自动先跑 compile,再跑 jar。这里有个小细节:dependsOn 可以接受多个任务名,也可以接受 Task 对象,甚至一个 Provider。我个人习惯用字符串,可读性高一些。

小提示: 多个依赖任务之间没有先后顺序,Gradle 会并行执行它们(如果配置了并行构建)。如果你非要 A 在 B 之前跑,让 A 直接 dependsOn B 就好。

任务图——你的构建地图

每次执行一个任务,Gradle 都会根据所有依赖关系构建出一个有向无环图(DAG)。这图就是整个执行计划。我遇到过不少同事直接写任务循环依赖,结果 Gradle 直接报错,不给你任何侥幸机会。

避坑指南: 我曾经在项目里让 taskA 依赖 taskB,同时又让 taskB 依赖 taskA,结果 Gradle 提示“Circular dependency”。记住:任务图不能有环,这是铁律。

咱们用一张图来直观理解一下任务图和依赖的关系:

任务依赖图示例 clean compileJava processResources classes jar test assemble 图例: 红: clean / assemble 蓝: 编译任务 绿/紫/橙/青: 资源/打包/测试

你看,classes 任务就依赖 compileJava 和 processResources。而 jar 又依赖于 classes。最终 assemble 依赖 jar 和 test。整个图就是一条条依赖线串起来的树状结构。

任务执行顺序——不只是“先执行依赖”

你可能会问:既然依赖决定顺序,那图上平行的任务(比如 compileJava 和 processResources)谁先跑?

答案是:不确定。默认情况下,Gradle 会按照任务注册或声明的顺序来遍历,但如果你开启了并行构建(--parallel),它们会同时跑。这不是 bug,这就是设计——想要严格顺序?用 mustRunAfter 或 shouldRunAfter。

tasks.named("compileJava") {
    shouldRunAfter("processResources")
}
关键区别:
  • dependsOn:强依赖,A 必须等 B 完成且 B 必须执行。
  • mustRunAfter:只排序,不引入依赖。如果 B 没被选中执行,A 不会强制拉 B 进来。
  • shouldRunAfter:软排序,Gradle 会尽量满足,但不强求(比如并行时可能忽略)。

我在一个微服务项目里就用过 shouldRunAfter:两个无关的单元测试任务,我希望它们顺序运行为了好排查日志,但又不想因为它们互相等待而拖慢构建。结果效果不错,大部分情况都按我的顺序跑了,偶尔并行也不报错。

依赖对增量构建的影响——一个很容易踩的坑

增量构建的核心是:只重新执行那些输入或输出发生变化的任务以及这些任务的下游任务。那么依赖关系会怎么影响它呢?举个例子:

  • 如果 compileJava 输入没变,它就会被标记为 UP-TO-DATE,跳过。
  • 但假如 processResources 改变了资源文件,它自己会重新执行。
  • 而 classes 任务依赖 processResources —— 注意,classes 通常是“聚合任务”,没有自己的输入输出。但 Gradle 会检查它的所有依赖任务,只要任何一个依赖任务不是 UP-TO-DATE,classes 本身就会重新运行(因为它的“产生”变了)。

这就是连锁反应:一个资源文件的改动,可能触发一系列下游任务重跑。我自己曾经犯过一个蠢错误:把一个纯聚合任务(比如 classes)配置了 outputs.upToDateWhen { false },结果每次构建都重新 jar、重新 test,增量构建形同虚设。后来才明白,dependsOn 的链条越长,增量失效的风险越大。

我曾经踩过的坑: 有一个模块 A 依赖模块 B 的 jar 输出,而我写了 A 的 dependsOn(":B:jar")。结果只要 B 的任何东西变了(哪怕只是注释),A 整个重新编译。后来我改用 artifact 机制来引用外部模块的输出,才恢复了增量。

那么如何利用依赖关系优化增量构建?

  1. 减少不必要的依赖链:能用 mustRunAfter 就别用 dependsOn。
  2. 把易变的任务放到依赖链末端:比如 lint 任务放到最后,别让它卡住编译。
  3. 利用 @InputFiles 和 @OutputFiles 精细声明:让小范围输入变化只触发局部重跑。

常见的依赖模式速查表

模式 写法 对增量的影响
强依赖(必须执行) taskA.dependsOn(taskB) B 重跑则 A 一定重跑(即使 A 的输入不变)
排序(不引入依赖) taskA.mustRunAfter(taskB) 若 B 不存在或被跳过,A 不受影响
软排序 taskA.shouldRunAfter(taskB) 几乎不影响增量,仅影响执行顺序
生命周期聚合(classes) plugin 自动添加 任一依赖变化即重跑

关键要记住:任务图一旦构建好,Gradle 不会动态更改依赖关系。所有决定在配置阶段就定死了。所以你在配置阶段写的 dependsOn 要慎重,不要依赖运行时条件来决定依赖。否则容易写出奇怪的图,轻则构建变慢,重则循环依赖。

嗯,任务依赖看似简单,但做好它能让你的构建又快又稳。下次我们聊聊 Gradle 的缓存机制——那才是进一步提速的利器。