4. 输入输出与增量构建:声明、条件与自定义逻辑
在多模块Gradle项目中,任务调度效率的核心瓶颈往往在于“重复执行”。Gradle通过增量构建机制,仅当任务的输入(inputs)或输出(outputs)发生变更时才执行任务本体,否则直接标记为UP-TO-DATE并跳过。这一机制依赖开发者正确声明任务的inputs和outputs。本章将深入讲解声明规则、判定条件以及如何编写自定义增量逻辑。
4.1 理解增量构建原理
每次任务执行时,Gradle会计算其inputs和outputs的内容哈希快照(Snapshot)。如果新的快照与前一次成功执行后的快照完全一致,则任务被视为UP-TO-DATE,直接跳过任务动作。若outputs被手动删除或部分丢失,任务也会重新执行。
核心原则:所有影响任务输出的因素都应声明为
inputs,所有由任务产生的文件/目录都应声明为outputs。遗漏任一关键输入都可能导致错误的UP-TO-DATE判断(即缓存毒化)。
知识结构表:Gradle 增量构建关键注解与属性
| 声明方式 | 适用场景 | 哈希比较依据 | 常用 API / 注解 |
|---|---|---|---|
| 文件输入 | 依赖源文件、配置文件、库文件 | 文件大小 + 内容哈希(MD5) | @InputFile, @InputFiles, @InputDirectory |
| 属性输入 | 编译参数、模块名、版本号 | 属性的 toString() 哈希 |
@Input, @Input + @Optional |
| 文件输出 | 编译产物、打包 JAR、测试报告 | 文件路径 + 内容清单 | @OutputFile, @OutputFiles, @OutputDirectory |
| 嵌套输入 | 复杂对象含有多个输入字段 | 递归计算嵌套属性快照 | @Nested |
| 自定义快照 | 需要高级比较逻辑(如只比较文件数量) | 通过 TaskInputs API 动态注册 |
inputs.file(), inputs.property() |
增量构建判定流程图
4.2 声明 inputs/outputs 的标准做法
在自定义任务类中,使用Gradle提供的注解是最直观的方式。对于中小型项目,也推荐在build.gradle中通过DSL动态注册。
4.2.1 文件输入与输出声明(注解方式)
// src/main/groovy/com/example/MyTransformTask.groovy
package com.example
import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import org.gradle.work.Incremental
import org.gradle.work.InputChanges
abstract class MyTransformTask extends DefaultTask {
@InputFile
abstract RegularFileProperty getInputFile()
@OutputDirectory
abstract DirectoryProperty getOutputDir()
@TaskAction
void transform() {
def input = inputFile.get().asFile
def output = outputDir.get().asFile
// 执行实际的转换逻辑
logger.lifecycle("Transform {} to {}", input.name, output.absolutePath)
}
}
4.2.2 属性输入与可选输入
// 在 build.gradle 中动态配置
abstract class ReportTask extends DefaultTask {
@Input
abstract Property getReportFormat()
@Input
@Optional
abstract Property getEncoding()
@OutputFile
abstract RegularFileProperty getReportFile()
@TaskAction
void generate() {
String format = reportFormat.get()
String enc = encoding.orElse("UTF-8").get()
// 生成报告...
}
}
最佳实践:对于
@Input属性,如果有可能为null,必须标注@Optional,否则Gradle会报错“No value has been specified”。对于目录输出,只要目录下任一文件变更,就会触发重新执行。
4.2.3 在 build.gradle 中使用 API 声明
如果不想创建额外类,可以直接在task块内通过inputs和outputs属性声明:
task compileThrift(type: Exec) {
def srcDir = file('src/main/thrift')
def outDir = file('build/generated/thrift')
// 声明输入输出
inputs.dir srcDir
outputs.dir outDir
commandLine 'thrift', '-o', outDir, '--gen', 'java', srcDir
}
task generateConfig {
def configFile = file('config/template.yaml')
def version = project.version
// 文件 + 属性混合输入
inputs.file configFile
inputs.property 'version', version
outputs.file file("${buildDir}/config/application.yml")
doLast {
// 具体生成逻辑
}
}
4.3 增量构建的判定条件
Gradle判定任务可跳过(UP-TO-DATE)需要同时满足以下所有条件:
- 所有
@Input属性和文件的内容哈希与上次成功构建完全一致; - 所有
@OutputFile和@OutputDirectory均存在,且路径、内容清单无变化; - 任务类本身(包括其父类和
@TaskAction方法签名)未发生代码变更; - 任务所依赖的其他任务皆处于
UP-TO-DATE或FROM-CACHE状态; - 任务的
onlyIf谓词返回true。
常见失效场景:
- 只声明了
inputs而忘记声明outputs,Gradle会认为该任务永远不能缓存,每次都会执行。 - 将指向临时目录的路径声明为
@OutputDirectory,但该目录在任务结束后被其他插件清空,导致下次构建时输出丢失而重新执行。 - 在
doLast闭包中动态生成的文件不属于outputs声明,Gradle不会感知这些文件的存在。
4.4 自定义增量逻辑:@Incremental 与 InputChanges
对于需要按文件粒度处理变更的场景(如增量编译、增量代码生成),Gradle提供了增量任务机制。通过@Incremental注解和InputChanges参数,可以获取本次变更中具体新增、修改、删除的文件集合。
4.4.1 定义增量任务
import org.gradle.work.Incremental
import org.gradle.work.InputChanges
import org.gradle.work.ChangeType
abstract class IncrementalProcessTask extends DefaultTask {
@Incremental
@InputFiles
abstract ConfigurableFileCollection getSourceFiles()
@OutputDirectory
abstract DirectoryProperty getOutputDir()
@TaskAction
void process(InputChanges changes) {
// 如果是首次执行或非增量执行,changes 为完整文件集
if (changes.isIncremental()) {
changes.getFileChanges(sourceFiles).each { change ->
def file = change.file
switch (change.changeType) {
case ChangeType.ADDED:
logger.lifecycle("新增文件: {}", file.name)
// 处理新增文件
break
case ChangeType.MODIFIED:
logger.lifecycle("修改文件: {}", file.name)
// 重新处理该文件
break
case ChangeType.REMOVED:
logger.lifecycle("删除文件: {}", file.name)
// 清理对应输出
break
}
}
} else {
// 非增量:遍历所有输入文件
sourceFiles.each { file ->
logger.lifecycle("全量处理: {}", file.name)
}
}
}
}
4.4.2 配置与使用
// build.gradle
tasks.register('processProto', IncrementalProcessTask) {
sourceFiles.from(fileTree('src/main/proto'))
outputDir.set(file('build/generated/proto'))
}
// 可以观察增量构建效果:
// gradle processProto // 第一次全量
// gradle processProto // 第二次 UP-TO-DATE
// touch src/main/proto/user.proto // 修改一个文件
// gradle processProto // 仅处理修改的文件
增量逻辑最佳实践:
- 确保任务类加上
abstract,Gradle通过属性延迟赋值管理快照。 - 在
InputChanges方法中,不要修改输入文件本身,只应基于变更信息调整输出。 - 如果增量处理逻辑过于复杂(如涉及跨文件依赖),建议退化为全量处理,通过
!changes.isIncremental()分支兜底。
4.5 调试增量构建与常见错误
4.5.1 调试命令
# 查看每个任务的构建缓存原因
gradle build --info | grep -E "(UP-TO-DATE|Executing|Cache)"
# 强制重新执行某个任务(忽略UP-TO-DATE)
gradle processProto --rerun-tasks
# 输出详细的任务输入输出快照
gradle processProto -Dorg.gradle.internal.tasks.snapshot.debug=true
# 打印所有任务的状态摘要
gradle build --console=verbose
4.5.2 常见错误与排查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 每次构建都重新执行任务 | 缺少outputs声明或输出路径错误 |
检查@OutputFile/@OutputDirectory是否完整,输出路径是否被清理 |
修改源文件后任务仍UP-TO-DATE |
inputs未包含该源文件,或使用了路径导致哈希不稳定 |
通过inputs.dir或@InputFiles声明文件树;优先使用fileTree()相对路径 |
任务抛NullPointerException于输出目录 |
输出目录未在任务执行前创建 | 在doFirst或@TaskAction中调用outputDir.get().asFile.mkdirs() |
| 增量任务触发全量处理 | 输入文件集合包含动态路径或非文件内容哈希 | 确保@InputFiles只包含稳定文件路径;检查是否有属性标注了@Input但未做变更 |
| 构建缓存命中但产物错误 | 缓存键遗漏了环境变量或外部工具版本 | 将环境依赖添加为@Input属性,如inputs.property("javaVersion", JavaVersion.current()) |
4.6 性能优化要点
- 精确声明:避免使用
inputs.files(fileTree('src/'))这种过大的文件集;改为inputs.dir('src/main/java'),减少哈希计算量。 - 利用
@PathSensitive:对于仅关心文件路径结构而非内容的场景,使用@PathSensitive(PathSensitivity.RELATIVE)可以跳过内容哈希,大幅加快快照比较速度。 - 规避
@Input中的可变对象:如果属性是List或Map,每次构建时即使内容不变,对象引用也可能不同,导致哈希改变。建议使用@Input+@Optional,或在toString()中返回稳定字符串。 - 考虑使用构建缓存:在多模块项目中,开启
--build-cache让其他模块或CI机器复用已构建的outputs,避免重复本地计算。 - 合并小任务:将多个连续的小任务合并成一个,减少快照计算和任务调度开销。例如将
compileJava与processResources的增量声明整合到同一个自定义任务中。
总结:增量构建是Gradle高性能的基石。正确声明
inputs/outputs是每个Gradle工程师必须掌握的技能。从使用注解和DSL声明文件/属性输入,到通过@Incremental处理文件级变更,再到利用调试命令排查缓存问题,每一步都能显著提升多模块项目的构建效率。下一章将深入探讨任务依赖排序与自定义排序规则。