GitHub Actions 的 Android CI 模板优化

GitHub Actions 的 Android CI 模板优化

GitHub Actions 的 Android CI 模板优化


GitHub Actions 的 Android CI 模板优化


那个被复制了十万次的模板有什么问题


几乎所有 Android 项目的 .github/workflows/android.yml 都是从同一个源头变异而来的:GitHub 官方文档里的示例,或者某个 Medium 教程里的片段。它大概长这样——checkout 代码、设置 JDK、加缓存、跑 gradlew build、上传 APK。这个模板能跑,但在真实项目里待过几个月的人都知道,它有几个让人头疼的硬伤。


缓存 miss 率高得离谱。GitHub Actions 的 actions/cache 对 Gradle 的支持看似开箱即用,实际命中率却经常在 30% 以下。根本原因是 Gradle 的缓存结构比 Maven 复杂得多,既有 ~/.gradle/caches 里的依赖缓存,又有 ~/.gradle/wrapper/dists 的 Wrapper 分发,还有项目级的 .gradle 构建缓存。官方示例通常只配了其中一两个路径,而且 key 的生成逻辑过于简单,分支一多就互相踩踏。更隐蔽的是,Gradle 7.x 之后默认启用了配置缓存(configuration cache),它的状态存到 .gradle/configuration-cache,这个路径绝大多数模板都没包含。


另一个问题是任务粒度太粗。./gradlew build 这个命令在 CI 里是个灾难——它同时触发 lint、test、assemble,任何一个环节失败都要从头再来。调试阶段浪费的时间累积起来相当可观。我见过一个中型项目,完整 build 要 14 分钟,其中 6 分钟花在 lint 上,而开发者真正想验证的单元测试其实 3 分钟就能跑完。


还有 JDK 版本管理的坑。actions/setup-java@v3distribution 参数选 temurin 还是 zulu 还是 corretto,在 Android 编译场景下是有实际差异的。Google 的 AGP 8.0 开始强制要求 JDK 17,但某些旧版 Gradle 对特定发行版的兼容性并不好。更麻烦的是,GitHub 的 ubuntu-latest 镜像里预装的 JDK 版本会随时间变化,曾经出现过镜像更新后 CI 突然挂掉的情况,因为系统默认 JDK 从 11 跳到了 21,而项目用的 AGP 7.4 还没准备好。


缓存策略的重构:从路径列表到分层设计


解决缓存问题需要理解 Gradle 的缓存层级。我现在的做法是把缓存拆成四层,每层独立的 key 和 restore-key 策略。


