Administrator
发布于 2026-08-30 / 61 阅读
0
0

CI 构建分片与测试提速:Build Scan、Test Sharding、Flaky Test 治理

系列文章《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 或 compileDebugKotlin

  • mergeDexRelease

  • packageRelease

  • 某些自定义的代码生成 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 按模块分片

最自然的分片方式是按模块维度。一个多模块项目可以拆成:

分片

负责内容

命令

app 分片

主工程编译、打包

./gradlew :app:assembleDebug

feature 分片

各业务模块 lint + 单元测试

./gradlew :feature:*:testDebugUnitTest

library 分片

基础库编译与测试

./gradlew :library:*:assemble :library:*:test

这种方式实现简单,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 跑全量。建议分层:

层级

触发时机

典型耗时

内容

快速检查

每次 push

3-5 分钟

lint、ktlint、单元测试

模块测试

每次 push

10-15 分钟

受影响模块的本地集成测试

全量测试

进入 main 前/夜间

30-60 分钟

全量仪器测试、性能回归、Monkey

把测试按稳定性、耗时、重要性分类,能避免“为了等一个全量测试,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,不要直接删掉,也不要放任不管。建议流程:

  1. 标记:在测试方法上加 @FlakyTest 或 Issue 链接

  2. 隔离:把它移到单独的 CI job 或 nightly 运行,避免阻塞主流程

  3. 修复:找到根因后重写,并解除标记

示例标记:

@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 成本与收益要算清楚

分片和真机测试都会带来直接成本。建议每个季度做一次复盘:

指标

优化前

优化后

备注

PR 平均构建时间

参考基线

目标

用 Build Scan/Profiler 统计

CI 并发 job 数

参考基线

目标

影响机器池大小

Firebase Test Lab 分钟数

参考基线

目标

按设备和时间计费

缓存命中率

参考基线

目标

决定缓存投入价值

不要为了分片而分片。如果某个模块改动频率很低,把它拆出去单独跑并不能省多少时间,反而增加了 pipeline 复杂度。

小结与下篇预告

CI 提速的核心不是堆机器,而是看清楚时间花在哪,再决定怎么拆。远程缓存解决“重复编译”,分片解决“单点太长”,测试分层解决“反馈太慢”,Flaky Test 治理解决“结果不可信”。

下一篇我们把视角再拉远一点,看看 Gradle 和 Kotlin Multiplatform 混合工程里的构建问题。KMP 的 Kotlin/Native 编译链路、XCFramework 打包、跨平台 Convention Plugin 都有不少和普通 Android 工程不一样的地方。


参考阅读

  • Gradle Build Scan 官方文档

  • Firebase Test Lab 分片参数

  • Android 测试编排器(Test Orchestrator)指南


评论