第四章:Gradle任务输入输出声明——inputs、outputs、doLast、upToDateWhen

说到Gradle的增量构建,核心就一句话:告诉Gradle「你的任务靠什么输入,产出什么输出」。只要输入没变,输出就能复用。我早年刚接手一个大型Android项目时,构建一次要十几分钟,后来靠把这套声明梳理清楚,时间直接砍到分钟级。

4.1 声明输入——inputs 才是“触发开关”

我个人习惯先声明输入。因为Gradle判断任务要不要重新执行,第一件事就是看输入的属性和文件有没有变化。

声明输入有三种常见方式:

// 声明单个文件或目录
task compileJava {
    inputs.file("src/main/java/Main.java")
    inputs.dir("src/main/java")
    inputs.files("config.properties", "rules.txt")
}

// 声明属性
task printVersion {
    inputs.property("version", project.version)
    doLast {
        println "版本号: $version"
    }
}

// 使用文件树
task processResources {
    inputs.dir("src/main/resources")
    inputs.fileTree("templates").include("**/*.ftl")
}

我记得有一次生产事故——同事把整个项目根目录声明为输入,导致任何文件改动都触发重建。你想想看,输入声明越精确,增量缓存命中率越高。建议只声明任务真正依赖的文件或属性。

我曾经踩过坑:使用了inputs.file("${buildDir}/generated")这种动态路径,结果每次构建都变,输入永远在变化,增量构建形同虚设。后来改成了将生成目录作为输出,再通过生产者任务的输出声明来解耦。

4.2 声明输出——outputs 是缓存钥匙

有了输出声明,Gradle才能判断「上次的结果还能不能用」。输出通常是一个目录或一组文件。

task buildRelease {
    outputs.dir(layout.buildDirectory.dir("release"))
    outputs.file("${buildDir}/release.apk")
    doLast {
        // 实际构建逻辑
    }
}

这里有个关键点:任务执行后,Gradle会记录输出文件的哈希值。下次构建时,如果输入没变,且输出文件仍然存在、哈希一致,就直接跳过任务。

我遇到过一种情况,任务声明了输出目录,但实际生成的文件却放在了其他位置。Gradle检查时发现输出缺失,就会重新执行。所以声明和实际输出必须严格对应。

输入输出声明对照表
声明类型常见方法作用个人建议
输入属性inputs.property(String, Object)触发重新执行的依据用于版本号、开关等简单值
输入文件inputs.file(), inputs.dir(), inputs.files()监控文件变化只包含任务真实读取的文件
输出目录outputs.dir(File/DirectoryProperty)缓存结果使用layout.buildDirectory确保路径不变
输出文件outputs.file(RegularFileProperty)精确定位缓存单元适合单文件产物
upToDateWhenoutputs.upToDateWhen { true/false }自定义无需执行的条件慎用,容易破坏增量安全

4.3 doLast——任务动作的正确位置

很多初学者喜欢把逻辑直接写进task xxx { ... }闭包里。其实那是配置阶段执行的。真正干活的地方是doLast或doFirst。

// 错误示范:配置阶段执行,每次gradle运行都执行
task badTask {
    println "我在配置阶段就跑了"
}

// 正确写法
task goodTask {
    inputs.property("mode", "release")
    outputs.file(layout.buildDirectory.file("result.txt"))
    doLast {
        println "任务执行阶段,只有需要时才运行"
        // 实际的业务逻辑
    }
}

我习惯把所有业务逻辑放doLast里,这样Gradle才能正确判断「任务是否过时」。注意,如果doLast内部依赖动态计算的结果,尽量把关键变量也加到inputs里。

4.4 upToDateWhen——最后一道“免执行”防线

Gradle默认的增量判定逻辑是「输入未变且输出存在」。但有时我们需要自定义条件。比如一个任务只在凌晨两点执行,或者只在特定环境变量下才做。

task deploy {
    inputs.property("env", System.getenv("DEPLOY_ENV"))
    outputs.dir(layout.buildDirectory.dir("deploy"))
    outputs.upToDateWhen {
        // 如果环境变量不是production,就不执行
        System.getenv("DEPLOY_ENV") != "production"
    }
    doLast {
        // 实际的部署逻辑
    }
}

说白了,upToDateWhen返回true,Gradle就认为任务是最新的,跳过执行。我在一个CI流水线里遇到过:测试报告生成任务每次运行都生成新时间戳,导致永远过时。后来用upToDateWhen { true }强制认为最新,配合单独的任务来清理旧报告,才算解决。

我的经验:能通过正常输入输出声明解决的,尽量别用upToDateWhen。它相当于给Gradle说「别管你的逻辑,听我的」,一旦写错很容易导致缓存永久命中或永久不命中。我曾经在多个子项目的共建设置中滥用它,差点让CI缓存失效一周。

4.5 综合示例:一个带增量能力的打包任务

task assembleDebugPackage {
    // 输入:源代码目录 + 配置属性
    inputs.dir("src/main")
    inputs.property("debugMode", true)

    // 输出:apk文件
    outputs.file(layout.buildDirectory.file("output/app-debug.apk"))

    // 自定义条件:如果手动清除输出文件,允许重新打包
    outputs.upToDateWhen {
        // 如果输出文件存在且input未变,则认为最新
        file(layout.buildDirectory.file("output/app-debug.apk")).exists()
    }

    doLast {
        // 模拟打包过程
        println "打包Debug APK..."
        // 实际这里会执行javac、资源合并、签名等
        // 生成输出文件
        layout.buildDirectory.file("output/app-debug.apk").get()
                .asFile.write("模拟apk内容")
    }
}

这段代码里,我把upToDateWhen和正常声明组合使用,既保留了默认的输入变化检测,又加了手动清除文件的回退逻辑。嗯,这里要注意:upToDateWhen闭包里不要写耗时的文件I/O,它本身也会被缓存系统反复调用。

核心结论:

  • inputs决定“该不该重新执行”
  • outputs决定“上次结果能不能复用”
  • doLast是任务动作的正确容器
  • upToDateWhen是最后一道手动闸门,能用默认逻辑就别碰它
Gradle任务增量构建核心流程 任务 声明 inputs 声明 outputs upToDateWhen Gradle 判定是否最新 跳过任务 执行 doLast 更新输出缓存 声明 声明 可选 最新 => 跳过 过时 => 执行 产物 关键:只有同时声明了正确的 inputs 和 outputs,Gradle 才能自动管理增量;upToDateWhen 是手动干预阀门

说了这么多,其实就一个原则:告诉Gradle「你监控什么,你缓存什么」。这样它就能像侦探一样,只在你真正改代码时才重新干活。下次配置任务时,不妨先想清楚三个问题:我的输入是什么?输出在哪里?有没有特殊情况需要手动干预?