Android 编译加速/优化 80%:一个文件搞定,零侵入零配置

9 阅读7分钟

项目地址

github.com/ktl-111/Fas…

背景

在大型 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,对应的执行状态变为 SKIPPED0 耗时,彻底消除这部分无效开销。

具体做法是在编译前按 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 状态为 SKIPPED0 耗时)
        │
        ▼
  ⑤ buildFinished 回调
     └─ 编译成功时,按 CPU 核数并发将各 module 状态写回 JSON

核心:UP-TO-DATE vs SKIPPED

状态含义实际开销
UP-TO-DATEGradle 执行了缓存对比,确认输入输出没变有开销(检查文件指纹、快照对比,部分 task 数百毫秒)
SKIPPEDtask.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.gradleFastBuildPlugin 类成员变量中,直接修改即可:

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 项目

注意事项

  1. 仅在 assemble* 命令时生效,其他命令(如 linttestclean)不受影响
  2. 不要对同一工程目录并发执行多个 assemble(多进程同时写缓存可能冲突)
  3. 首次编译为全量编译(无缓存),从第二次开始生效
  4. 若怀疑缓存导致编译异常,删除 build/FastBuild/ 目录或执行 clean 即可重置