7、增量构建实战:自定义增量Task、使用@Input/@Output注解、增量构建的调试与验证
说实话,Gradle 的增量构建是我个人觉得最值得投入时间搞懂的特性之一。你想想看,一个项目从几十个模块涨到几百个模块,每次改一行代码就要全量编译,那滋味可不好受。我早期在一个大型微服务项目里就吃过这个亏——构建一次要 8 分钟,团队每天浪费大量时间在等编译上。
后来我花了两个下午,把核心的几个 Task 改成了增量模式。效果立竿见影,大部分改动只需要 10-20 秒就能完成增量构建。嗯,今天我们就来聊聊怎么做到这一点。
7.1 增量构建的核心思想
增量构建说白了就一句话:只重新执行那些输入发生了变化的 Task。Gradle 通过比较 Task 的输入和输出状态,来判断这个 Task 是否需要重新运行。
判断逻辑其实很简单:
- 如果 Task 的输入没有变化,且输出文件还在,那就跳过执行
- 如果输入变了,或者输出被删了,那就老老实实重新跑一遍
这里有个关键点——Gradle 默认的 Task 是不支持增量的。你得主动告诉它:哪些是你的输入,哪些是你的输出。这就是 @Input 和 @Output 注解的用武之地。
核心原则:没有声明输入输出的 Task,Gradle 会认为它每次都需要重新执行。说白了就是「你不说,我不猜」。
7.2 自定义增量 Task 实战
我们来写一个实际的例子。假设我们要做一个代码检查工具,它会扫描 Java 源文件中的 TODO 注释,并生成一份报告。
先看一个不支持增量的版本:
abstract class TodoCheckerTask extends DefaultTask {
@TaskAction
void checkTodos() {
// 每次都要全量扫描,效率很低
project.fileTree('src/main/java').files.each { file ->
file.eachLine { line ->
if (line.contains('TODO')) {
println "Found TODO in ${file.name}: ${line.trim()}"
}
}
}
}
}
这个 Task 每次都会扫描所有文件。如果项目有 1000 个 Java 文件,你只改了 1 个,它还是得扫 1000 个。浪费啊。
现在我们来改造它,让它支持增量:
abstract class IncrementalTodoCheckerTask extends DefaultTask {
@InputFiles
@PathSensitive(PathSensitivity.RELATIVE)
abstract ConfigurableFileCollection getSourceFiles()
@OutputFile
abstract RegularFileProperty getOutputFile()
@TaskAction
void checkTodos() {
// 这里只会处理发生变化的文件
sourceFiles.files.each { file ->
file.eachLine { line, number ->
if (line.contains('TODO')) {
outputFile.get().asFile.append(
"${file.name}:${number}: ${line.trim()}\n"
)
}
}
}
}
}
看到区别了吗?关键就两点:
- 用
@InputFiles声明输入文件集合 - 用
@OutputFile声明输出文件
有了这两个注解,Gradle 就能自动判断:如果输入文件没变,输出文件也存在,那这个 Task 就直接跳过。
个人经验:我习惯在声明输入时加上 @PathSensitive。它告诉 Gradle 文件路径的敏感程度。用 RELATIVE 的话,只要文件相对路径不变,就算绝对路径变了也不触发重新执行。这在 CI 环境里特别有用。
7.3 @Input 和 @Output 注解家族
光有 @InputFiles 和 @OutputFile 还不够。Gradle 提供了一整套注解,覆盖各种场景。我整理了一张表,方便你对照使用:
| 注解 | 适用场景 | 我踩过的坑 |
|---|---|---|
@Input |
单个值,比如版本号、开关标志 | 忘了加这个,改配置也不重新执行 |
@InputFile |
单个输入文件 | 路径写错了,Gradle 也不报错,直接跳过 |
@InputFiles |
多个输入文件 | 文件集合为空时,Task 会被跳过 |
@InputDirectory |
整个目录作为输入 | 目录里新增文件不会触发重新执行?其实会的 |
@OutputFile |
单个输出文件 | 输出文件路径不能是目录 |
@OutputFiles |
多个输出文件 | 需要配合 @OutputFile 的集合形式 |
@OutputDirectory |
输出目录 | 目录里的旧文件不会被自动清理 |
@Nested |
嵌套的输入对象 | 嵌套对象里的字段也要加注解 |
这里有个容易忽略的点:@Input 注解的属性,Gradle 默认只比较对象的 toString() 或者 equals()。如果你自定义了一个复杂对象,记得重写 equals() 方法,否则每次都会认为输入变了。
我曾经踩过的坑:有一次我用了 @Input 注解一个 List 类型的属性。结果每次构建都重新执行,排查了半天才发现——List 的 equals() 比较的是引用,而我每次都在 Task 配置阶段新建了一个 List。解决方案是用 @InputFiles 或者把 List 转成字符串再比较。
7.4 增量构建的调试与验证
写好了增量 Task,怎么验证它真的生效了?我一般用三个方法:
方法一:看日志
运行 Gradle 时加上 --info 参数,你会看到类似这样的输出:
Task ':checkTodos' is UP-TO-DATE
Task ':checkTodos' is EXECUTING
Task ':checkTodos' is FROM-CACHE
UP-TO-DATE:输入输出都没变,跳过执行EXECUTING:输入变了,重新执行FROM-CACHE:从构建缓存里拿的结果
如果看到 UP-TO-DATE,说明你的增量配置生效了。
方法二:用 --rerun-tasks 强制全量
有时候你想确认增量构建的结果和全量构建一致。可以用 --rerun-tasks 强制所有 Task 重新执行,然后对比输出文件。
gradle checkTodos --rerun-tasks
如果两次结果一样,说明增量逻辑没问题。如果不一样,那就要检查你的 Task 是不是有副作用——比如依赖了全局状态或者外部文件。
方法三:用 Gradle 的构建扫描
构建扫描(Build Scan)是调试增量构建的神器。运行 gradle build --scan,它会生成一个网页报告,里面详细记录了每个 Task 的输入输出哈希值、执行状态、耗时等。
我个人习惯在 CI 上开启构建扫描,这样每次构建都能看到增量构建的命中率。如果某个 Task 的命中率突然下降,说明有人改了不该改的东西。
调试小技巧:如果你不确定某个 Task 为什么没有增量,可以在 build.gradle 里加上 gradle.taskGraph.beforeTask { task -> println "${task.path} inputs: ${task.inputs.files.files}" },这样每次执行前都会打印输入文件列表,方便排查。
7.5 增量构建的常见陷阱
写增量 Task 不难,但写好增量 Task 需要避开一些坑。我列几个常见的:
- 忘记声明所有输入:如果你的 Task 依赖了某个系统属性或者环境变量,但你没用
@Input声明,那改了环境变量也不会重新执行。 - 输出文件被多个 Task 共享:两个 Task 写同一个输出文件,Gradle 会认为它们有冲突,增量构建可能失效。
- Task 有副作用:比如在 Task 里发了网络请求、写了数据库、改了全局变量。这些副作用 Gradle 感知不到,增量构建会跳过,但实际效果已经变了。
- 用了
doLast或doFirst但没有声明输入输出:这两个闭包里的逻辑不会被 Gradle 的增量机制管理,容易出问题。
嗯,说到副作用,我记得有一次在 Task 里调了一个静态方法,那个方法内部写了一个临时文件。结果增量构建跳过了 Task,但那个临时文件还在,导致后续 Task 读到了脏数据。从那以后,我写 Task 都格外小心,确保所有操作都基于输入输出。
7.6 知识体系总览
最后,我用一张图来总结本章的核心内容。这张图展示了增量构建的完整流程,从 Task 定义到执行决策:
这张图把整个决策过程串起来了。从 Task 定义开始,到检查注解,再到比较输入哈希值,最后决定是重新执行还是跳过。你可以把它当作一个检查清单,写增量 Task 时对照着来。
好了,关于增量构建的实战内容就到这里。记住三个关键点:声明输入输出、验证增量效果、避开常见陷阱。做到这三点,你的构建速度至少能快一个数量级。