2. 缓存隔离核心原理:构建缓存key组成、输入输出哈希、模块边界定义规则
AGP 升级后,构建缓存失效频繁、跨模块增量编译不生效的根本原因在于:缓存 Key 的组成范围发生了变化。理解缓存隔离原理,就是理解 AGP 在构建过程中如何生成、匹配和复用编译中间产物。本章围绕三个核心概念展开:缓存 Key 的组成结构、输入与输出哈希的计算逻辑、以及模块边界的定义与隔离策略。
2.1 缓存 Key 的组成:从 Gradle 的 Task 到 AGP 的 Transform
在 AGP 构建体系中,每个编译步骤(如 Java 编译、Aapt2 资源打包、Dex 合并)都被抽象为一个或多个 Gradle Task。每个 Task 的输入决定了其输出的唯一性,而缓存 Key 就是这些输入的数字化摘要。
| 组件 | 说明 | AGP 升级前后差异 |
|---|---|---|
| Task 路径 | Task 的全限定名,如 :app:compileDebugJavaWithJavac |
新增 InputMerge、SourceSet 重组导致路径偏移 |
| 输入文件哈希 | .java/.kt 源文件、资源文件、AAR 库的时间戳与内容哈希 | AGP 8.x 改用 FileCollection + RegularFile 更细粒度跟踪 |
| 构建参数 | JDK 版本、AGP 版本、minSdk、compileSdk 等 BuildConfig | 新增非 ABC 无关字段(如 AAPT2 参数重排) |
| 依赖图谱 | 项目间插件的 transform 链、KSP/KAPT 增量模式开关 | 新增 TransformManager 缓存隔离层级 |
实践要点:在升级 AGP 后,不要只关注编译是否通过,要主动检查
~/.gradle/caches/transforms-* 和 build/tmp 目录下的缓存 Key 生成日志。使用 --info 或 --scan 参数输出 Task 的 Input Snapshot,定位哪部分输入导致了缓存失效。
2.2 输入输出哈希:如何被计算与匹配
AGP 使用 Gradle 内置的 缓存快照(Snapshot) 机制对 Task 输入进行哈希计算。其核心路径如下:
- 文件归集:通过
TaskInputs.file()、TaskInputs.dir()注册输入源;AGP 7.x+ 采用InputChanges增量模式。 - 快照生成:Gradle 根据文件路径、最后修改时间构建文件树哈希。AGP 8.x 引入
LazyFileHasher优化增量扫描。 - 输出哈希绑定:Task 的输出产物(
.class、.dex、.ap_)会附带本次输入哈希的指纹,存入构建缓存。 - 匹配判重:同一 Task 在新的构建请求中,若所有注册输入的快照哈希与缓存记录一致,则直接复用旧输出,跳过执行。
缓存隔离的关键干扰因素:
- 路径漂移:源码路径包含
/tmp或随机目录名导致哈希变更。 - 时间戳噪声:AGP 生成中间文件时若保留毫秒级时间戳,会破坏快照一致性。
- 非确定性参数:如
BuildConfig中插入时间戳或 Git Commit 字段。
2.3 模块边界定义规则:隔离粒度与跨模块缓存
“模块边界”是指在构建图谱中,两个项目模块之间明确的编译隔离面。AGP 在升级后对边界的处理方式发生了变化:
| 边界类型 | AGP 7.x 行为 | AGP 8.x 行为 | 隔离策略 |
|---|---|---|---|
| Transform 链 | 全局插件生效,多模块共享 | 模块级 transform 隔离,不跨模块缓存 |
使用 TransformScope 限定 |
| 动态特性 | 依赖图全局合并 | 按 Feature 分区,缓存独立 |
使用 distinctOutputDirs |
| KSP/KAPT | 源文件变更全量重处理 | 按模块+符号隔离增量 | 关闭 incremental 或配置缓存白名单 |
常见错误:升级后出现 “Task has been configured with a different input set” 错误,往往是因为模块边界内混入了不受控的全局类路径,例如将
android.jar 以 project dependency 方式引入。必须将其归类为 provided 或 compileOnly 以隔离缓存。
2.4 模块边界缓存隔离的核心流程图
下图展示了从源文件变更到跨模块缓存命中与隔离的完整数据流:
2.5 模块边界定义的具体实现
在 AGP 8.x 中,模块边界通过 Transform 或 TransformManager 的作用域参数来定义。下面是一个模块边界隔离的典型配置示例:
// build.gradle.kts (模块级)
android {
transformManager {
// 为模块 "feature_a" 定义独立缓存域
val moduleId = "feature_a"
scope(moduleId) {
variantFilter { variant ->
variant.name == "debug"
}
// 输入输出缓存 Key 隔离标识
isolateOutputBy("${moduleId}_${variant.name}")
}
}
}
边界定义规则总结:
- 名称空间绑定:每个模块的缓存 Key 必须包含其
moduleName,防止不同模块间互相污染。 - 变体分离:debug/release 各有独立缓存域;如果混用会导致构建异常或产物错误。
- Transform 链剪枝:AGP 8.x 会主动裁剪跨模块的 Transform 链,开发者在自定义 Transform 时必须显式标注
Scope.PROJECT或Scope.SUB_PROJECTS,否则会引发缓存隔离失败。
2.6 调试与验证模块边界缓存
当需要确认模块边界隔离是否生效时,可使用以下步骤:
- 清空缓存并执行一次全量构建:确保所有 Task 输出被正确缓存。
- 修改一个模块的单一文件:例如在
module_b中添加一个注释。 - 执行增量构建并检查日志:
./gradlew :app:assembleDebug --info 2>&1 | grep "REUSED\|FROM-CACHE\|Executing task"
# 期望输出:只有 module_b 相关的 Task 被重新执行
# module_a 和 module_c 的 Task 显示 "FROM-CACHE"
验证要点:如果
module_a 的 Task 也被重新执行,说明模块边界未能正确隔离。常见原因是 module_b 中导入了 module_a 的非 api 依赖,导致 AGP 将两个模块的输入合并处理。应在 module_b/build.gradle.kts 中将 implementation(project(":module_a")) 改为 compileOnly(project(":module_a")) 来切断缓存关联。
2.7 常见缓存隔离错误与性能稳定优化
| 错误现象 | 根因分析 | 解决方案 |
|---|---|---|
| 小改动导致全量重编 | 模块边界未定义或 annotationProcessor 全局生效 |
改用 kapt 并配置 incremental + 模块域隔离 |
| 缓存命中后产物缺失 | 不同模块输出目录重叠,缓存 Key 冲突 | 在 transform 中使用 distinctOutputDirs = true |
| 跨模块调试崩溃 | 缓存 Key 包含 file:/path 路径,不同机器不匹配 |
使用 relativePath + Project.fileTree 替代路径 |
| C.I. 与本地缓存冲突 | Git 分支切换导致时间戳快照失效 | 开启 .gitignore 对 build/ 完全忽略,并禁用本地缓存依赖 |
性能稳定性优化建议:
- 在 AGP 8.x 中,将
android.enableParallelJsonGen=false插入gradle.properties,减少并行生成 JSON 配置对缓存 Key 的随机噪声。 - 使用
Set运行时注入到 Task 的 input 注解中,确保每次构建都携带一致的边界标识。moduleBoundaryTags - 定期清理
~/.gradle/caches/transforms-3/*中过期且无引用的缓存,减少不必要的哈希比对开销。