系列文章《Android Gradle 构建体系深度实践(续)》第 2 篇
推荐阅读时间:10 分钟
前言
上一篇聊了远程构建缓存,它能把“编译”这件事的成本压到很低。项目再大一点,瓶颈就会转移:
单台 CI 机器跑完整套构建要 40 分钟,远程缓存也救不了“最后一个链接 Task”
单元测试和集成测试越来越多,一次 PR 验证跑完要 1 小时
测试偶尔失败,重跑又过,Flaky Test 把团队搞得焦头烂额
这篇聚焦 CI 层面的提速。思路不是换更强的机器,而是把一大块工作拆小、并行跑。我们会讲:
Build Scan 怎么用来看 CI 构建的瓶颈
构建分片(Build Sharding)的两种落地方式
测试分片(Test Sharding)与 Firebase Test Lab 的结合
Flaky Test 的识别、隔离和修复闭环
一、先用 Build Scan 找到真瓶颈
1.1 为什么不要盲目分片
很多团队一遇到构建慢,第一反应是“加机器、搞并行”。但如果不知道时间花在哪,分片可能越做越差。比如一个 Task 占用了 80% 的时间,拆别的 Task 再多也没用。
Build Scan 是 Gradle 提供的免费构建报告,只要有 --scan 就能生成。它会给出:
每个 Task 的执行时间
Task 依赖图(DAG)
缓存命中/未命中详情
项目配置时间
网络下载耗时
1.2 识别长链路 Task
打开 Build Scan 的 Timeline 视图,通常会看到几条“长横线”:
compileReleaseKotlin或compileDebugKotlinmergeDexReleasepackageRelease某些自定义的代码生成 Task
如果最长链路是编译,优先做模块拆分和远程缓存。如果是打包/链接,优先做构建分片或分布式编译。如果是测试,优先做测试分片。
1.3 配置时间也要看
很多人只看 Task 执行时间,忽略了 Configuration 阶段。一个滥用 allprojects 或 subprojects 的脚本,可能让 Configuration 阶段拖 2-3 分钟。Build Scan 的 Configuration 标签页会显示:
应用了哪些插件
哪些脚本执行耗时最长
是否有不必要的依赖解析
先优化 Configuration,再搞分片,性价比更高。
1.4 用 Gradle Profiler 做线下基线
除了 Build Scan,Gradle 官方还提供了 gradle-profiler 工具,适合做回归测试:
gradle-profiler \
--project-dir . \
--scenario-file scenarios.txt \
--profile buildscan \
--warmups 3 \
--iterations 5
scenarios.txt 里可以定义多个构建场景,比如:
assembleDebug {
tasks = ["assembleDebug"]
gradle-args = ["--build-cache", "--parallel"]
}
cleanBuild {
tasks = ["clean", "assembleDebug"]
cleanup-tasks = ["clean"]
}
在接入缓存或分片前后各跑一组基线,用数据验证改动效果,而不是凭感觉说“快了一点”。
二、构建分片:把一个大 APK 拆成多份验证
2.1 按模块分片
最自然的分片方式是按模块维度。一个多模块项目可以拆成:
这种方式实现简单,GitHub Actions / GitLab CI / Jenkins 都支持。但缺点是:模块之间的依赖关系可能导致某个分片其实还是跑了很多间接依赖。
2.2 按 ABI 或渠道分片
对于需要输出多个 APK 的场景,可以按 ABI 分片:
./gradlew :app:assembleDebug -Pandroid.injected.build.abi=arm64-v8a
./gradlew :app:assembleDebug -Pandroid.injected.build.abi=armeabi-v7a
或者按渠道分片,每个 CI job 只打一部分渠道的包。这样单 job 的链接和签名时间会显著下降。
2.3 用 Gradle 的 --parallel 和 Worker API
除了任务级分片,还可以提升单台机器内的并行度:
./gradlew assembleDebug \
--parallel \
--build-cache \
--max-workers=8 \
-Dorg.gradle.parallel=true
--max-workers 控制同时执行的 Task 数。一般设为 CPU 核心数的 1-1.5 倍。配合 Worker API,单个 Task 内部也能并行,比如代码生成、资源合并等。
2.4 分片后的产物聚合
分片不是为了各自为政,而是为了更快得到完整反馈。CI pipeline 设计建议:
stages:
- cache-warmup
- lint-and-unit-test
- assemble-checks
- integration-test
jobs:
cache-warmup:
script: ./gradlew assembleDebug --build-cache --push
lint-unit:
script: ./gradlew lintDebug testDebugUnitTest
assemble-arm64:
script: ./gradlew :app:assembleDebug -Pandroid.injected.build.abi=arm64-v8a
assemble-armv7:
script: ./gradlew :app:assembleDebug -Pandroid.injected.build.abi=armeabi-v7a
cache-warmup job 负责把 main 分支的产物推送到远程缓存,后面的 job 直接命中。
三、测试分片:让测试跑得又快又稳
3.1 单元测试分片
Gradle 7.4+ 支持通过 maxParallelForks 在 JVM 内并行跑单元测试:
android {
testOptions {
unitTests {
all {
it.maxParallelForks = Runtime.getRuntime().availableProcessors() / 2
it.forkEvery = 100
}
}
}
}
forkEvery 控制每跑多少个测试类重启一次 JVM,能缓解内存泄漏导致的测试失败。但重启也有开销,需要结合实际测试数量调整。
3.2 集成测试与 Firebase Test Lab
Android 的仪器测试(instrumentation test)跑在模拟器或真机上,天然适合分片。Firebase Test Lab 提供 shardCount 参数,可以把一套测试拆到多台设备上跑:
android {
testOptions {
managedDevices {
devices {
pixel2Api30 (com.android.build.api.dsl.ManagedVirtualDevice) {
device = "Pixel 2"
apiLevel = 30
systemImageSource = "google"
}
}
}
execution = "ANDROIDX_TEST_ORCHESTRATOR"
}
}
命令行指定 shard:
gcloud firebase test android run \
--type instrumentation \
--app app-debug.apk \
--test app-debug-androidTest.apk \
--device model=redfin,version=30,locale=zh,orientation=portrait \
--num-uniform-shards=8
8 个 shard 意味着 8 台设备同时跑,整体时间接近单台的 1/8,但设备成本和排队时间要算进去。
3.3 测试分类:快反馈 vs 全量验证
不是所有测试都要在每次 PR 跑全量。建议分层:
把测试按稳定性、耗时、重要性分类,能避免“为了等一个全量测试,PR 挂半天”。
3.4 本地仪器测试也可以分片
不想上 Firebase Test Lab 的话,可以用 Gradle 的 testInstrumentationRunnerArguments 做本地分片:
android {
defaultConfig {
testInstrumentationRunnerArguments["numShards"] = "4"
testInstrumentationRunnerArguments["shardIndex"] = "0"
}
}
CI 里启动 4 个 emulator 实例,每个实例跑不同 shardIndex。这种方式设备资源可控,但维护 emulator 集群本身也是成本。对于大多数团队,先用 Firebase Test Lab 跑关键路径,本地 emulator 跑冒烟测试,是比较务实的组合。
四、Flaky Test 治理
4.1 什么叫 Flaky Test
Flaky Test 指的是在不修改代码的情况下,多次运行结果不一致的测试。它会让 CI 失去公信力:一旦失败,大家第一反应是“重跑一下”,而不是去查问题。
常见原因:
异步等待时间写死
依赖系统时间或随机数
共享状态没清理
多测试并发访问同一文件/数据库
Espresso 测试的动画或网络抖动
4.2 识别:重复运行 + 统计
JVM 测试可以用 Gradle 的 --rerun-tasks 或 junit 的重复运行参数。更系统化的做法是用一个脚本跑 N 次,统计失败率:
for i in {1..10}; do
./gradlew :feature:home:testDebugUnitTest
echo "Run $i exit code: $?" >> flaky-report.txt
done
在 CI 里,可以用 Build Scan 的 Tests 标签页查看历史失败趋势。某些 CI 系统还提供 flaky test 报告面板。
4.3 隔离:先标记,再修复
发现一个 Flaky Test,不要直接删掉,也不要放任不管。建议流程:
标记:在测试方法上加
@FlakyTest或 Issue 链接隔离:把它移到单独的 CI job 或 nightly 运行,避免阻塞主流程
修复:找到根因后重写,并解除标记
示例标记:
@Tag("flaky")
@Test
fun `search result should update after debounce`() {
// 测试代码
}
CI 配置里让主流程排除 flaky tag:
./gradlew testDebugUnitTest -PexcludeTags=flaky
4.4 修复技巧
用 IdlingResource 替代
Thread.sleep用 FakeClock 替代
System.currentTimeMillis()每个测试前后清理数据库和 SharedPreferences
避免测试之间共享单例状态
Espresso 测试关闭动画:
adb shell settings put global animator_duration_scale 0
4.5 建立 Flaky Test 看板
光靠命令行统计不够系统。可以在 CI 里把测试结果上传到内部平台,按周统计每个测试的失败次数和重跑次数。一个简单的指标:
flaky_rate = 失败但总通过的测试数 / 总测试数
把 flaky_rate 超过 1% 的测试列进看板,assign 给对应模块负责人修复。修复后连续 7 天稳定,再移除标记。
五、把分片和缓存串起来
5.1 完整 CI 流水线示例
一个兼顾缓存、分片、测试分层的 pipeline:
build:
stage: build
script:
- ./gradlew :app:assembleDebug --build-cache --push
only:
- main
unit-test:
stage: test
parallel:
matrix:
- SHARD: [1, 2, 3, 4]
script:
- ./gradlew testDebugUnitTest -PtestShard=$SHARD -PtotalShards=4 --build-cache
instrumentation:
stage: integration
script:
- gcloud firebase test android run --num-uniform-shards=8 ...
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
build job 在 main 分支把缓存预热好;unit-test 用 4 个 shard 并行跑;instrumentation 在 MR 阶段用 Firebase Test Lab 跑真机测试。
5.2 注意网络瓶颈
分片越多,同时下载依赖和缓存条目的网络压力越大。如果 CI 机器共用同一个出口带宽,8 个 job 同时拉远程缓存可能会把带宽打满,反而变慢。建议:
给 CI 机器配置本地 Maven 镜像和 Gradle 缓存代理
按 job 类型分配机器池,避免构建 job 和测试 job 抢带宽
监控每个 job 的缓存下载耗时,及时调整 shard 数量
5.3 成本与收益要算清楚
分片和真机测试都会带来直接成本。建议每个季度做一次复盘:
不要为了分片而分片。如果某个模块改动频率很低,把它拆出去单独跑并不能省多少时间,反而增加了 pipeline 复杂度。
小结与下篇预告
CI 提速的核心不是堆机器,而是看清楚时间花在哪,再决定怎么拆。远程缓存解决“重复编译”,分片解决“单点太长”,测试分层解决“反馈太慢”,Flaky Test 治理解决“结果不可信”。
下一篇我们把视角再拉远一点,看看 Gradle 和 Kotlin Multiplatform 混合工程里的构建问题。KMP 的 Kotlin/Native 编译链路、XCFramework 打包、跨平台 Convention Plugin 都有不少和普通 Android 工程不一样的地方。
参考阅读
Gradle Build Scan 官方文档
Firebase Test Lab 分片参数
Android 测试编排器(Test Orchestrator)指南