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()

增量构建判定流程图

增量构建判断流程图 触发任务执行 读取上次构建快照 (inputs / outputs 已存哈希) 对比当前 inputs 哈希 (文件大小、内容、属性 toString) 输入 变更? 是 否 输出 有效? 否 是 UP-TO-DATE 执行任务动作 说明 若输入未变且输出 有效,任务状态为 UP-TO-DATE 且跳过。

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)需要同时满足以下所有条件:

  1. 所有@Input属性和文件的内容哈希与上次成功构建完全一致;
  2. 所有@OutputFile和@OutputDirectory均存在,且路径、内容清单无变化;
  3. 任务类本身(包括其父类和@TaskAction方法签名)未发生代码变更;
  4. 任务所依赖的其他任务皆处于UP-TO-DATE或FROM-CACHE状态;
  5. 任务的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处理文件级变更,再到利用调试命令排查缓存问题,每一步都能显著提升多模块项目的构建效率。下一章将深入探讨任务依赖排序与自定义排序规则。