Gradle核心架构与生命周期:Project、Task与DAG构建模型
Gradle 不是一个把命令按顺序串起来的脚本执行器,而是一套“先建模,再求解,最后执行”的构建引擎。
这句话是理解 Android 构建系统的入口。你在 build.gradle.kts 里写的 plugins、android、dependencies、tasks.register,表面上像脚本,实际上是在向 Gradle 填充一棵构建模型:有哪些项目、每个项目暴露哪些扩展、哪些任务可以完成工作、任务之间依赖什么、哪些文件是输入和输出。
真正执行时,Gradle 不会从脚本第一行一路跑到最后一行完成构建。脚本主要服务于“配置模型”。当模型配置结束,Gradle 才根据命令行请求的目标任务,反向追踪依赖,生成一个跨项目的任务执行图,然后按图调度任务。
一个大型 Android 工程能不能构建得快、稳定、可观测,核心不在于你会不会写几个 assembleDebug 命令,而在于你是否真的理解这套模型边界:
| 维度 | 表面现象 | 真正模型 | 工程风险 |
|---|---|---|---|
settings.gradle(.kts) |
声明模块 | 创建 Settings,确定参与构建的 Project 层级 |
include 关系混乱会让模块边界失真 |
build.gradle(.kts) |
写配置脚本 | 在 Project 对象上注册插件、扩展、任务、依赖 |
配置期做 I/O 会拖慢所有构建 |
Task |
一个可执行动作 | 原子工作单元,带输入、输出、动作与依赖 | 输入输出不完整会破坏增量构建和缓存 |
| DAG | 任务顺序 | 从目标任务反向求依赖得到的有向无环图 | 循环依赖、隐式依赖丢失会导致构建失败或产物不可信 |
| Provider API | 看起来更绕的懒加载写法 | 延迟求值和值生产关系建模 | 提前 get() 会把懒配置打回全量配置 |
本文是 Gradle 构建系统目录的总览文章。后续文章会分别深入 Groovy/Kotlin DSL、Wrapper、依赖解析、Task 系统、AGP 构建管线和性能优化。这里先建立一套能贯穿所有后续章节的底层心智模型。
构建引擎的本质:把脚本变成可求解的工作图
传统脚本的执行模型很直接:
step1 -> step2 -> step3 -> step4
这种模型对小项目足够,但对 Android 工程会迅速失控。
因为 Android 构建不是一个线性流程。一个普通应用至少会牵涉这些维度:
- 多模块:
:app、:core、:feature:pay、:design-system。 - 多变体:
debug、release、不同 flavor 的笛卡尔积。 - 多语言:Kotlin、Java、C/C++、资源 XML、AIDL、RenderScript 历史遗留输入。
- 多工具链:Kotlin 编译器、Java 编译器、D8、R8、AAPT2、签名工具、测试执行器。
- 多缓存:本地增量、构建缓存、配置缓存、远程缓存。
如果仍按线性脚本处理,构建引擎必须保守地执行大量不必要工作。改了一个 XML 资源,为什么要重新配置所有发布任务?只运行 :app:lintDebug,为什么要创建所有 release variant 的打包任务?只构建 :core:test,为什么要触碰 :feature:pay 的发布配置?
Gradle 的答案是:把构建描述成模型,再让引擎决定哪些模型节点需要参与本次构建。
可以把 Gradle 想象成一座大型工厂的调度中心:
Settings像工厂总图纸,决定有哪些车间参与生产。Project像一个车间,拥有自己的原料仓库、设备、规则和工序。Plugin像安装到车间里的生产线套件,例如 Android 插件会安装资源处理、编译、打包、签名相关能力。Task像一台具体机器,完成“编译 Kotlin”“合并资源”“生成 APK”这样的原子工作。- DAG 像本次订单的生产排程,只包含这次订单真正需要启动的机器。
这套设计的关键收益不是“语法灵活”,而是“可求解”。
只要输入、输出、依赖、顺序约束被正确建模,Gradle 就能回答几个工程上极其重要的问题:
- 这次命令到底需要哪些任务?
- 哪些任务可以并发执行?
- 哪些任务因为输入输出没变可以跳过?
- 哪些任务的产物可以从缓存复用?
- 哪些配置可以延迟到确实需要时再创建?
如果构建逻辑绕开模型,直接在配置期读文件、调命令、扫目录、写产物,Gradle 就失去了推理能力。构建会变慢,更危险的是产物会变得不可审计。
三层核心对象:Settings、Project、Task
Gradle 构建启动后,最核心的对象关系可以压缩成下面这张图:
Gradle Build
|
|-- Settings
| |
| |-- include(":app")
| |-- include(":core")
| `-- includeBuild("../build-logic")
|
`-- Project Tree
|
|-- Project(":")
| |-- extensions
| |-- configurations
| |-- repositories
| `-- tasks
|
|-- Project(":app")
| |-- android extension
| |-- debug/release variants
| `-- compile/merge/package tasks
|
`-- Project(":core")
|-- kotlin/java extension
`-- compile/test/jar tasks
这三层对象解决的问题不同。
Settings 解决“谁参与构建”。它读取 settings.gradle(.kts),决定 root project、subproject、included build 的集合。官方生命周期文档把初始化阶段定义为:检测 settings 文件,创建 Settings 实例,解析参与构建的项目,再为每个项目创建 Project 实例。
Project 解决“一个模块是什么”。官方 Project API 明确说明:Project 是构建脚本访问 Gradle 功能的主 API,一个 Project 与一个 build.gradle 文件是一对一关系;项目本质上是一组 Task 对象,同时还持有依赖配置、仓库、插件、扩展等构建模型。
Task 解决“一个原子工作怎么执行”。官方 Task API 将任务描述为构建中的单个原子工作单元,例如编译 class 或生成文档。任务有名称、全局唯一路径、动作序列、依赖和顺序约束。
这三层对象不是概念摆设。它们直接决定你在 Android 项目中看到的一切:
// settings.gradle.kts:修改参与构建的项目集合
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "ZeroBugAndroid"
include(":app")
include(":core")
// app/build.gradle.kts:配置 :app 这个 Project 的模型
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "club.zerobug.app"
compileSdk = 36
defaultConfig {
applicationId = "club.zerobug.app"
minSdk = 26
targetSdk = 36
}
}
dependencies {
implementation(project(":core"))
}
上面这段 android {} 不是在“立即构建 Android 应用”。它是在 :app 的 Project 上配置 Android 插件暴露的扩展对象。AGP 随后会根据这些扩展创建 variant 模型、依赖配置和任务图节点。
如果把 build.gradle.kts 当成普通脚本,就很容易犯一个根本性错误:在配置期做本该属于任务的工作。
// 风险写法:每次配置项目都会读取文件,即使本次只运行 clean。
val gitSha = providers.exec {
commandLine("git", "rev-parse", "HEAD")
}.standardOutput.asText.get().trim()
android {
defaultConfig {
buildConfigField("String", "GIT_SHA", "\"$gitSha\"")
}
}
这里的问题不在于能不能运行,而在于它把外部命令求值提前到了配置模型阶段。一旦项目变大,这类代码会让每一次同步、每一次 help、每一次 clean 都背上不相关成本。更稳健的做法是让值以 Provider 形式流入真正需要它的任务或变体属性,尽量让 Gradle 保留延迟求值能力。
生命周期:初始化、配置、执行
Gradle 构建生命周期分为三个阶段:
┌────────────────┐
│ Initialization │ 读取 settings,确定项目集合,创建 Project 对象
└───────┬────────┘
│
┌───────▼────────┐
│ Configuration │ 执行 build scripts,注册插件、扩展、任务和依赖
└───────┬────────┘
│
┌───────▼────────┐
│ Execution │ 根据任务图调度任务动作
└────────────────┘
这三个阶段的边界必须牢牢记住。
初始化阶段读取 settings.gradle(.kts)。在多模块 Android 项目中,include(":app")、include(":core")、includeBuild("build-logic") 都属于这个阶段的关键输入。Gradle 此时要回答的是“构建世界里有哪些项目”。
配置阶段执行每个参与项目的构建脚本。插件会被应用,扩展会被创建,依赖会被登记,任务会被注册。官方文档描述配置阶段时强调:Gradle 会向初始化阶段发现的项目添加任务和其他属性,并理解任务之间的依赖以构建任务图。
执行阶段才真正运行任务动作。doFirst {}、doLast {}、自定义 task class 里的 @TaskAction,都属于执行期。官方文档还指出,Gradle 使用配置阶段生成的任务执行图来决定执行哪些任务,并且可以并行执行任务。
一个最小例子能把边界暴露得很清楚:
println("配置期:build.gradle.kts 正在被执行")
tasks.register("traceLifecycle") {
println("配置期:只有任务被需要时,这段配置动作才会执行")
doLast {
println("执行期:任务动作真正运行")
}
}
如果运行:
./gradlew traceLifecycle
你会看到配置期输出和执行期输出。
如果运行:
./gradlew help
在使用配置规避 API 的前提下,traceLifecycle 的内部配置动作不一定会执行,因为本次构建没有需要这个任务。这正是 tasks.register 的价值:让任务先作为“可引用的模型节点”存在,而不是立刻创建完整任务实例。
Gradle 构建最常见的性能灾难,基本都来自生命周期边界被破坏:
| 破坏方式 | 表现 | 后果 |
|---|---|---|
| 配置期扫描全仓库文件 | Android Studio Sync 很慢 | 每次只想看模型也要付出 I/O 成本 |
| 配置期执行外部命令 | help、tasks 也变慢 |
构建结果依赖不可观测的外部状态 |
| 配置期解析巨大 JSON | unrelated task 也耗时 | 无法按任务需求降级成本 |
提前调用 TaskProvider.get() |
懒注册失效 | 大量任务被迫创建和配置 |
滥用 afterEvaluate |
顺序变得隐式 | 插件组合后出现脆弱时序问题 |
优秀的 Gradle 构建逻辑应该让配置期只描述模型,把真正昂贵、可缓存、可并发、可重试的工作放进 Task。
Project:构建脚本背后的领域对象
build.gradle.kts 看起来像一个独立脚本,但它的接收者不是“空气”,而是当前模块对应的 Project 对象。
这解释了为什么你可以直接写:
plugins {
id("com.android.application")
}
repositories {
google()
mavenCentral()
}
tasks.register("printProjectPath") {
doLast {
println(project.path)
}
}
plugins、repositories、tasks、project 都来自 Project 暴露的 API 或插件挂载的扩展。官方 Project 文档说明,Gradle 会把构建文件作用到关联的 Project 实例上,脚本中使用的属性和方法会委托给该 Project 对象。
从源码结构看,DefaultProject 并不是只存几个字段的简单对象。它持有:
ProjectState:项目配置状态。ClassLoaderScope:脚本和插件的类加载边界。ServiceRegistry:当前 Project 可用的服务集合。TaskContainerInternal:任务容器。ExtensionContainerInternal:插件扩展容器。PluginManagerInternal:插件应用入口。DependencyHandler、RepositoryHandler、ConfigurationContainer等依赖模型入口。ExtensibleDynamicObject:Groovy DSL 动态属性和方法查找的关键结构。
可以把 Project 理解为一个模块级的“构建领域容器”。它不是最终产物,也不是任务本身,而是构建模型的聚合根。
Project(":app")
|
|-- identity: path/name/group/version
|-- plugin manager
|-- extension container
| `-- android extension
|-- dependency model
| |-- configurations
| |-- dependencies
| `-- repositories
|-- task container
| |-- TaskProvider("compileDebugKotlin")
| |-- TaskProvider("mergeDebugResources")
| `-- TaskProvider("assembleDebug")
`-- service registry
这套结构解释了 Android 插件为什么强大。AGP 并不是一个外部脚本,它是通过 Gradle 插件机制进入 Project,然后向 Project 安装 Android DSL、variant 模型、依赖配置和任务注册逻辑。
所以,下面两种写法在工程语义上差异巨大:
// 面向 Project 模型:声明这个模块依赖 :core 的产物。
dependencies {
implementation(project(":core"))
}
// 绕开模型:直接引用另一个模块 build 目录里的产物。
tasks.register<Copy>("copyCoreJarByPath") {
from("../core/build/libs/core.jar")
into(layout.buildDirectory.dir("manual-libs"))
}
第一种写法让 Gradle 理解项目依赖、变体选择、任务依赖、缓存和发布元数据。第二种写法只是文件路径拼接,Gradle 很难知道这个 jar 由谁生产、什么时候失效、是否可以并发、是否应该先执行上游任务。
这就是“不写 BUG”的构建原则:让构建引擎知道事实,而不是让脚本偷偷完成事实。
Task:原子工作单元,而不是随手写的回调
官方 Task API 把任务定义为构建中的单个原子工作。这个定义很重要。
“原子”不是说任务内部不能做多步操作,而是说从 Gradle 调度视角看,它应该有清晰边界:
- 输入是什么。
- 输出是什么。
- 依赖哪些上游任务。
- 运行后对文件系统产生什么影响。
- 是否可以跳过。
- 是否可以缓存。
- 是否可以并发。
一个任务由动作序列组成。doFirst 会把动作放到前面,doLast 会把动作放到后面,自定义任务类型通常用 @TaskAction 标记动作入口。
abstract class GenerateBuildInfoTask : DefaultTask() {
@get:Input
abstract val gitSha: Property<String>
@get:OutputFile
abstract val outputFile: RegularFileProperty
@TaskAction
fun generate() {
outputFile.get().asFile.writeText(
"""
object BuildInfo {
const val GIT_SHA = "${gitSha.get()}"
}
""".trimIndent()
)
}
}
val generateBuildInfo = tasks.register<GenerateBuildInfoTask>("generateBuildInfo") {
gitSha.set(providers.gradleProperty("gitSha").orElse("local"))
outputFile.set(layout.buildDirectory.file("generated/source/buildInfo/BuildInfo.kt"))
}
这段代码的重点不是生成了一个 Kotlin 文件,而是它把构建事实交给了 Gradle:
gitSha是输入。outputFile是输出。- 任务动作只在执行期写文件。
- 任务通过
TaskProvider注册,未被需要时不必实例化。
如果另一个任务使用这个输出,应该通过 Provider 连接,而不是手写路径和 dependsOn:
val generatedBuildInfoFile = generateBuildInfo.flatMap { it.outputFile }
tasks.register("printBuildInfoLocation") {
inputs.file(generatedBuildInfoFile)
doLast {
println(generatedBuildInfoFile.get().asFile.absolutePath)
}
}
Provider 链条携带“这个值由哪个任务生产”的信息。官方懒配置文档强调,惰性属性可以把一个任务的输出连接到另一个任务的输入,并让 Gradle 自动确定任务依赖。这比手写 dependsOn 更接近模型本质,因为依赖来自数据流,而不是来自人脑记忆。
任务依赖分两类:
| 类型 | 例子 | 推荐程度 | 原因 |
|---|---|---|---|
| 隐式依赖 | 下游任务的输入来自上游任务的输出 Provider | 高 | 数据流和执行顺序绑定,不容易漏 |
| 显式依赖 | taskB.dependsOn(taskA) |
中 | 适合没有直接文件数据流的生命周期任务 |
| 顺序约束 | mustRunAfter / shouldRunAfter |
谨慎 | 只规定顺序,不代表任务一定进入图 |
| Finalizer | finalizedBy |
谨慎 | 适合资源释放和清理,但要注意失败路径 |
这里最容易踩的坑是混淆 dependsOn 和 mustRunAfter。
dependsOn 表示“没有 A,B 不能执行”。如果请求 B,A 会被拉进任务图。
mustRunAfter 表示“如果 A 和 B 都在任务图里,B 必须在 A 后面”。如果只请求 B,A 不会因此进入任务图。
在构建系统里,这种边界不是语法细节,而是产物正确性的边界。把真实依赖写成顺序约束,可能导致任务在缺少输入的情况下运行;把顺序约束写成依赖,又可能引入不必要任务,造成超时或并发能力下降。
DAG:Gradle 为什么必须构建有向无环图
Gradle 在执行任务前会构建任务图。官方生命周期文档明确说,Gradle 会在执行任何任务前构建任务图,并且整个构建中的任务形成一个 DAG,也就是有向无环图。
DAG 的“有向”表示依赖方向:
compileDebugKotlin ─┐
├─> packageDebug ─> assembleDebug
mergeDebugResources ─┘
DAG 的“无环”表示不能出现循环:
taskA -> taskB -> taskC -> taskA
循环依赖在构建系统里没有合理执行顺序。taskA 等 taskC,taskC 等 taskB,taskB 又等 taskA,调度器无法决定谁先开始。Gradle 会拒绝这样的图,而不是猜一个顺序。
更接近源码的流程可以画成这样:
命令行请求
|
| ./gradlew :app:assembleDebug
v
入口任务解析
|
| 找到 :app:assembleDebug 对应 Task
v
DefaultExecutionPlan.addEntryTasks(...)
|
| 把入口任务转成 Node,放入队列
v
discoverNodeRelationships(...)
|
| 解析 hard dependencies / dependency successors / finalizers
v
determineExecutionPlan()
|
| 计算可调度顺序
v
finalizePlan()
|
| 形成 FinalizedExecutionPlan
v
DefaultTaskExecutionGraph.populate(...)
|
| 通知 whenReady 监听器,快照 allTasks
v
DefaultTaskExecutionGraph.execute(...)
|
| PlanExecutor 取出可执行节点,NodeExecutor 执行
v
任务动作运行
Gradle 源码中的 DefaultExecutionPlan 会从入口任务出发,把任务包装成内部 Node,然后用队列发现依赖关系。核心逻辑不是“按字符串排序任务名”,而是:
- 入口任务进入
entryNodes。 TaskNodeFactory为任务创建或复用节点。- 节点解析依赖,发现后继依赖节点。
- 过滤不满足条件的节点。
- 记录 finalizer。
- 通过
DetermineExecutionPlanAction计算最终调度顺序。 finalizePlan生成不可变的最终执行计划。
DefaultTaskExecutionGraph 则负责把最终执行计划暴露为任务图并执行。源码里它会在 populate 时保存 FinalizedExecutionPlan,快照 allTasks,触发 whenReady;执行时再把 plan 转成 work source,交给 PlanExecutor 和 NodeExecutor。
这套实现透露了几个重要事实。
第一,Gradle 的公开 TaskExecutionGraph 不是构建模型的起点,而是配置结束后的结果视图。官方 API 文档也写明:TaskExecutionGraph 只有在所有项目 evaluate 完成后才会被填充,在此之前是空的。
第二,任务图不是只包含 Task。内部执行计划里的节点还可能承载 finalizer、ordinal、transform 等执行约束。公开 API 看到的是任务视角,调度器内部看到的是更一般的执行节点。
第三,whenReady 能看到任务图,但不适合作为常规构建逻辑入口。很多基于 gradle.taskGraph.whenReady {} 的老写法,在配置缓存语义下会变得脆弱。现代 Gradle 更鼓励通过 Provider、任务输入输出、插件扩展、Build Service 等模型化方式表达关系。
配置规避:TaskProvider 为什么比 Task 更重要
早期 Gradle 构建脚本经常这样创建任务:
tasks.create("legacyPackage") {
doLast {
println("package")
}
}
这段代码的代价是立即创建并配置任务。即使本次只运行 :app:clean,这个任务也已经付出了对象创建、配置闭包执行、插件交互的成本。
现代 Gradle 推荐:
val packageRelease = tasks.register("packageRelease") {
doLast {
println("package")
}
}
官方配置规避文档明确建议:创建任务时应使用配置规避 API;register 返回的是 TaskProvider,任务对象本身不会立即创建,直到构建中确实需要实例化任务对象。
从 DefaultTaskContainer 源码可以看到这条边界:
create(...)
-> createTask(...)
-> addTask(...)
-> configureAction.execute(task)
-> 返回真实 Task
register(...)
-> 创建 TaskIdentity
-> new TaskCreatingProvider(...)
-> addLaterInternal(provider)
-> 返回 TaskProvider
create 是“现在就造机器”。register 是“先在工厂目录里登记一台机器,等订单需要时再安装和启动”。
这正是大型 Android 构建变快的基础。AGP 会为每个 variant 准备大量任务:资源合并、Manifest 处理、Kotlin/Java 编译、Dex、打包、签名、测试、lint。如果每次构建都急切创建所有 variant 的所有任务,配置期会迅速膨胀。
配置规避不是写法洁癖,而是复杂工程的生存条件。
下面是一些高风险 API 和替代方式:
| 风险写法 | 问题 | 更稳健写法 |
|---|---|---|
tasks.create("x") |
立即创建任务 | tasks.register("x") |
tasks.getByName("x") |
立即实现任务 | tasks.named("x") |
tasks.withType<T>().all {} |
可能触发批量配置 | tasks.withType<T>().configureEach {} |
provider.get() 出现在配置期 |
提前求值 | 用 map / flatMap 继续传递 |
| 用字符串路径连接产物 | Gradle 不知道生产者 | 用 RegularFileProperty / DirectoryProperty |
如果一个 Android 项目“什么都没做,Sync 就很慢”,第一步通常不是看编译器,而是看配置期是否已经创建了过多任务、解析了过多文件、调用了过多外部命令。
Provider 与 Property:把值变成数据流
TaskProvider 只是 Provider 模型的一部分。Gradle 的懒配置文档把 Provider<T> 定义为只能查询、不能修改的值;Property<T> 则是可配置、可查询的值。
它们解决的根本问题是:构建模型里很多值在配置早期还不知道,但你仍然希望先把关系连起来。
例如,某个任务的输出目录取决于 build directory,而 build directory 可能被用户或插件调整:
abstract class GenerateReportTask : DefaultTask() {
@get:InputDirectory
abstract val sourceDir: DirectoryProperty
@get:OutputFile
abstract val reportFile: RegularFileProperty
@TaskAction
fun run() {
val files = sourceDir.get().asFile.walkTopDown().filter { it.isFile }.count()
reportFile.get().asFile.writeText("files=$files")
}
}
val generateReport = tasks.register<GenerateReportTask>("generateReport") {
sourceDir.set(layout.projectDirectory.dir("src/main/java"))
reportFile.set(layout.buildDirectory.file("reports/source-count.txt"))
}
这里没有在配置期调用 layout.buildDirectory.get()。代码只是把“输出文件位于 buildDirectory 下”这个关系交给 Gradle。
这带来三个收益:
- 值可以延迟到真正需要时再解析。
- 上下游任务可以通过 Provider 形成隐式依赖。
- 配置缓存可以更容易序列化构建模型,因为配置期副作用更少。
用数据流理解构建,比用回调理解构建更可靠:
Task A outputFile
|
| Provider<RegularFile>
v
Task B inputFile
|
| Gradle 推导
v
Task B depends on Task A
这也是为什么现代 AGP API 倾向于暴露 Property、Provider、artifact API,而不是让插件作者到处拿真实文件路径。真实路径太早暴露,就会诱导插件在配置期读取或写入文件,破坏 Gradle 的推理空间。
Android 构建如何落到 Gradle 模型上
Android 项目里的 assembleDebug 不是一个魔法命令。它只是 AGP 在 :app 这个 Project 上注册的一个生命周期任务。
当你执行:
./gradlew :app:assembleDebug
Gradle 做的不是“调用 Android Studio 的打包按钮”,而是:
- 初始化项目集合,确认
:app、:core等 Project 存在。 - 配置参与构建的项目,AGP 在
:app上创建 Android 扩展和 variant 模型。 - 根据
:app:assembleDebug找到入口任务。 - 从入口任务反向追踪依赖,拉入 debug variant 需要的任务。
- 按执行计划运行资源处理、编译、dex、打包、签名等任务。
可以把一个简化后的 Android debug 构建图理解为:
:core:compileDebugKotlin
|
v
:core:bundleLibCompileToJarDebug
|
v
:app:compileDebugKotlin :app:mergeDebugResources
| |
v v
:app:dexBuilderDebug :app:processDebugResources
| |
└──────────────┬───────────────┘
v
:app:packageDebug
|
v
:app:assembleDebug
真实图会更复杂,但本质仍然是 Project 和 Task 组成的 DAG。
Android 构建特殊在 variant。buildTypes 与 productFlavors 组合后,会生成不同组件。Android 官方构建变体文档说明,构建变体来自 build type 与 product flavor 等配置组合;每个变体可以有自己的 source set、依赖和打包行为。
这意味着 AGP 必须极度依赖 Gradle 的懒建模能力。一个有 3 个 flavor、2 个 build type、多个测试组件的项目,理论上会有大量 variant 相关任务。如果每次只构建 freeDebug 却配置所有 paidRelease、enterpriseRelease 的任务,构建系统就会把大部分时间浪费在本次不需要的模型上。
所以 Android 构建优化里经常强调:
- 使用新版 AGP 的 Variant API,而不是旧的全量 variant 遍历方式。
- 尽量把昂贵计算放入任务动作。
- 用 Provider 传递路径和值。
- 避免
afterEvaluate里批量修改任务。 - 避免
subprojects {}/allprojects {}中对每个模块做重配置。 - 自定义插件优先写 convention plugin,而不是在根脚本里堆闭包。
这些建议背后的共同原则只有一个:尊重 Gradle 的模型,让它保留延迟、隔离、并发、缓存和审计能力。
源码主干:从入口任务到执行节点
为了避免把 Gradle 理解成黑箱,我们把源码主干再压缩一次。
DefaultExecutionPlan 的字段已经暴露了它的职责:
| 字段/组件 | 作用 |
|---|---|
entryNodes |
命令行请求或默认任务对应的入口节点 |
nodeMapping |
Task 与内部 Node 的映射 |
taskNodeFactory |
把 Task 包装为可调度节点 |
dependencyResolver |
解析任务依赖 |
filteredNodes |
被过滤掉的节点,例如 -x test |
finalizers |
需要在主任务后运行的终结节点 |
scheduledNodes |
计算后的调度节点列表 |
核心算法可以用伪代码表达:
addEntryTasks(tasks):
for each task:
node = taskNodeFactory.getOrCreateNode(task)
entryNodes.add(node)
queue.add(node)
discoverNodeRelationships(queue)
discoverNodeRelationships(queue):
visiting = {}
while queue is not empty:
node = queue.first
node.prepareForScheduling()
if node already processed:
queue.removeFirst()
continue
if node is filtered:
mark filtered
continue
if node first seen:
node.resolveDependencies(dependencyResolver)
push dependency successors before current node
else:
mark dependencies processed
collect finalizers
determineExecutionPlan():
scheduledNodes = DetermineExecutionPlanAction(...).run()
finalizePlan():
return DefaultFinalizedExecutionPlan(...)
这段伪代码解释了几个日常现象。
为什么请求 assembleDebug 会自动执行 compileDebugKotlin?因为入口任务解析依赖时,上游编译任务会被加入节点队列。
为什么 -x test 可以排除测试任务?因为执行计划里存在过滤节点的机制,被过滤节点不会进入最终调度路径。
为什么 finalizer 能在任务后做资源释放?因为 Gradle 在发现节点关系时会收集 finalizer,并在执行计划中安排它们。
为什么循环依赖会失败?因为调度计划必须得到一个可拓扑排序的无环结构,循环会破坏这个前提。
DefaultTaskExecutionGraph 则更像执行计划和公开 API 之间的门面:
populate(plan):
close old plan
executionPlan = plan
allTasks = snapshot(plan.tasks)
fireWhenReadyOnce()
execute(plan):
assert same plan
notify beforeGraphExecutionStarts
planExecutor.process(
executionPlan.asWorkSource(),
nodeExecutorAction
)
close plan
这里有一个非常工程化的细节:populate 会快照全部任务,因为节点在执行过程中可能从 plan 中被移除。这说明 Gradle 的执行计划不是一个静态展示用列表,而是一个会被调度器消费的工作源。
理解这一点后,就不会把 gradle.taskGraph 当成可以随意改写构建模型的地方。它更适合观测和诊断,而不是承载核心配置逻辑。
构建正确性的核心:输入、输出、依赖必须真实
Gradle 的增量构建和缓存都建立在一个前提上:任务声明的输入输出必须反映真实世界。
如果任务读取了某个文件却没有声明为输入,Gradle 可能认为任务 up-to-date,从而跳过执行,产物就会陈旧。
如果任务写了某个文件却没有声明为输出,Gradle 无法追踪这个产物,缓存、清理、并发隔离都会受影响。
如果任务依赖另一个任务的输出,却只写了裸路径,没有通过 Provider 或显式依赖表达生产关系,Gradle 可能在错误顺序下运行任务。
下面这类任务看似能用,实际上风险很高:
tasks.register("writeVersionFile") {
doLast {
val version = file("version.txt").readText().trim()
file("$buildDir/generated/version.txt").writeText(version)
}
}
问题有三个:
version.txt没声明为输入。- 输出文件没声明为输出。
- 直接使用
buildDir字符串路径,缺少 Provider 关系。
更稳健的写法应该让 Gradle 看见事实:
abstract class WriteVersionFileTask : DefaultTask() {
@get:InputFile
abstract val versionFile: RegularFileProperty
@get:OutputFile
abstract val generatedFile: RegularFileProperty
@TaskAction
fun writeVersion() {
val version = versionFile.get().asFile.readText().trim()
generatedFile.get().asFile.writeText(version)
}
}
tasks.register<WriteVersionFileTask>("writeVersionFile") {
versionFile.set(layout.projectDirectory.file("version.txt"))
generatedFile.set(layout.buildDirectory.file("generated/version.txt"))
}
这不是形式主义。它决定了:
- 输入没变时任务能不能跳过。
- 输出能不能进入 build cache。
- 并发执行时是否会写入同一文件。
- 配置缓存能不能复用模型。
- 构建失败时能不能从日志和 Build Scan 里审计任务边界。
在大型 Android 工程里,很多“偶发构建失败”本质上不是编译器随机坏掉,而是构建逻辑没有真实表达输入、输出和依赖,导致任务在不同机器、不同并发度、不同缓存状态下暴露出不一致行为。
常见架构误区与修复方向
在根脚本里控制所有模块
很多项目会在 root build.gradle.kts 写:
subprojects {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions {
freeCompilerArgs.add("-Xcontext-receivers")
}
}
}
这种写法短期方便,但会让根项目侵入所有子项目模型。模块越多,配置边界越模糊。更好的方向是把共享构建逻辑放进 convention plugin:
build-logic/
convention/
src/main/kotlin/
zerobug.android-library.gradle.kts
zerobug.kotlin-common.gradle.kts
然后模块按需应用:
plugins {
id("zerobug.android-library")
}
这样每个 Project 自己声明需要什么能力,根脚本不再成为全局泥球。构建逻辑也更容易测试、复用和审计。
用 afterEvaluate 修补时序
afterEvaluate 常被用来“等别人配置完再改”。问题是插件越多,谁先谁后就越难推理。配置缓存、并行配置、隔离项目等能力也都更不喜欢这种隐式时序。
更好的做法是:
- 插件扩展用
Property暴露可延迟配置的值。 - 任务注册时用 Provider 连接扩展值。
- 需要等待插件时使用
pluginManager.withPlugin("id") {}。 - 需要变体信息时使用 AGP 官方 Variant API。
把任务图当成配置入口
下面这种写法在老项目里很常见:
gradle.taskGraph.whenReady {
if (hasTask(":app:assembleRelease")) {
println("release build")
}
}
它的问题是把“任务图已生成后的观测点”当成“配置决策点”。一旦配置缓存启用,很多任务执行监听 API 也不再适合使用。核心构建逻辑应该在模型层表达,而不是等图 ready 后补丁式修改。
手写任务依赖掩盖数据流
dependsOn 可以用,但不应该成为默认手段。如果任务 B 使用任务 A 的文件产物,优先让 B 的输入接收 A 的输出 Provider。这样 Gradle 同时知道数据关系和执行关系。
手写依赖像口头约定,Provider 数据流像合同。前者容易漏,后者更适合长期维护。
诊断 Gradle 架构问题的观察点
当一个 Android 构建变慢、不稳定、缓存命中差,不要一上来就清缓存或升级机器。先观察模型是否健康。
观察配置期成本
./gradlew help --scan
help 不应该触发大量业务任务配置。如果 help 都很慢,说明配置期已经承担了太多非必要工作。
也可以用:
./gradlew :app:tasks --all
观察任务数量是否异常膨胀,尤其是多 variant 项目是否创建了大量本次不需要的任务。
观察任务依赖图
./gradlew :app:assembleDebug --dry-run
--dry-run 可以看到会执行哪些任务,但不会真正执行动作。它适合快速检查某个入口任务为什么拉入了意料之外的上游任务。
观察增量构建边界
./gradlew :app:assembleDebug --info
--info 会输出更多 up-to-date、cache、task execution 信息。重点看:
- 哪些任务总是执行。
- 哪些任务提示没有输出。
- 哪些任务输入变化异常。
- 哪些任务无法缓存。
观察配置缓存兼容性
./gradlew :app:assembleDebug --configuration-cache
配置缓存会暴露很多构建逻辑问题,例如配置期访问不该访问的 Project 状态、使用不兼容监听器、捕获不可序列化对象等。它不是单纯性能开关,也是一次架构体检。
观察自定义任务边界
每个自定义任务都应该能回答:
- 是否有明确的
@Input、@InputFile、@InputDirectory? - 是否有明确的
@OutputFile、@OutputDirectory? - 是否在执行期才做昂贵 I/O?
- 是否通过 Provider 接收上游输出?
- 是否避免写入共享目录导致并发冲突?
- 失败后是否有足够日志观测问题?
如果这些问题答不上来,任务只是“能跑”,还不是工业级构建单元。
本章精粹
Gradle 的核心不是 DSL,而是构建模型。
Settings 决定哪些项目参与构建,Project 承载模块级构建模型,Task 表示原子工作单元,DAG 把本次命令真正需要执行的任务组织成可调度计划。
生命周期边界决定构建质量:初始化阶段确定项目集合,配置阶段描述模型,执行阶段运行任务动作。把昂贵工作放进配置期,是大型 Android 构建变慢的根源之一。
源码层面,DefaultExecutionPlan 从入口任务出发发现依赖节点,计算并固化执行计划;DefaultTaskExecutionGraph 持有最终计划、触发图就绪监听,并把工作源交给执行器。
现代 Gradle 构建逻辑应优先使用 TaskProvider、Provider、Property、输入输出注解和插件扩展,把关系建模给 Gradle,而不是用脚本副作用偷跑构建步骤。
Android Gradle Plugin 之所以能支撑复杂 variant、资源处理、编译、dex、打包和签名流程,靠的正是 Gradle 这套 Project、Task、DAG 和懒配置基础设施。后续学习 AGP 构建管线时,所有复杂步骤都可以回到这套模型中定位。
参考资料
- Gradle User Manual: Build Lifecycle, https://docs.gradle.org/current/userguide/build_lifecycle.html
- Gradle API: Project, https://docs.gradle.org/current/javadoc/org/gradle/api/Project.html
- Gradle API: Task, https://docs.gradle.org/current/javadoc/org/gradle/api/Task.html
- Gradle API: TaskExecutionGraph, https://docs.gradle.org/current/javadoc/org/gradle/api/execution/TaskExecutionGraph.html
- Gradle User Manual: Avoiding Unnecessary Task Configuration, https://docs.gradle.org/current/userguide/task_configuration_avoidance.html
- Gradle User Manual: Configuring Tasks Lazily, https://docs.gradle.org/current/userguide/lazy_configuration.html
- Gradle User Manual: Controlling Task Execution, https://docs.gradle.org/current/userguide/controlling_task_execution.html
- Gradle Source: DefaultExecutionPlan, https://github.com/gradle/gradle/blob/master/subprojects/core/src/main/java/org/gradle/execution/plan/DefaultExecutionPlan.java
- Gradle Source: DefaultTaskExecutionGraph, https://github.com/gradle/gradle/blob/master/subprojects/core/src/main/java/org/gradle/execution/taskgraph/DefaultTaskExecutionGraph.java
- Gradle Source: DefaultTaskContainer, https://github.com/gradle/gradle/blob/master/subprojects/core/src/main/java/org/gradle/api/internal/tasks/DefaultTaskContainer.java
- Android Developers: Configure build variants, https://developer.android.com/build/build-variants