Task 系统深入解析:从定义到加入 DAG 任务图全流程
Task 是 Gradle 构建图里的最小可执行工作单元,但它不是“脚本里顺序执行的一段代码”。
一个 Task 同时包含三类信息:它要做什么动作、它依赖哪些前置工作、它的输入输出是什么。Gradle 在配置阶段收集这些信息,在执行阶段根据命令行请求生成有向无环图,然后只执行图中必要的节点。
可以把 Task 系统想象成一座工厂的生产排程。每台机器不是按写在纸上的顺序启动,而是由调度系统根据“最终要生产什么零件”和“每个零件依赖哪些上游零件”生成排程表。没有被最终产品需要的机器,不应该启动。
Task 的真实模型
最小的自定义任务看起来很简单:
abstract class GenerateBuildInfoTask : DefaultTask() {
@get:OutputFile
abstract val outputFile: RegularFileProperty
@TaskAction
fun generate() {
outputFile.get().asFile.writeText("generatedAt=${System.currentTimeMillis()}")
}
}
但 Gradle 看见的不是一个普通 Kotlin 类,而是一个带元数据的工作节点:
Task
├── identity: :app:generateBuildInfo
├── actions: [generate()]
├── dependencies: [...]
├── inputs: [...]
├── outputs: [build/generated/build-info.properties]
└── state: created / configured / executed / skipped / failed
@TaskAction 只是动作入口;输入输出注解才决定增量构建、缓存和任务依赖推断是否可靠。一个没有正确声明输入输出的任务,即使功能能跑,也会破坏整个构建图的可信度。
注册任务不等于创建任务
现代 Gradle 推荐使用 tasks.register:
val generateBuildInfo = tasks.register<GenerateBuildInfoTask>("generateBuildInfo") {
outputFile.set(layout.buildDirectory.file("generated/build-info.properties"))
}
这里返回的是 TaskProvider<GenerateBuildInfoTask>,不是任务实例。它代表“未来可能需要这个任务”。只有当任务进入执行图,或者有人显式要求它的实例时,Gradle 才会真正创建并配置它。
tasks.register(...)
|
`-- TaskProvider
|
|-- 未被请求:不创建任务实例
`-- 被图需要:创建 -> 配置 -> 参与执行
这就是 Task Configuration Avoidance 的基础。大型 Android 工程中,AGP 会注册海量 variant 相关任务。如果每次同步或执行任意命令都把所有任务实例化,配置期会被任务对象创建和闭包执行吞掉。
依赖关系如何进入 DAG
任务依赖可以显式声明:
tasks.register("packageReport") {
dependsOn(generateBuildInfo)
}
也可以通过输入输出隐式建立:
val generate = tasks.register<GenerateBuildInfoTask>("generateBuildInfo") {
outputFile.set(layout.buildDirectory.file("generated/build-info.properties"))
}
tasks.register<Zip>("zipReport") {
from(generate.flatMap { it.outputFile })
archiveFileName.set("report.zip")
}
第二种更强,因为文件提供关系本身携带了任务依赖。zipReport 不只是知道一个路径,还知道这个路径由 generateBuildInfo 生产。Gradle 能在图中自动加入边:
:app:generateBuildInfo ---> :app:zipReport
这比手写 dependsOn 更不容易出错。手写依赖只保证顺序,不保证消费的文件真来自那个任务;Provider 链路同时表达“值从哪里来”和“谁生产它”。
执行图从目标任务反向生成
当你执行:
./gradlew :app:assembleDebug
Gradle 不会执行所有注册过的任务。它从目标任务出发,反向追踪 dependsOn、finalizedBy、输入输出生产关系和插件注入的依赖,得到本次需要执行的子图。
:app:assembleDebug
^
|
:app:packageDebug
^
|
+-- :app:dexBuilderDebug
+-- :app:mergeDebugResources
+-- :app:processDebugManifest
这个图必须是 DAG。若出现循环:
A -> B -> C -> A
Gradle 无法找到合法拓扑顺序,构建会失败。循环依赖通常不是“任务顺序写错”这么简单,而是模块边界或构建逻辑边界混乱的信号。
mustRunAfter 不是 dependsOn
Gradle 任务关系里最容易混淆的是顺序关系和依赖关系:
| API | 含义 | 是否把任务拉进图 |
|---|---|---|
dependsOn |
当前任务需要另一个任务先完成 | 是 |
finalizedBy |
当前任务结束后执行清理任务 | 是 |
mustRunAfter |
两者都在图中时,规定先后顺序 | 否 |
shouldRunAfter |
弱顺序,冲突时可被忽略 | 否 |
如果 B.mustRunAfter(A),但本次只请求 B,A 不会因此被执行。这个设计很重要:顺序约束不等于生产关系。把 mustRunAfter 当依赖使用,会制造偶发性缺文件问题。
Task 动作要保持纯净
一个健康任务应该像纯函数:
声明输入 + 声明参数 + 工具版本
|
v
TaskAction
|
v
声明输出
不要在 @TaskAction 中读取未声明的全局文件、系统时间、环境变量或网络数据。如果必须读取,就把它建模成 @Input、@InputFile、@Classpath 等输入。否则 Gradle 无法判断任务是否需要重跑,也无法安全缓存输出。
上面的 GenerateBuildInfoTask 使用 System.currentTimeMillis() 就是一个故意的坏例子:如果时间参与输出,它也必须成为输入;如果不想每次变化,就不应该写入时间。
Android 任务图为什么庞大
Android 构建不是单一编译任务,而是 variant 维度下的大量任务组合:
debug variant
├── manifest processing
├── resource merge
├── AAPT2 compile/link
├── Kotlin/Java compile
├── bytecode instrumentation
├── D8/R8
├── asset merge
├── native libs packaging
└── APK/AAB packaging/signing
AGP 根据 BuildType、ProductFlavor、测试组件和发布组件注册任务。理解 Task 系统的意义,就是不要把这些任务当成黑盒命令列表,而是能定位:
- 哪个任务生产了坏产物。
- 哪个输入变化导致任务重跑。
- 哪个自定义任务破坏了缓存。
- 哪个构建逻辑提前实现了所有 variant 任务。
工程风险与观测清单
Task 系统深入解析:从定义到加入 DAG 任务图全流程 一旦进入真实 Android 工程,最大的风险不是单个 API 写错,而是构建行为失去可解释性:一次小改动触发大面积重编译,CI 偶发超时,缓存命中却产物不可信,或者发布后才发现某条 variant 管线没有被覆盖。
因此,学习这个主题时要同时建立两套模型:一套解释底层机制,一套解释工程风险、观测信号、回滚策略和审计边界。前者让你知道系统为什么这样运行,后者让你在生产环境里能证明它确实按预期运行。
关键风险矩阵
| 风险点 | 触发条件 | 直接后果 | 观测方式 | 缓解策略 |
|---|---|---|---|---|
| 输入声明缺失 | 构建逻辑读取未声明文件或环境变量 | UP-TO-DATE 或缓存结果错误 | 使用 --info 和 Build Scan 查看输入变化 |
把所有影响输出的状态建模为 @Input 或 Provider |
| 绝对路径泄漏 | 任务 key 包含本机路径 | CI 与本地缓存无法复用 | 对比不同机器的 cache key 变化 | 使用相对路径敏感性和路径归一化 |
| 配置期副作用 | build script 执行 I/O、Git、网络请求 | 任意命令都变慢,configuration cache 失效 | 执行 help --scan 观察配置期耗时 |
把副作用移动到任务动作并声明输入输出 |
| Variant 污染 | 对所有 variant 注册重型任务 | debug 构建被 release 逻辑拖慢 | 查看 realized tasks 和 task timeline | 使用 selector 精确匹配目标 variant |
| 权限外溢 | 插件或脚本读取 CI secret、用户目录 | 构建不可复现,存在供应链风险 | 审计构建日志和环境变量访问 | 使用最小权限和显式 secret 注入 |
| 并发竞争 | 多个任务写同一输出目录 | 产物互相覆盖或偶发失败 | 检查 overlapping outputs 报告 | 每个任务拥有独立输出目录 |
| 缓存污染 | 不可信分支向远程缓存 push | 全团队复用错误产物 | 统计 remote cache push 来源 | 只允许受信任 CI 写入远程缓存 |
| 回滚困难 | 构建逻辑与业务变更混在一起 | 发布失败时无法快速定位 | 变更审计和构建 scan 对比 | 构建逻辑独立提交、独立验证 |
| 降级缺口 | 新 Gradle/AGP API 无兜底策略 | 升级失败后阻塞全线开发 | 记录兼容矩阵和失败任务 | 保留可回滚版本和迁移开关 |
| 资源释放遗漏 | 自定义任务打开文件句柄或进程未关闭 | Windows/CI 上清理失败或锁文件 | 观察 daemon 日志和文件锁错误 | 使用 Worker API 或 try/finally 释放资源 |
需要持续观测的指标
- 配置阶段耗时是否随模块数量线性或超线性增长。
- 单次本地 debug 构建的关键路径任务是谁。
- CI clean build 与 incremental build 的耗时差距。
- 远程 Build Cache 的 hit rate、miss 原因和下载耗时。
- Configuration Cache 的命中率和失效原因。
- Kotlin/Java 编译任务是否被不相关资源或依赖变化触发。
- 资源合并、DEX、R8、打包任务是否在小改动后全量重跑。
- 自定义插件是否提前实现了无关任务。
- 构建日志中是否出现未声明输入、重叠输出、deprecated API。
- 发布产物是否能追溯到唯一的源码提交、依赖锁和构建扫描。
- 失败是否可稳定复现,还是只在特定机器、特定并发下出现。
- 变更是否影响开发构建、测试构建和发布构建三条路径。
回滚与降级策略
- 构建逻辑变更与业务代码变更分开提交,便于二分定位。
- Gradle、AGP、Kotlin、JDK 升级必须保留兼容矩阵和回滚版本。
- 新插件能力先只接入一个低风险模块,再扩大到全工程。
- 远程缓存先 pull 后 push,确认产物稳定后再允许 CI 写入。
- 新增插桩、生成代码、资源处理逻辑必须提供开关。
- 发布构建失败时,优先回滚构建逻辑版本,而不是清空所有缓存碰运气。
- 对 CI 超时设置分阶段日志,确认卡在配置、依赖解析还是任务执行。
- 对不可恢复的构建产物变更记录迁移步骤,避免开发者本地状态残留。
最小验证矩阵
| 验证场景 | 命令或动作 | 期望信号 |
|---|---|---|
| 空任务配置成本 | ./gradlew help --scan |
配置期没有无关重任务 |
| 本地增量构建 | 连续执行同一 assemble 任务 | 第二次大量任务 UP-TO-DATE |
| 缓存复用 | 清理输出后启用 build cache | 可缓存任务出现 FROM-CACHE |
| Variant 隔离 | 分别构建 debug/release | 只出现目标 variant 相关任务 |
| CI 可复现 | 干净工作区执行 release 构建 | 不依赖本机隐藏文件 |
| 依赖稳定 | 执行 dependencyInsight | 版本选择可解释,无动态漂移 |
| 配置缓存 | --configuration-cache 连跑两次 |
第二次复用配置缓存 |
| 发布审计 | 记录 scan、mapping、签名信息 | 产物可追溯、可回滚 |
审计问题
- 这段构建逻辑是否有明确所有者,还是散落在多个模块脚本里。
- 它是否读取了没有声明为输入的文件、环境变量或系统属性。
- 它是否在配置阶段执行了本应放到任务动作里的工作。
- 它是否对所有 variant 生效,还是只应该对某些 variant 生效。
- 它是否可以在没有网络、没有本地 IDE 状态的 CI 中运行。
- 它是否把权限、密钥、签名文件路径写进了仓库。
- 它是否破坏了并发执行,例如多个任务写同一个目录。
- 它是否能在失败时输出足够日志,帮助定位根因。
- 它是否能通过一个开关降级,避免阻塞全工程构建。
- 它是否有最小复现样例或 TestKit/集成测试覆盖。
- 它是否会让下游模块承担不必要的依赖或任务成本。
- 它是否能在升级 Gradle/AGP 后继续工作,还是依赖内部 API。
反模式清单
- 用
clean掩盖输入输出声明错误。 - 用
afterEvaluate修补本可以用 Provider 表达的依赖关系。 - 用动态版本解决依赖冲突,却让构建不可复现。
- 把所有公共配置塞进一个巨型 convention plugin。
- 在 debug 构建默认开启 release 级别的重型优化。
- 在任务动作里读取
project或全局 configuration。 - 在多个任务中共享同一个临时目录。
- 缓存命中率异常时只重启 CI,不分析 miss reason。
- 把构建扫描链接当作可选附件,而不是性能回归证据。
- 用本地 IDE 成功运行证明 CI 发布链路安全。
最小实操脚本
./gradlew help --scan
./gradlew :app:assembleDebug --scan --info
./gradlew :app:assembleDebug --build-cache --info
./gradlew :app:assembleDebug --configuration-cache
./gradlew :app:dependencies --configuration debugRuntimeClasspath
./gradlew :app:dependencyInsight --dependency <module> --configuration debugRuntimeClasspath
这组命令覆盖配置期、执行期、缓存、配置缓存和依赖解析五条主线。任何 Task 系统深入解析:从定义到加入 DAG 任务图全流程 相关的改动,都应该能用其中至少一条命令解释它带来的行为变化。