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 输入进行哈希计算。其核心路径如下:

  1. 文件归集:通过 TaskInputs.file()、TaskInputs.dir() 注册输入源;AGP 7.x+ 采用 InputChanges 增量模式。
  2. 快照生成:Gradle 根据文件路径、最后修改时间构建文件树哈希。AGP 8.x 引入 LazyFileHasher 优化增量扫描。
  3. 输出哈希绑定:Task 的输出产物(.class、.dex、.ap_)会附带本次输入哈希的指纹,存入构建缓存。
  4. 匹配判重:同一 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 模块边界缓存隔离的核心流程图

下图展示了从源文件变更到跨模块缓存命中与隔离的完整数据流:

模块边界缓存隔离:Key 生成、哈希与匹配流程 源文件/资源/AAR 构建参数/AGP 版本 输入快照哈希 文件树哈希 参数摘要 Task 路径指纹 模块边界标识 缓存 Key 缓存命中:复用产物 缓存未命中:重新编译 跨模块边界隔离判定 隔离缓存独立存储 模块边界隔离判定依据:模块名称 + 依赖范围 + Transform 作用域 不同模块即使源文件哈希相同,若边界标识不同(如 feature_a 与 feature_b),缓存 Key 依然不同

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 调试与验证模块边界缓存

当需要确认模块边界隔离是否生效时,可使用以下步骤:

  1. 清空缓存并执行一次全量构建:确保所有 Task 输出被正确缓存。
  2. 修改一个模块的单一文件:例如在 module_b 中添加一个注释。
  3. 执行增量构建并检查日志:
./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 moduleBoundaryTags 运行时注入到 Task 的 input 注解中,确保每次构建都携带一致的边界标识。
  • 定期清理 ~/.gradle/caches/transforms-3/* 中过期且无引用的缓存,减少不必要的哈希比对开销。