第四章: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) | 精确定位缓存单元 | 适合单文件产物 |
| upToDateWhen | outputs.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「你监控什么,你缓存什么」。这样它就能像侦探一样,只在你真正改代码时才重新干活。下次配置任务时,不妨先想清楚三个问题:我的输入是什么?输出在哪里?有没有特殊情况需要手动干预?