6、增量构建原理:增量构建是什么、输入与输出声明、Up-to-date检查机制、Task的inputs与outputs。
聊到Gradle的增量构建,我得先坦白一件事。我刚接触Gradle那会儿,根本没把这当回事。项目小嘛,每次跑个构建也就十几秒,谁在乎那点时间?直到后来接手了一个微服务项目,一次完整构建要跑四分多钟。你想想看,改一行代码,等四分钟才能验证结果,这谁受得了?
那时候我才真正意识到,增量构建不是锦上添花,而是救命稻草。
什么是增量构建?
说白了,增量构建就是「只重新做那些需要做的事」。Gradle会记住上一次构建的状态,这次再跑的时候,它会问自己三个问题:
- 输入变了没?
- 输出还在不在?
- 构建逻辑本身有没有变化?
如果答案都是「没变」,那这个Task就直接跳过。这就是所谓的UP-TO-DATE状态。
核心思想: 同样的输入 + 同样的逻辑 = 同样的输出。既然结果一样,何必再跑一遍?
我在项目中遇到过最典型的场景:一个项目有四十多个子模块,每次改一个模块的代码,如果全量编译,得等两分多钟。配置好增量构建后,改一个模块只需要十几秒。嗯,这差距就是这么明显。
输入与输出声明
要让Gradle知道「什么变了」,你得先告诉它「你在乎什么」。这就是inputs和outputs的作用。
看个最简单的例子:
tasks.register('processData') {
inputs.file('data/input.txt')
outputs.file('build/output.txt')
doLast {
def input = file('data/input.txt').text
file('build/output.txt').text = input.toUpperCase()
}
}
这段代码告诉Gradle:
- 我的输入是
data/input.txt - 我的输出是
build/output.txt
下次再跑这个Task,Gradle会检查这两个文件。如果输入没变、输出还在,那就直接标记为UP-TO-DATE,跳过执行。
我的习惯: 只要是自己写的Task,我一定会显式声明inputs和outputs。哪怕只是临时用一下,也写上。为什么?因为后来你可能忘了,而Gradle不会忘。
Up-to-date检查机制
Gradle是怎么判断一个Task是不是「最新的」?它有一套完整的检查流程。我把它画成了下面这张图:
检查顺序是这样的:
- 输出是否存在? 如果输出文件都没了,那肯定得重新跑。
- 输入是否变化? 包括文件内容、属性值、文件集合等。
- 构建逻辑是否变化? 比如Task的
doLast闭包或者@TaskAction方法有没有改。
这三项检查,只要有一项没通过,Task就会重新执行。全部通过,才标记为UP-TO-DATE。
我曾经踩过一个坑: 有个Task每次构建都重新跑,我查了半天,发现是outputs.dir指向了一个临时目录,每次构建前会被清理掉。输出都没了,Gradle当然认为需要重新执行。所以,输出目录一定要选对,别指向会被自动清理的地方。
Task的inputs与outputs详解
Gradle提供了多种方式来声明输入输出。我整理了一张表,方便你对照着看:
| 声明方式 | 示例 | 说明 |
|---|---|---|
inputs.file() |
inputs.file('config.yml') |
单个文件作为输入 |
inputs.files() |
inputs.files(fileTree('src')) |
文件集合作为输入 |
inputs.dir() |
inputs.dir('src/main') |
整个目录作为输入(递归) |
inputs.property() |
inputs.property('version', '1.0') |
字符串、数字等属性值 |
outputs.file() |
outputs.file('build/report.txt') |
单个输出文件 |
outputs.dir() |
outputs.dir('build/outputs') |
输出目录 |
outputs.upToDateWhen() |
outputs.upToDateWhen { false } |
自定义判断逻辑(慎用) |
来看一个稍微复杂点的例子:
abstract class CodeGenerator extends DefaultTask {
@Input
abstract Property<String> getVersion()
@InputFile
abstract RegularFileProperty getSchemaFile()
@InputDirectory
abstract DirectoryProperty getTemplateDir()
@OutputDirectory
abstract DirectoryProperty getOutputDir()
@TaskAction
void generate() {
// 实际的代码生成逻辑
def schema = schemaFile.get().asFile.text
def templates = templateDir.get().asFile.listFiles()
// ... 生成代码
}
}
这里用了注解的方式:
@Input标记属性输入@InputFile标记单个文件输入@InputDirectory标记目录输入@OutputDirectory标记输出目录
我个人的建议: 能用注解就用注解。注解方式更清晰,而且Gradle能自动处理很多细节,比如文件快照的缓存。用inputs.file()那种方式虽然也行,但可读性差一些。
增量构建的「坑」与「解」
讲几个我实际遇到过的问题:
坑一:输出文件被外部修改
有一次,构建脚本里有个Task生成了一份JSON配置文件。结果另一个脚本在构建过程中偷偷改了这份文件。Gradle检查时发现输出变了,但输入没变,它不会重新执行。这就导致输出和输入不一致了。
解法: 确保每个Task的输出只被它自己修改。如果多个Task共享输出,考虑拆分成独立的输出目录。
坑二:使用了不可序列化的输入
Gradle需要把输入状态缓存下来,用于下次比较。如果你传了一个闭包或者一个不可序列化的对象作为输入,Gradle会报错。
解法: 只使用基本类型、字符串、文件路径等可序列化的输入。
坑三:忘记声明输出
这个最常见。Task跑完了,文件也生成了,但你没声明outputs。Gradle不知道你产生了什么,每次都会重新跑。
解法: 养成习惯,写Task的第一件事就是声明outputs。
如何验证增量构建是否生效?
跑一次构建,看日志就知道了:
$ gradle processData
> Task :processData UP-TO-DATE
BUILD SUCCESSFUL in 0s
看到UP-TO-DATE,说明增量构建生效了。如果看到Executing,说明Task被重新执行了。
你也可以用--info参数看更详细的信息:
$ gradle processData --info
...
Task ':processData' is not up-to-date because:
Output property 'outputDir' file ... has been removed.
...
这样就能看到具体是哪一项检查没通过。
一句话总结: 增量构建的核心就是「声明输入输出,让Gradle帮你做判断」。你声明得越准确,构建速度就越快。别偷懒,每个Task都写上inputs和outputs,这是值得的。
公众号:蓝海资料掘金营,微信deep3321