这几个 GitHub 仓库,帮我省了不少时间
这几个 GitHub 仓库,帮我省了不少时间
从一次 CI 崩溃说起
去年维护一个老项目时,我们的 GitHub Actions 流水线突然开始随机失败。错误日志很干净,没有任何 stack trace,只有一行 "Process completed with exit code 137"。查了一圈才知道,这是 OOM killer 干的——GitHub Actions 的 runner 只有 7GB 内存,而我们的 Gradle build 在 AGP 8.0 升级后内存占用暴涨,加上几个并行任务,触发了 Linux 的 OOM 机制。
当时团队里有人提议直接上自托管 runner,被老板以"运维成本"否了。我花了一个周末翻 GitHub 上各种 Android CI 优化的仓库,最后靠几个开源工具的组合把 build 时间从 14 分钟压到 4 分钟,内存峰值也稳在了 5GB 以下。这个过程中发现的几个仓库,后来成了我每个新项目都会引入的标配。这篇文章就是关于它们的,以及我用下来觉得需要警惕的地方。
Gradle Doctor:那些 Gradle 没告诉你的事
第一个要聊的是 Gradle Doctor,作者是 Square 的 Nelson Osacky。这个插件的定位很精准:它不做优化,只负责诊断。安装后会在每次 build 结束时输出一份"体检报告",告诉你哪里可能有问题。
我最早注意到它是因为一个反直觉的现象。那个 CI 崩溃的项目,本地 build 一直很快,但 CI 上就是慢。Gradle Doctor 跑完直接标红了一条:"GC 开销过大,检测到 47 次 Full GC,总耗时 3.2 分钟"。原来是我们给 Gradle daemon 配的堆内存太小(默认 512MB),而项目依赖了 200 多个第三方库,metadata 解析阶段就把老年代塞爆了。CI 上每次 build 都是全新环境,没有 warmed-up daemon,这个问题被放大到了极致。
Gradle Doctor 的另一个实用功能是检测"build cache 失效"。它会对比两次 build 的 task inputs,找出哪些 task 本可以从 cache 恢复却被强制重新执行。我们有一次发现 mergeDexRelease 总是 cache miss,追下去是某个自定义 plugin 在 task 的 doFirst 里往输入目录写了个时间戳文件,导致 input 的 hash 每次都变。这种坑没有工具提示,靠肉眼 diff 几百行 Gradle debug log 根本找不出来。
不过 Gradle Doctor 也有让我踩坑的时候。它默认会检查"Jetifier 是否必要"——如果你的项目已经没有 Support Library 依赖,它会建议你关掉 android.enableJetifier。听起来合理,但我们的项目依赖了一个内部 SDK,那个 SDK 的 AAR 里还包着 android.support.v7.widget.RecyclerView 的引用。关掉 Jetifier 后编译通过,运行期直接 ClassNotFoundException。Jetifier 检查这个功能本身没问题,但它的判断基于静态分析依赖树,检测不到 transitive AAR 里的 class 引用。后来我给那个检查加了 doctor.enableJetifierCheck = false,也提了 issue,作者回复说这种 runtime-only 的 Support Library 引用确实没法静态检测,建议配合 byecycle 一起用。
还有一个不算坑但需要注意的点:Gradle Doctor 的报告输出在 build 结束后,如果 build 本身因为 OOM 被 kill 了,你是看不到报告的。所以它的诊断能力和 build 的稳定性是绑定的,对于那种频繁崩溃的项目,得先想办法让 build 能跑完一次。
gradle-build-action:GitHub Actions 上的缓存博弈
解决 CI 内存问题的过程中,第二个关键仓库是 gradle/gradle-build-action。这是 Gradle 官方维护的 GitHub Action,核心能力是对 Gradle 用户 home 目录做精细化缓存。
以前我们的 workflow 是这样写的:
- uses: actions/cache@v3
with:
path: ~/.gradle/caches
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}看起来标准,实际上问题很大。~/.gradle/caches 里混了 build cache、dependencies cache、wrapper cache、还有 daemon 的临时文件。GitHub Actions 的 cache 有 10GB 上限,这个目录膨胀到 6GB 后,每次 cache save/restore 就要花掉 2-3 分钟,而且 key 设计粗糙,经常 restore 了一个过时的 cache,导致实际命中率很低。
gradle-build-action 的做法是拆分 + 智能失效。它把缓存拆成四块:wrapper 单独缓存(key 基于 gradle-wrapper.properties 的 distributionUrl)、dependencies 按解析结果缓存(key 基于依赖的 hash)、build cache 独立管理、还有 daemon 的 JVM 参数缓存。最实用的是它的"cache write"策略:默认只在 main 分支的 build 成功后写 cache,PR 分支只读。这避免了一个经典问题——恶意 PR 往 build script 里塞个超大依赖,把 team 的 10GB cache quota 打满,导致其他分支的 cache 被 evict。
用这个 action 后,我们的 cache restore 时间从平均 2 分 40 秒降到 20 秒,cache 体积稳定在 3GB 以内。但这里有个版本陷阱。action 的 v2 和 v3 在缓存 key 的生成逻辑上有 breaking change,v2 的 cache 在 v3 下会被视为 miss。我们有一次升级 action 版本后,连续一周的 CI build 都变成了"首次 build",速度比没缓存还慢——因为没命中 cache 却要花额外时间写新的 cache。官方文档里 buried 在 migration guide 里的一句话提到了这个,但 README 的主流程没强调。我的建议是:升级前先用 actions/cache 的 API 把现有 cache 列出来,确认 key 命名空间的变化,或者直接清掉旧 cache 重新开始。
另外,这个 action 的 gradle-home-cache-cleanup 功能(默认关闭)建议打开。它会定期清理 cache 里未被引用的旧版本依赖。我们的项目依赖更新频繁,开了这个功能后 cache 体积增长了两个月后反而开始下降,长期维持在 2.5GB 左右。
Dependency Guard:防止依赖的"静默膨胀"
第三个仓库是 Dependency Guard,Dropbox 开源的。它的功能一句话概括:锁定依赖树,防止意外变更。
Android 项目的依赖管理有个长期痛点。你在 build.gradle 里写的是 implementation("com.squareup.okhttp3:okhttp:4.10.0"),但实际 resolved 的版本可能是 4.10.0,也可能是 4.11.0——如果某个 transitive dependency 的 POM 里强制升级了版本。更隐蔽的是,同样的版本号,不同时间 resolve 出来的 POM 可能不同,因为上游仓库(Maven Central 或者某个内部 Nexus)的 metadata 变了,或者某个 -SNAPSHOT 依赖被重新发布。
Dependency Guard 的做法是生成一份"依赖锁文件",记录每个 resolved dependency 的精确坐标和 checksum。CI 上跑 dependencyGuard task,如果实际 resolved 结果和锁文件不一致就 fail build。
我们引入这个工具的契机是一次生产事故。某个内部 SDK 的 2.1.3 版本被重新上传到了 Nexus——不是 SNAPSHOT,是 release 版本被覆盖了。Gradle 的默认缓存策略是信任本地缓存,所以本地 build 没问题;但 CI 的 clean 环境拉到了"新"的 2.1.3,里面多了一行代码调用了我们项目里不存在的 API,编译直接失败。更糟的是,这个失败发生在周五晚上,阻塞了整个团队的发布流程。事后复盘,如果当时有 Dependency Guard,CI 会在第一时间检测到 checksum 变化,而不是等到编译阶段才暴露。
这个工具的配置也有坑。它的锁文件默认生成在 dependencies/ 目录下,文件名是 releaseRuntimeClasspath.txt 这种。我们项目有 6 个 flavor × 2 个 build type = 12 种 variant,锁文件爆炸到 12 个。后来通过 dependencyGuard 块的 configuration 过滤,只锁定真正发布的 variant,减少到 2 个。还有一个细节:它的 checksum 用的是 SHA-256,但默认不锁 POM 文件本身的 hash,只锁 artifact(AAR/JAR)。这意味着 POM 里的 transitive dependency 声明变了,Dependency Guard 检测不到。需要显式开启 includePomChecksums = true,这个选项在文档里被标记为"experimental",但我用了一年多没出过问题。
最大的局限是:Dependency Guard 只检测"变化",不判断变化是否"安全"。一个合法的依赖升级(比如 patch 版本的安全修复)和一个恶意的篡改,它都会 fail build。这意味着团队需要建立锁文件更新的流程——我们规定锁文件变更必须走单独的 PR review,不能和 feature 代码混在一起。这个流程成本对于小团队可能偏高。
Macrobenchmark 样本库:Google 没说完的事
第四个不是单个仓库,而是 Google 的 android/performance-samples 里的 Macrobenchmark 部分。但我要说的重点不是这个官方样本本身,而是围绕它的一系列社区仓库和 issue 讨论。
Macrobenchmark 是 Jetpack 里用来测量启动时间和帧率的工具,原理是在 test APK 里通过 UiAutomation 执行 shell 命令控制被测应用。官方文档的 quick start 看起来很顺:加依赖、写 MacrobenchmarkRule、跑 measureRepeated。但我们在 Pixel 6 上跑第一个 benchmark 时就撞墙了——measureRepeated 抛异常,IllegalStateException: Unable to find target package。
翻性能样本仓库的 issue #127,发现这是 Android 13 的权限变更导致的。Macrobenchmark 需要 DUMP 权限来读取 frame timeline,而 Android 13 上这个权限的授予方式从 adb shell pm grant 变成了需要通过 android.permission.DUMP 的 shell 交互。官方样本代码在 issue 讨论两周后才更新,而那个 issue 里有个叫 Chris Craik 的 Google 工程师(Macrobenchmark 的核心作者之一)贴了一段 workaround,用 UiAutomation.executeShellCommand 配合 sleep 等待权限生效。这段代码没有进任何官方 release,但我们在自己的 benchmark 模块里用了半年多,直到 AGP 8.1 配套的 benchmark 1.2.0-alpha 才正式修复。
另一个围绕这个样本仓库的实用工具是 androidx.benchmark:benchmark-macro-junit4 的 profiling 配置。官方文档只提了 MethodTracing 和 SampleTracing 两种模式,但样本仓库的 advanced 分支里有 StackSampling 的配置,采样频率可以调到 1000Hz。我们用它定位过一个 RecyclerView 的卡顿:默认的 MethodTracing 开销太大,测出来的帧时间失真;StackSampling 的 overhead 在 3% 以内,抓到了 DiffUtil 的 calculateDiff 在主线程执行的问题。这个配置在官方文档里完全没有,只在样本代码的注释里有一行说明。
Macrobenchmark 的坑还包括设备兼容性。三星 Galaxy S 系列的 One UI 对后台进程限制很激进,Macrobenchmark 的冷启动测量需要 kill 进程再启动,One UI 的"电池优化"会把 kill 后的进程保留在缓存里,导致测出来的是温启动时间。我们在样本仓库的 issue 里找到个非官方方案:用 am crash 代替 am force-stop,绕过 One UI 的缓存策略。但这个方案在 Android 14 上又失效了,因为 Google 限制了 am crash 的调用频率。目前没有完美解法,我们的做法是 benchmark 只跑 Pixel 设备,三星设备上的性能数据用手动测试补充。
Now in Android:一个被低估的架构参考
第五个仓库是 android/nowinandroid,Google 的官方架构样本。很多人把它当 Compose 的学习材料,但我用下来最有价值的是它的模块化结构和 Gradle 配置。
Now in Android 的模块划分遵循"功能垂直切片":每个 feature 模块包含自己的 UI、ViewModel、Repository interface,数据实现放在 core 模块。这种划分在官方文档里被描述为"推荐实践",但文档没说的是 Gradle 配置上的代价。feature 模块之间的依赖如果形成环,Android Gradle Plugin 8.0 以上会直接 fail build,错误信息是 Circular dependency between the following tasks,但指向的 task 名称和实际有问题的模块依赖链往往差很远。
Now in Android 用了一个 module graph assertion 的自定义 plugin 来防环。这个 plugin 不是独立发布的,是直接写在 build-logic 里的。我们把它抽出来改造了一下,加了层数限制(feature 模块不能依赖其他 feature 模块,只能依赖 core),以及禁止跨层依赖的检查。这些规则在 Now in Android 里是靠 code review 保证的,我们把它自动化后,新入职的工程师第一次提交就能在 CI 阶段得到明确的错误提示,而不是等到 code review 才被指出。
这个仓库的另一个实用片段是它的 version catalog 设计。不是简单的版本号集中管理,而是把 Compose 相关的库用 bundle 打包,把 plugin 版本和 library 版本用同一个变量关联。比如 android-gradlePlugin 和 android-tools-common 共享 androidTools 版本,避免 AGP 和 lint 版本不匹配导致的诡异问题。我们之前遇到过 lint 检查在 CI 上 pass、本地 fail 的情况,追查发现是 AGP 8.0.2 配的 lint 31.0.2,而某个工程师本地缓存了 31.1.0-alpha 的 lint,行为不一致。Now in Android 的版本关联设计从机制上避免了这个问题。
但 Now in Android 作为"官方推荐"也有误导性。它的网络层用了 Retrofit + Kotlin Serialization,这个组合在官方文档里被大肆宣传,但实际用下来有个性能陷阱。Kotlin Serialization 的 Json 实例默认配置对未知字段的处理是 ignore,而 Retrofit 的 converter 每次请求都创建新的 Json 实例。我们在高并发场景下(批量下载配置)发现 GC 频繁,profile 下去是 Json 的 SerializersModule 分配了大量短期对象。后来改成复用单例 Json 实例,吞吐量提升了 40%。这个优化在 Now in Android 里没有体现,因为样本代码的请求频率很低,不会触发这个问题。
一个冷门但救命的工具: dependency-analysis-gradle-plugin
最后要提的是 dependency-analysis-gradle-plugin,作者 Tony Robalik。这个插件分析你的依赖使用情况,告诉你哪些依赖是"unused"(声明了但没用到)、哪些是"used transitively"(没声明但 transitive 拉进来的,应该显式声明)、哪些是"api vs implementation 误用"(implementation 依赖暴露到了 public API 里,应该升级成 api)。
我们引入这个插件时,项目已经膨胀到 180 个模块,依赖管理完全是历史遗留状态。插件跑完输出了 400 多条建议,看起来 overwhelming,但它的报告是按 severity 和 category 分类的,可以先处理 compileClasspath 里的 unused dependency——这些是最安全的删除,不会影响到 runtime。
实际执行中有个细节:插件判断"unused"是基于字节码引用分析的,但反射调用和 JNI 调用检测不到。我们有一个模块依赖了 com.google.protobuf:protobuf-java,代码里没有直接 import,但通过 Class.forName 动态加载了 GeneratedMessageV3。插件建议删除这个依赖,如果无脑执行就会 runtime crash。作者 Tony 在 issue #769 里讨论过这个问题,目前的 workaround 是在 build script 里加 dependencyAnalysis.issues { onUnusedDependencies { exclude("com.google.protobuf:protobuf-java") } }。我的建议是:对插件的建议分批处理,每批只处理一种类型,处理完跑全量测试,而不是一次性"优化"到底。
这个插件的另一个实用功能是检测"abi 变更导致的重新编译"。Gradle 的 incremental compilation 在 api/implementation 边界上很敏感:如果模块 A 以 api 依赖模块 B,B 的 public API 变了,所有依赖 A 的模块都要重新编译。插件可以分析出哪些依赖实际上只用到 B 的 implementation 细节,建议降级成 implementation,从而缩小变更传播范围。我们在一个核心模块上应用这个优化后,修改该模块的某个内部工具类,受影响需要重新编译的模块从 47 个降到 3 个,CI 的 incremental build 时间从 8 分钟降到 1 分钟。
插件的局限在于运行速度。全量分析我们 180 模块的项目需要 6 分钟,所以不会放在每个 PR 的 CI 里,而是 nightly 跑一次,生成报告发到 Slack。另外它的 Android Gradle Plugin 版本兼容性跟踪得很快,AGP 8.0 发布一周内就有适配版本,但偶尔会有 regression。我们遇到过 1.20 版本在分析 androidTest 依赖时 NPE,回退到 1.19.0 解决,两周后 1.21 修复。
关于"省时间"的诚实说法
把这些工具串起来看,它们解决的不是同一个层面的问题,但有一个共同特点:都是把 Gradle/Android build 过程中那些"隐式行为"变成"显式可检查"的东西。Gradle Doctor 暴露 GC 和 cache 问题,gradle-build-action 把缓存策略从黑盒变成可配置,Dependency Guard 锁定依赖解析的不确定性,Macrobenchmark 样本填补了官方文档和实际 API 行为之间的 gap,Now in Android 提供了可执行的架构约束,dependency-analysis-plugin 则把依赖关系的复杂度降到人类可以 review 的范围。
但"省时间"这个说法需要加个限定。引入这些工具的前期投入是真实的:Gradle Doctor 的安装和配置花了半天,dependency-analysis-plugin 的 400 条建议处理了两周,Macrobenchmark 的权限 workaround 调试了三个晚上。它们省的是"长期维护中反复踩同样坑的时间",而不是"今天就能少干点活"。如果你的项目是短期交付、做完就扔,这些工具可能是过度工程。
另外,所有这些工具都是围绕 Gradle 生态的。Google 在推 Bazel 作为替代,Compose Multiplatform 也在探索自己的 build 系统。如果五年后 Android 的主流 build 工具不再是 Gradle,这些工具的积累可能大部分要作废。但这是技术选型的常态风险,不是拒绝当下优化的理由。
我现在的做法是每个新项目都从这份清单里按需选取,不是全上。CI 频繁的项目必装 gradle-build-action 和 Gradle Doctor,依赖复杂的加 Dependency Guard,需要性能基线的加 Macrobenchmark,模块超过 20 个的考虑 dependency-analysis-plugin。Now in Android 的代码片段则是持续参考,但不盲从它的所有技术选型。工具是手段,解决具体问题是目的,这个顺序不能倒过来。