6、增量构建原理:增量构建是什么、输入与输出声明、Up-to-date检查机制、Task的inputs与outputs。

聊到Gradle的增量构建,我得先坦白一件事。我刚接触Gradle那会儿,根本没把这当回事。项目小嘛,每次跑个构建也就十几秒,谁在乎那点时间?直到后来接手了一个微服务项目,一次完整构建要跑四分多钟。你想想看,改一行代码,等四分钟才能验证结果,这谁受得了?

那时候我才真正意识到,增量构建不是锦上添花,而是救命稻草。

什么是增量构建?

说白了,增量构建就是「只重新做那些需要做的事」。Gradle会记住上一次构建的状态,这次再跑的时候,它会问自己三个问题:

  • 输入变了没?
  • 输出还在不在?
  • 构建逻辑本身有没有变化?

如果答案都是「没变」,那这个Task就直接跳过。这就是所谓的UP-TO-DATE状态。

核心思想: 同样的输入 + 同样的逻辑 = 同样的输出。既然结果一样,何必再跑一遍?

我在项目中遇到过最典型的场景:一个项目有四十多个子模块,每次改一个模块的代码,如果全量编译,得等两分多钟。配置好增量构建后,改一个模块只需要十几秒。嗯,这差距就是这么明显。

输入与输出声明

要让Gradle知道「什么变了」,你得先告诉它「你在乎什么」。这就是inputsoutputs的作用。

看个最简单的例子:

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是不是「最新的」?它有一套完整的检查流程。我把它画成了下面这张图:

Gradle Up-to-date 检查流程 Task 被触发 检查1:输出文件/目录是否存在? 检查2:输入属性/文件是否变化? 检查3:Task 实现类/动作是否变化? ✅ UP-TO-DATE(跳过) ❌ 重新执行 Task

检查顺序是这样的:

  1. 输出是否存在? 如果输出文件都没了,那肯定得重新跑。
  2. 输入是否变化? 包括文件内容、属性值、文件集合等。
  3. 构建逻辑是否变化? 比如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