第一层是 Wrapper 分发,路径固定为 ~/.gradle/wrapper/dists,key 用 wrapper-${{ hashFiles('__PLACEHOLDER_BOLD_0__/libs.versions.toml 和所有 build.gradle.kts 的哈希。这里有个细节:如果项目用了 Version Catalog,必须把 gradle/libs.versions.toml 单独列出来,因为它的变更直接影响依赖解析,但位置不在 buildSrcbuild.gradle 里,容易被漏掉。


第三层是构建缓存,路径包括 ~/.gradle/caches/build-cache-1 和项目级的 .gradle/buildOutputCleanup。这层我通常设置 restore-keys 为前缀匹配,允许跨分支复用。第四层是配置缓存 .gradle/configuration-cache,这层最特殊——Gradle 官方文档明确警告,配置缓存的条目与项目结构高度绑定,跨分支复用可能导致错误。所以它的 key 必须包含 ${{ github.ref }},宁可 miss 也不冒险用脏数据。


实际配置时,这四层用四个独立的 actions/cache 步骤实现,而不是一个步骤里列四个路径。原因是 GitHub Actions 的缓存有 10GB 的单仓库上限,独立步骤可以让每层有自己的淘汰策略。当总缓存接近上限时,GitHub 按 LRU 淘汰,如果全塞在一个缓存条目里,一次大变更就可能把整个缓存清空,四层独立的话至少能保住三层。


还有一个少有人提的技巧:gradle-build-action(GitHub 官方维护,gradle/actions/setup-gradle@v3)比手动的 actions/cache 更聪明。它会自动处理 Gradle 用户主目录的缓存,而且支持构建扫描(Build Scan)的自动发布。但这个 action 的缓存逻辑是黑盒,遇到奇怪行为时很难调试。我现在的混合策略是:用 setup-gradle 处理常规缓存,但把构建缓存和配置缓存的精细控制留给自己手动配置,通过 cache-read-only: true 参数避免冲突。


任务拆分与并行化:从 monolithic job 到 workflow 编排


build 拆成 lint、unit test、assemble 三个独立 job 是基本操作,但真正的优化在依赖图设计。GitHub Actions 的 needs 语法可以构造 DAG,但很多人只用它做线性串行,浪费了并行能力。


我的典型结构是:一个 setup job 负责 checkout 和缓存预热,然后 lintunit-testassemble-debug 三个 job 同时启动,都依赖 setupassemble-release 则等 unit-test 通过后再跑,因为签名和混淆更耗时,没必要在单元测试失败时浪费资源。


这里有个 Android 特有的坑:多个 job 同时跑 Gradle 任务时,它们各自启动的 Gradle daemon 会争抢文件锁。表现是日志里出现 "Gradle build daemon disappeared unexpectedly" 或者诡异的超时。解决方案是在每个 job 里加 GRADLE_OPTS: -Dorg.gradle.daemon=false 强制禁用 daemon。代价是每次 Gradle 启动慢 3-5 秒,但稳定性提升明显,尤其对于 4 个并行 job 以上的场景。


测试任务的拆分还可以更细。Android 的单元测试分两类:纯 JVM 的 testDebugUnitTest 和需要 Android 模拟器的 connectedCheck。后者在 GitHub Actions 的免费 runner 上跑极其痛苦,macos-latest 虽然支持 HAXM 加速,但启动模拟器仍需 2-3 分钟,而且 GitHub 的 macOS runner 分钟配额是 Linux 的 10 倍价格。我的做法是:单元测试全放 Linux runner,模拟器测试只在 nightly schedule 里跑,或者推到 Firebase Test Lab。


Firebase Test Lab 的定价值得细说。它的免费额度是每天 10 台物理设备测试 30 分钟,或者 5 台虚拟设备测试 60 分钟。对大多数项目足够覆盖核心机型矩阵。集成到 GitHub Actions 用 google-github-actions/auth 做 OIDC 认证,避免把服务账号密钥塞到 GitHub Secret 里。这个 auth action 的 v2 版本有个 breaking change:workload_identity_provider 的格式从 projects/... 变成了完整的 URL,升级时容易踩坑。


矩阵构建与版本覆盖


AGP 和 Gradle 的版本兼容性矩阵是 Android CI 的另一个痛点。Google 每年发布一个新 AGP 主版本,配套特定的 Gradle 最低版本和 JDK 要求。维护多版本兼容的库(比如开源 SDK)需要在 CI 里验证多个组合。


GitHub Actions 的 strategy.matrix 本是为这种场景设计的,但直接套用会爆炸。假设要测 AGP 7.4/8.0/8.1/8.2 × Gradle 7.5/8.0/8.2/8.5,理论上 16 种组合,实际有效的可能只有一半,因为 AGP 8.2 要求 Gradle 8.2+,低版本组合直接无效。


我的做法是用 include 显式列出有效组合,而不是 exclude 过滤无效组合。后者在矩阵维度多的时候容易漏掉,而且日志里看到跳过的 job 很干扰调试。更进一步的优化是:把矩阵维度从"版本号"抽象成"兼容性档位",比如 agp-compat: [old, stable, latest],然后在 job 里用 if 条件映射到具体版本。这样矩阵配置和实际版本解耦,升级时只改一处。


JDK 版本的选择也有讲究。actions/setup-java 支持 java-version: 17 这样的语义化指定,但我倾向于写死完整版本号 17.0.9,避免 GitHub 更新 patch 版本时引入不可预期的行为。Temurin 发行版的 17.0.8 曾经有个 bug,在特定 Linux 内核版本下触发 JVM crash,那个月很多项目的 CI 随机失败,查了很久才发现是 JDK patch 版本的问题。


产物处理与签名安全


assemble 产出的 APK/AAB 需要持久化,但 GitHub Actions 的 actions/upload-artifact 有个隐藏限制:单个文件 5GB,单个 artifact 总大小 10GB(免费账户)。Android 的 debug APK 通常几十 MB,但开启了 R8 全模式优化的 release build 加上 mapping 文件和 native debug symbols,很容易突破这个限制。


更麻烦的是签名。把 keystore 和密钥密码放到 GitHub Secret 里是常规做法,但有两个风险点:一是 android.injected.signing.store.password 这样的 Gradle 属性会出现在构建日志里,即使标记为 secret,某些 Gradle 插件的错误输出仍可能泄露;二是任何有 write 权限的协作者都可以通过修改 workflow 文件把 secret 打印出来。


相对安全的做法是用 GitHub 的 Environments 功能配合 protection rule。把签名步骤放到需要手动审批的 environment 里,只有特定分支(如 mainrelease/*)能触发,而且每次触发需要指定人员审批。keystore 文件本身用 base64 编码存到 secret,构建时解码写入临时文件,构建结束后立即删除。更现代的方案是 Google Play 的 App Signing 配合 GitHub 的 OIDC:上传的是未签名 AAB,签名完全交给 Play Console,CI 流程里不再需要接触签名密钥。


upload-artifact 的 retention 策略也要注意。默认 90 天的保留期对 nightly build 来说太长,快速消耗存储配额。我通常设置 retention-days: 14,release build 单独用 actions/upload-artifactif: github.ref == 'refs/heads/main' 条件设置更长的保留期。或者把 release 产物直接推送到 S3 或 Google Cloud Storage,用 google-github-actions/upload-cloud-storage 这类 action,成本比 GitHub 的额外存储低得多。


真实项目的完整配置演进


拿一个我维护的中型项目举例,它的 CI 配置经历了三次大迭代。


第一版就是复制粘贴的官方模板,跑在 ubuntu-latest 上,单 job 串行,完整流程 18 分钟。痛点是每次 push 都要等,开发者开始把多个 commit 攒在一起 push,反而增加了调试难度。


第二版做了任务拆分和缓存优化,降到 9 分钟。但引入了新的问题:lint 和 unit test 的并行 job 频繁因为 Gradle daemon 冲突失败,稳定性从 95% 跌到 80% 左右。那段时间团队养成了"看到红叉就点 re-run"的习惯,很糟糕。


第三版是现在的结构,核心变化是:彻底禁用 Gradle daemon,改用 Gradle 的 configuration cache 和 build cache 组合,把模拟器测试剥离到 Firebase Test Lab,release build 用 GitHub Environments 保护。完整 workflow 文件 200 多行,但关键路径(push 后得到 unit test 结果)稳定在 4 分半。


具体数字:unit test job 用 ubuntu-22.04 runner(比 ubuntu-latest 更可预测),Gradle 8.5,AGP 8.2.0,JDK Temurin 17.0.9。缓存命中时,依赖下载从 2 分钟降到 15 秒;配置缓存命中时,Gradle 配置阶段从 40 秒降到 3 秒。这两个优化是时间缩减的主要来源。


Firebase Test Lab 的集成花了额外功夫。官方没有现成的 GitHub Action,社区有几个第三方实现,但要么年久失修,要么权限模型设计粗糙。我最后用的是 google-github-actions/auth 拿到临时 token,然后直接调用 gcloud firebase test android run。这个命令的参数极其冗长,我把常用配置封装成一个 composite action,放在组织级的 .github 仓库里供多个项目复用。composite action 的一个限制是不能嵌套调用其他 composite action,而且 uses 路径的解析规则在调用方和被调用方视角下不一致,调试时很头疼。


成本核算与 runner 选择


GitHub Actions 的计费对 Android 项目不太友好。Linux runner 每分钟 $0.008,macOS 每分钟 $0.08,Windows 每分钟 $0.016。Android 模拟器必须 macOS,但编译本身 Linux 足够。一个折中方案是:编译和单元测试用 Linux,只在需要跑模拟器测试时用 macOS,而且通过 if: contains(github.event.head_commit.message, '[run-emulator]') 这类条件控制触发频率。


自托管 runner 是另一个选项。GitHub 提供了 runner 的自动扩缩容方案 actions-runner-controller(ARC),部署在 Kubernetes 上。对于 Android 场景,可以定制 runner 镜像,预装特定版本的 SDK、NDK、CMake,省去每次 job 的下载时间。但 ARC 的维护成本不低,需要处理 runner pod 的清理(Gradle daemon 残留、模拟器实例泄漏)、GitHub API 速率限制、以及 Kubernetes 集群本身的稳定性。我试过在一个 5 人小团队推广,两个月后因为运维负担放弃,退回 GitHub 托管 runner。


GitHub 的 larger runner(4 核 16GB 起步,最高 64 核 256GB)对 Android 编译有显著加速,但价格线性增长。实测一个 32 核 larger runner 能让我们的完整 build 从 9 分钟降到 3 分钟,但每分钟成本 $0.064,单次 build 成本从 $0.072 涨到 $0.192。对于需要快速反馈的 PR 检查,这个 trade-off 可以接受;对于 nightly build 就没必要。


调试技巧与故障排查


CI 失败时的调试效率直接影响开发体验。GitHub Actions 的日志搜索功能很弱,大日志文件经常浏览器卡死。我的习惯是在关键步骤后加 if: failure() 条件,上传 Gradle 的 build scan 链接或完整日志作为 artifact。


Gradle build scan 是个被低估的工具。在 gradle.properties 里加 org.gradle.buildscan.publish=always,配合 GRADLE_ENTERPRISE_ACCESS_KEY 环境变量,每次 build 自动生成扫描报告。免费版 Gradle Enterprise 云托管的 scan 保留 30 天,足够调试用。它的依赖解析时间线、task 执行瀑布图、缓存命中统计,比 GitHub 的原生日志直观得多。


另一个常见故障是 gradlew 的权限问题。Windows runner 不区分可执行权限,但 Linux 和 macOS 需要 chmod +x gradlew。GitHub 的 checkout action 在 Windows 上默认不保留文件权限,跨平台协作时容易出问题。解决方案是在 workflow 里显式加一步 run: git update-index --chmod=+x gradlew,而不是依赖本地 git add --chmod=+x 提交。


网络超时也困扰过很多项目。jcenter() 关闭后,某些老旧依赖只能从特定镜像下载,而 GitHub 的 runner 对国内或小众仓库的连通性不稳定。在 settings.gradle.kts 里配置多个仓库并启用 content { includeGroup("...") } 的过滤,可以减少无效请求。极端情况下,在自托管 runner 上搭一个 Nexus/Artifactory 代理,把外部依赖缓存到内网,能彻底解决网络问题,但引入了新的单点故障。


与竞品 CI 的横向观察


虽然题目是 GitHub Actions,但理解其他 CI 的设计有助于借鉴优化思路。


GitLab CI 的 cache 机制更灵活,支持 cache:key:files 直接基于文件哈希生成 key,不需要像 GitHub Actions 那样手动拼接。它的 rules:if 条件语法也比 GitHub 的 if: 更强大,支持基于变更文件的 diff 决定是否触发 job。但 GitLab 的共享 runner 对 Android 的支持更弱,默认镜像没有预装 SDK,需要自己维护 Docker 镜像。


Bitrise 是专门为移动开发设计的 CI,有可视化的 workflow 编辑器,预置了大量 Android 专用 step。它的缓存机制对 Gradle 更友好,开箱即用的命中率就很高。缺点是价格:免费版只有 200 分钟/月,团队版 $36/月起步,对开源项目不如 GitHub Actions 慷慨。而且它的配置是 YAML 但有很多平台特有语法,迁移成本高。


CircleCI 的 Docker layer caching 是个亮点,Android 项目可以基于一个预装 SDK 的镜像,只把项目代码作为 layer 覆盖上去,镜像构建极快。但 DLC 是付费功能,免费账户没有。它的 circleci/android convenience image 维护得很勤快,AGP 新版本发布后通常一周内更新。


这些观察让我更确定 GitHub Actions 的优化方向:把官方没做好的部分(Gradle 缓存精细化、Android 模拟器管理)用社区 action 或自定义脚本补齐,同时利用 GitHub 在生态集成(Issues、PR、Packages 的联动)上的优势。


一个可直接落地的模板框架


说了这么多,给一个经过生产验证的 workflow 骨架。这不是复制粘贴就能用的完整配置,而是需要按项目调整的关键结构。


setup job 负责环境准备:checkout 代码,用 setup-java 装固定 patch 版本的 JDK,用 setup-gradle 做基础缓存预热,输出一个 cache-hit 的 flag 供下游 job 参考。


lint job 独立运行,用 continue-on-error: true 让它不阻塞 PR 合并(如果团队 lint 债务较重),但把报告上传到 GitHub Code Scanning 的 SARIF 格式接口,在 PR 的 Files changed tab 里直接显示警告位置。github/codeql-action/upload-sarif 这个 action 对 SARIF 的格式要求很严格,Android Lint 的默认输出需要 sarif 报告类型,而且 informational 级别的规则会被 Code Scanning 忽略,需要在 lint 配置里调高 severity。


unit-test job 是核心反馈路径,拆分 testDebugUnitTesttestReleaseUnitTest 为两个 step 而不是一个 test task,因为 release 测试需要额外的 ProGuard/R8 配置验证,失败原因通常不同。测试报告用 EnricoMi/publish-unit-test-result-action 发布到 PR 评论,这个 action 的 v2 版本支持按测试类分组折叠,长列表不会刷版。但要注意它的 token 权限:在 fork 的 PR 里,GITHUB_TOKEN 默认只有 read 权限,需要 pull-requests: write 的显式声明,否则评论发布失败但 job 显示绿色,很误导。


assemble job 分 debug 和 release 两个变体,debug 用于 QA 快速验证,release 走完整的签名和混淆。AAB 产物用 actions/upload-artifact 暂存,同时触发一个可选的 deploy-play-store job,用 r0adkll/upload-google-play action 推送到 Google Play 的内部测试轨道。这个 action 的 v1 版本有 bug,AAB 上传后版本号显示异常,v2 修复了但文档没更新,issue 区里有讨论。


最后,整个 workflow 的触发条件要精细控制:push 事件只跑 mainrelease/* 分支的完整流程,PR 事件跑 lint + unit-test,手动触发 workflow_dispatch 可以选跑模拟器测试或 Play Store 发布。避免每个 commit 都跑全套,既省钱又减少排队等待。


这个框架在我们团队跑了八个月,迭代了十几版。它不完美——Firebase Test Lab 的集成还是偶尔超时,larger runner 的成本还没算清楚到底值不值——但比最初的复制粘贴模板可靠太多。CI 配置和代码一样,需要持续重构,而不是一劳永逸。

ProGuard 和 R8 的规则文件编写技巧 2026-07-06
WorkManager 的约束条件,电池优化下还靠谱吗 2026-07-06

评论区