项目地址
背景
在大型 Android 多 module 项目中,每次执行 assembleDebug / assembleRelease 时,即使只修改了一个 module 的代码,Gradle 仍然会对所有 module 执行大量编译相关 task。
你可能注意到,不修改任何代码再次增量编译时,这些 task 的状态虽然显示为 UP-TO-DATE,看似被跳过了——但实际上 Gradle 在每个 UP-TO-DATE 的 task 内部仍然会执行输入输出缓存对比(检查文件指纹、比较快照等)。不同的 task 缓存对比开销不同,实测发现部分 task 的 UP-TO-DATE 检查就要耗费数百毫秒。当 module 数量越多,这些"看似跳过"的 task 累积起来就造成了数分钟的无效等待。
FastBuild 的思路是:既然这些 module 的代码没有变更,那连 UP-TO-DATE 检查都不需要做。直接将 task 设为 disabled,对应的执行状态变为 SKIPPED,0 耗时,彻底消除这部分无效开销。
具体做法是在编译前按 CPU 核数并发扫描各 module 的文件状态(mtime + MD5),与上次成功编译时保存的状态进行增量对比,自动识别出未变更的 module,并在运行时禁用其编译 task。
测试效果
在实际公司项目(100+ module)上的测试结果:
| 场景 | 未开启 FastBuild | 开启 FastBuild | 提升 |
|---|---|---|---|
| 修改一行代码增量编译 | ~2 分钟 | ~25 秒 | 提升 80%+ |
module 越多,未变更 module 占比越高,加速效果越显著。
原理
整体流程
gradlew assembleDebug
│
▼
① 检查命令是否以 assemble 开头
├─ 否 → 不做任何处理,正常执行
└─ 是 ↓
② 读取上次编译缓存(.json)
│
▼
③ taskGraph.whenReady 回调
├─ 从 task graph 提取实际参与编译的 module
├─ 按 CPU 核数创建线程池
├─ 并发遍历各 module 文件,记录 mtime + MD5 hash
├─ 与上次状态对比,得到 changedModules(文件有变更的 module)
├─ 依赖传递:若 B 变更,则所有依赖 B 的 module(如 A)也加入 changedModules,避免 B 的 API 变更导致 A 未重编而运行时报错
└─ 遍历所有 task,将 unchangedModules 的编译 task 设为 disabled(SKIPPED)
│
▼
④ Gradle 执行编译(未变更 module 的 task 状态为 SKIPPED,0 耗时)
│
▼
⑤ buildFinished 回调
└─ 编译成功时,按 CPU 核数并发将各 module 状态写回 JSON
核心:UP-TO-DATE vs SKIPPED
| 状态 | 含义 | 实际开销 |
|---|---|---|
UP-TO-DATE | Gradle 执行了缓存对比,确认输入输出没变 | 有开销(检查文件指纹、快照对比,部分 task 数百毫秒) |
SKIPPED | task.enabled = false,Gradle 完全不执行 | 0 开销 |
FastBuild 的本质就是把未变更 module 的 task 从 UP-TO-DATE 提升为 SKIPPED。
文件状态检测
| 文件类型 | 记录内容 | 对比策略 |
|---|---|---|
| 源码/资源文件 | lastModifiedTime + MD5 hash | 先比 mtime,mtime 变则比 hash,hash 不同才判为变更 |
| build 目录 | 「文件数 + 总大小」指纹(可配置关闭) | 指纹变化 → 变更;指纹关闭时仅检测 build 是否被删除 |
| 排除后缀的文件 | 不记录 | 不参与对比 |
依赖传递(避免 API 变更导致运行时错误)
若 A 依赖 B,当 B 的公开 API 发生变更(例如 Kotlin 接口方法从 fun get() = "B" 改为 fun get() = true,返回值类型变化),而 A 的源码未改,仅做文件对比会认为 A 未变更。此时若只编译 B、不编译 A,运行时 A 仍使用旧的方法签名调用 B,会报方法签名不匹配等错误。
FastBuild 在得到“文件层面”的 changedModules 后,会根据 Gradle 的 compileClasspath 依赖关系做依赖传递:若 B 在 changedModules 中,则把所有直接或间接依赖 B 的 module(如 A)也加入 changedModules,从而保证 A 会重新编译,与 B 的新 API 一致。
快速开始
1. 添加文件
将 fastbuild.gradle 放到项目根目录。
2. 在根 build.gradle 中引用
// 根 build.gradle
apply from: 'FastBuild.gradle'
3. 正常执行编译
./gradlew assembleDebug
首次执行时,FastBuild 会全量扫描并记录各 module 的文件状态。从第二次开始,未变更的 module 将自动跳过编译 task。
配置项
所有配置项均在 fastbuild.gradle 的 FastBuildPlugin 类成员变量中,直接修改即可:
EXCLUDED_MODULE_NAMES
private static final List<String> EXCLUDED_MODULE_NAMES = ['app']
不做任何处理的 module 列表。列表中的 module 不会被扫描文件、不参与变更对比、其 task 也不会被禁用。
典型用法:app 模块通常是最终打包模块,建议始终排除。
EXCLUDED_TASK_NAMES
private static final List<String> EXCLUDED_TASK_NAMES = ['lint']
不做任何处理的 task 名称列表。即使所属 module 未变更,名称中包含列表内关键字的 task 也不会被禁用。
匹配规则:taskName.toLowerCase().contains(keyword.toLowerCase())。
INTEGRITY_CHECK
private static final boolean INTEGRITY_CHECK = true
| 值 | 行为 |
|---|---|
true | 完整性校验:记录 build 目录「文件数 + 总大小」指纹 + 依赖链传递校验(B 变更 → 依赖 B 的 A 也重编) |
false | 快速模式:仅在 build 目录被删除时触发重编,不校验依赖链 |
- CI/CD 或 Gerrit Code Check 场景:建议设为
true,确保编译产物的完整性,避免 API 变更导致运行时方法签名错误。 - 日常开发:可设为
false,跳过 build 指纹统计和依赖链分析,进一步加快扫描速度;但若被依赖的 module 改了公开 API(如返回值类型),上层 module 可能不会自动重编,需手动 clean。
EXCLUDED_FILE
private static final List<String> EXCLUDED_FILE = ['.log', '.tmp', '.bak', '.swp', '.puml', '.md']
不参与记录的文件后缀列表。匹配到的文件不会被记录、不参与变更对比。修改这些文件不会触发 module 重编。
支持的场景
日常增量开发
最常见的场景。修改了某个 module 的代码后执行 assembleDebug,FastBuild 自动识别变更的 module 参与编译,其余 module 的 task 直接 SKIPPED。
依赖 module 的 API 变更(如 Kotlin 接口返回值)
典型场景:A 依赖 B,B 提供 Kotlin 接口 fun get() = "B"(隐式返回 String),A 里只调用并打印。若 B 改为 fun get() = true(返回 Boolean),仅做文件对比时 A 无改动会被误判为未变更;只编 B 不编 A 会在运行时出现方法签名错误。FastBuild 在 INTEGRITY_CHECK = true 时通过依赖传递:B 变更后,所有依赖 B 的 module(含 A)会被一并加入 changedModules,从而 A 也会重新编译,避免此类运行时错误。
Gerrit Code Check / CI 加速
在 Gerrit 提交后触发的 Code Check 编译中,通常只有少量 module 有变更。FastBuild 可以大幅缩短 CI 编译时间。建议在 CI 环境下开启 INTEGRITY_CHECK = true,确保 build 产物的完整性和依赖链正确性,避免因上次构建残留的 build 目录或 API 变更导致编译结果不正确。
切换分支
从分支 A 切到分支 B 再切回 A 时:
- 不会整缓存失效、不会全量重编
- 只有文件状态确实发生变化的 module 才会重编
- 如果开启了
INTEGRITY_CHECK,被其他分支编译改过 build 目录的 module 也会被检测到,且依赖链也会传递校验
编译失败 / 中断
- 编译失败或 Ctrl+C 中断时,不会写回缓存
- 下次编译仍使用上次成功编译时的缓存,不会出现脏状态
多 module 大型项目
- 文件扫描和缓存写入均按 CPU 核数并发执行,module 越多加速越明显
- 单个 module 扫描失败不会影响其他 module,该 module 会被视为变更、正常参与编译
Clean 后首次编译
执行 clean 后缓存(位于 build/FastBuild/)会被清除,首次编译等同全量编译并重建缓存,后续恢复增量。
缓存目录
缓存文件位于项目根目录的 build/FastBuild/ 下,每个 module 一个 .json 文件。clean 时会自动清理。
build/FastBuild/
├── moduleA.json
├── moduleB.json
└── moduleC.json
JSON 格式化存储,可直接查看某个 module 记录了哪些文件及其状态。
依赖
- Gson:用于 JSON 序列化/反序列化(脚本内已自带
buildscript依赖声明) - Gradle:适用于标准 Android Gradle 项目
注意事项
- 仅在
assemble* 命令时生效,其他命令(如lint、test、clean)不受影响 - 不要对同一工程目录并发执行多个
assemble(多进程同时写缓存可能冲突) - 首次编译为全量编译(无缓存),从第二次开始生效
- 若怀疑缓存导致编译异常,删除
build/FastBuild/目录或执行clean即可重置