Artifact Transform 机制:在依赖图中进行变体转换剖析
Artifact Transform 是 Gradle 依赖解析系统里最容易被低估的一层:它允许消费者在拿到依赖产物之前,把产物从一种形态转换成另一种形态。
普通任务是“我有输入文件,执行后产生输出文件”。Artifact Transform 更像依赖图里的自动加工站:某个配置请求一种属性组合,如果生产者没有直接发布这种 artifact,Gradle 会检查是否存在从已有 artifact 到目标 artifact 的转换链。存在的话,它会在任务消费文件之前自动执行转换。
在 Android 构建中,这个机制非常关键。AAR、classes jar、资源、instrumented classes、Hilt 聚合产物、字节码重写结果,都可能通过属性和转换串起来。理解 Artifact Transform,才能理解为什么“依赖解析”并不只是下载文件。
变体选择之后,还有产物形态选择
上一篇讲过,Gradle 依赖图的节点不是 jar,而是 component variant。Variant 选择解决的是“我要哪个生产者出口”。Artifact Transform 解决的是“这个出口里的文件是否需要换一种形态”。
Consumer Configuration
attributes:
usage = java-runtime
artifactType = android-classes
|
| 1. 选择组件和变体
v
Producer Variant
attributes:
usage = java-runtime
artifactType = aar
|
| 2. 没有直接匹配 artifactType
v
Transform Chain
aar -> exploded-aar -> android-classes
|
v
Task Input
这就是 Artifact Transform 的位置:它发生在依赖解析产物交付给任务之前。
TransformAction 的最小模型
一个 transform 本质上是实现 TransformAction 的工作单元:
abstract class UnzipAarTransform : TransformAction<TransformParameters.None> {
@get:InputArtifact
abstract val inputArtifact: Provider<FileSystemLocation>
override fun transform(outputs: TransformOutputs) {
val input = inputArtifact.get().asFile
val outputDir = outputs.dir(input.nameWithoutExtension)
// 真实工程中应使用可复现的解压实现,并声明完整输入。
projectLikeUnzip(input, outputDir)
}
}
注册时要声明从哪些 attributes 转到哪些 attributes:
dependencies {
registerTransform(UnzipAarTransform::class) {
from.attribute(ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE, "aar")
to.attribute(ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE, "exploded-aar")
}
}
这里的核心不是“解压”,而是 attributes。Gradle 不会因为类名叫 UnzipAarTransform 就猜测用途,它只根据消费者请求、生产者已有属性和 transform 注册的 from/to 属性做图搜索。
转换链是被解析出来的
如果一次转换不能到达目标,Gradle 可以串联多段 transform:
artifactType=aar
|
| A: aar -> exploded-aar
v
artifactType=exploded-aar
|
| B: exploded-aar -> android-classes
v
artifactType=android-classes
这很像城市轨道换乘。你不需要每两个站点之间都有直达线路,只要线路图能拼出路径,调度系统就可以把你送到目标站。
这种设计避免了生产者发布所有可能组合。一个 library 不需要同时发布 jar、minified-jar、relocated-jar、instrumented-jar;消费者可以按需把已有 artifact 加工成自己需要的形态。
Transform 与 Task 的关键差异
Artifact Transform 和 Task 都有输入、输出、缓存能力,但调度语义不同:
| 维度 | Task | Artifact Transform |
|---|---|---|
| 触发方式 | 命令目标和任务依赖图 | 依赖解析请求 |
| 输入来源 | 显式任务属性、文件集合 | 被选中 artifact |
| 输出消费 | 由其他任务或用户消费 | 回到 dependency artifact set |
| 可见性 | ./gradlew tasks 可见 |
可用 artifactTransforms 诊断 |
| 常见用途 | 编译、打包、生成源码 | 解压、重定位、插桩、产物形态转换 |
如果某段逻辑是构建流程中的显式步骤,应建模为 Task。如果它的存在只是为了让某类依赖产物满足消费者属性,应建模为 Artifact Transform。
缓存正确性取决于输入声明
Transform 可以标记为 @CacheableTransform,但缓存正确性完全依赖输入声明。
@CacheableTransform
abstract class RelocateJarTransform : TransformAction<RelocateParameters> {
interface RelocateParameters : TransformParameters {
@get:Input
val packagePrefix: Property<String>
}
@get:Classpath
@get:InputArtifact
abstract val inputArtifact: Provider<FileSystemLocation>
override fun transform(outputs: TransformOutputs) {
val output = outputs.file("relocated.jar")
// packagePrefix 和 inputArtifact 共同决定输出。
}
}
如果 transform 读取了外部配置文件、系统环境变量、网络数据,却没有通过 @Input、@Classpath、@InputArtifactDependencies 等方式声明,Gradle 可能复用过期结果。构建缓存最怕“看起来命中,实际产物已经不对”。
Android 中的典型使用场景
在 Android 构建里,Artifact Transform 常出现在这些场景:
- AAR 解包:从
.aar中拆出classes.jar、资源、manifest、jni libs。 - 字节码处理:把目录或 jar 转成可插桩、可聚合、可分析的中间产物。
- Hilt/Dagger 聚合:对依赖中的类信息做聚合扫描。
- 资源/类路径归一化:为后续编译或打包任务提供统一形态。
AGP 之所以能把复杂 Android 产物接入 Gradle 依赖模型,关键就是它不把 AAR 当作一团黑盒文件,而是用 attributes、variant、transform 把内部产物拆成可选择、可转换、可缓存的文件集合。
如何诊断 Transform 问题
当依赖解析报出属性不匹配或 transform 链异常时,优先看三件事:
./gradlew :app:artifactTransforms
./gradlew :app:dependencyInsight --dependency some-lib --configuration debugRuntimeClasspath
./gradlew :app:dependencies --configuration debugRuntimeClasspath
诊断方向:
- 消费者请求了哪些 attributes。
- 生产者实际提供了哪些 variant/artifact attributes。
- 是否注册了能从
from到to的 transform。 - transform 是否注册在会解析该 configuration 的 project 上。
- transform 输入是否完整声明,是否可缓存。
不要直接把报错理解成“依赖下载失败”。很多时候文件已经下载,失败发生在 variant/artifact 属性无法拼出合法路径。
设计权衡
Artifact Transform 的价值是把“产物适配”从任务逻辑里抽出来,交给依赖解析图统一处理。这样消费者只声明自己需要什么形态,Gradle 负责寻找生产者和转换路径。
代价是模型更抽象。你必须用 attributes 思考,而不是用文件名思考;必须声明纯净输入,而不是在 transform 里随手读全局状态。对于大型 Android 工程,这个成本是值得的,因为它换来的是更强的复用、并行和缓存能力。
工程风险与观测清单
Artifact Transform 机制:在依赖图中进行变体转换剖析 一旦进入真实 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
这组命令覆盖配置期、执行期、缓存、配置缓存和依赖解析五条主线。任何 Artifact Transform 机制:在依赖图中进行变体转换剖析 相关的改动,都应该能用其中至少一条命令解释它带来的行为变化。