本文主要分享常见的 Gradle 编译优化手段,并提供成本,收益,推荐度等维度供参考。以帮助大家快速找到最适合自己项目情况的优化项。
每个团队或许都有那么一个或两个比较关注工程编译耗时的同学,那么这篇文章就是分享给你的。
本文主要分享常见的 Gradle 编译优化手段,并提供成本,收益,推荐度等维度供参考。以帮助大家快速找到最适合自己项目情况的优化项。
工欲善其事,必先利其器。本章节介绍可以让你观测编译情况的工具。
Gradle Build Scan 是分析编译耗时不得不了解的一个官方工具。它提供了几乎所有你想了解的信息:
方式一:gradle 命令末尾加上 --scan
参数
方式二:在工程根目录 settings.gradle
增加如下声明:
apply plugin: com.gradle.enterprise.gradleplugin.GradleEnterprisePlugin
gradleEnterprise {
buildScan {
termsOfServiceUrl = "https://gradle.com/terms-of-service"
termsOfServiceAgree = "yes"
// isOpenDebugLog 控制是否开启默认发布scan链接
publishAlwaysIf(isOpenDebugLog)
}
}
在不同的时间段,我对产品的主工程编译进行了集中投入优化,效果还不错:
编译耗时中位数 1.5min -> 0.5min(中位数可以比较好的体现增量编译的耗时):
编译平均耗时 2.5min -> 1.6min(本地):
编译平均耗时 5.4min -> 4.1min (蓝盾):
当然,项目是不断在增长和劣化的,停止优化后编译耗时又会开始缓慢上涨。
电脑很卡,那就换台电脑。——鲁迅
这里云构建指的是:购买云开发机,通过 ssh 和 rsync 工具完成源码推送,编译,产物拉取。
在漫长的代码提交过程中,会有各种各样的人因为各种各样的需求,往工程里面增加各种各样的 task。
但并不是每个人都擅长将一个 task 写的高效,很容易就让编译耗时逐渐劣化。比较常见的,就是写了一个每次都需要执行的耗时 task。
Gradle task 的执行结果大部分情况是这三种:(你可以通过 gradle 运行时输出和 build scan 来观察 task 的执行结果)
更多的类型见:Authoring Tasks
不带任何声明直接实现一个 task,执行结果将每次都是 EXECUTED。 如果需要让 task 在第二次执行变为 UP_TO_DATE,其中一个必要条件是:需要将所有的入参和出参都用 @Input @Output 等注解声明,以此告诉 Gradle 如何正确地保证 task 按需执行。
所以这同样也有一个弊处:不正确地声明输入输出,可能导致 task 该执行的时候没执行,出现预期相反的情况。
如何实现一个正确的增量编译 task,可参考官方介绍:Incremental build
在漫长的代码提交过程中,会有各种各样的人因为各种各样的需求,往工程里面增加各种各样的 task。(复读机)
但并不是每个人都会细致的思考:我这个 task 是否所有人都需要?是否每次构建都需要? 久而久之,就会出现不少平时编译调试并不需要的 task,但每次都花费大家不少的执行耗时。
对于本地开发编译,这里有几个建议可以参考:
上文提到 task 常见的三种执行结果:EXECUTED,UP-TO-DATE,FROM-CACHE,其中 FROM-CACHE 就是命中缓存的结果。
命中缓存和 UP-TO-DATE 的判断条件几乎一致,差异是:
@CacheableTask
来声明自己可以缓存;build cache 是 Gradle 自带的一个 task 缓存能力。打开方式:在 gradle.properties
声明 org.gradle.caching=true
,打开后默认启用本地缓存。
所有可能影响 task 的变量,包括但不限于所有入参,task 实现,buildSrc 源码,gradle 版本,JVM 版本,都会被加入计算,得到一个 string 类型的 cache key。通过 cache key 可以快速比对是否有命中的缓存。build-cache 最终会存储到这里:
和 UP-TO-DATE 的问题一样,如果没有正确实现入参出参声明,则可能出现 cache 不正确被复用的情况。
目前大部分 Gradle 和 AGP task 都已经正确实现入参出参声明和声明可缓存。之前开发还会偶尔出现脏 cache 的情况,需要 clean + 关闭 cahce。但升级为 gradle 7.3.3 + AGP 7.2.2 之后,我个人就没遇到过了。
同事倒是经常说遇到,但没有证据。(编译信任是一件很难的事)
官方介绍:Build cache
正常情况下,本地 build cache 只在工程删除了产物的时候能够用上。如果是多工程场景,如我们可能在一个工程上同时开发多个需求,我们可能就会同时有这个工程的多份拷贝。这个时候如果两个工程代码相似,则在这个工程编译完成后,另一个工程有机会复用部分 task 缓存,节省编译时间。
但看起来好像还是挺鸡肋的~其实 build cache 真正的杀器是远程 build cache,见下节。
Gradle 支持指定远程 build cache。这样一来,task 缓存就可以跨设备共享了。比较典型的做法是,由 CI 构建编译并上传 build cache,本地开发机仅读取。
搭建远程 build cache 的服务器有几个选择:
更详细的 build cache 配置方法可看官方介绍:Configure the Build Cache
前面提到非常多的条件可能使得 task 缓存 key 发生变化,导致无法复用缓存:
我们判断 task 是否成功复用缓存,可以通过以下方法检验:
对于没有 FROM-CACHE 的 task,除了声明不支持 cache 之外的 task,我们需要分析缓存为何没有复用。最好的办法就是使用 build scan 的编译结果比较功能,他可以指出两个编译之间,为何 task 的缓存无法复用:
但目前该功能已经收费了,只能用免费的办法:编译时增加参数 -Dorg.gradle.caching.debug=true
,此时 gradle 会把 cache key 的计算过程打印出来。我们拿到所有日志后,在两个编译之间再进行比对。
以下一些常见的操作可能会导致你的缓存无法复用:
fileTree(include: ['*.jar'], dir: 'libs')
导致依赖顺序不稳定。fileTree 是一个顺序不稳定的 api,需要进行排序:fileTree(include: ['*.jar'], dir: 'libs').sorted()
kotlin.incremental.useClasspathSnapshot=true
。会导致编译产物不稳定导致无法复用 Kotlin 编译缓存,建议关闭。android.enableJetifier=true
。jetifier 本身是一个输出不稳定的工具,不同设备的 jetfied 结果可能和本地不一致,导致 jar md5 不一致,从而导致缓存无法复用。于是我花了不少精力,把 jetifier 关掉了(见后面内容)。
SNAPSHOT
包。由于 SNAPSHOT 包更新和实现的不确定性,会导致不同设备的依赖不完全一致。非常建议使用非 SNAPSHOT 包以提高缓存命中率。
api
依赖。API 的依赖变更,会导致所有模块需要重新编译,建议减少 api
依赖。
EventBus 也有生成代码乱序的问题,但这个能力是用于加速查找索引的,非开发阶段必须,所以 debug 包可以不执行:
在解决了大部分缓存复用的问题后,全新构建从 15min 降低到最低 3min,在大部分主干构建场景下,可节约 80% 以上的构建时间。
Gradle 的执行分为三个阶段: 初始化 Initialization,配置 Configuration,执行 Execution。
Gradle configuration cache 指的是配置阶段的缓存,当执行过一次某个任务时,下次执行可以跳过配置阶段,直接进入 Execution 阶段。
configuration cache 本质上是将 task 入参,依赖关系等进行持久化存储,下一次运行的时候只要环境变量和执行命令都没有改变,就直接将缓存反序列化,就不用再经过 configuration 阶段执行了。configuration cache 的存储位置为项目根目录的 .gradle/configuration-cache
。
我所在团队的主工程模块数量达到了 180 个。每次少量修改编译耗时约 60s,但 configuration 就占了 20-30s。 configuration 缓存的实现对我们工程有着非常大的帮助。
configuration cache 打开方式是:在 gradle.properties
中声明 org.gradle.unsafe.configuration-cache=true
打开后运行任意 task,运行结束后 gradle 会判断是否可以缓存。如果不能缓存则报错(不影响 task 执行)。
报错可以通过 org.gradle.configuration-cache.problems=warn
来降级为 warn。但不推荐这么做,因为降级后容易出现其他同学提交了劣化的代码而不自知。
在 configuration 缓存上线后的一段时间内,我遭遇了非常多次背刺。有些同学发现自己写的一些 task 无法复用缓存,然后就会将缓存关闭然后提交到主干。痛苦了一段时间后,最后我通过增加 MR 检查,校验缓存是否关闭,和是否可以成功复用,来保障功能的安全。
官方介绍:Configuration cache
上面提到,打开 configuration cache 后,gradle 会把有问题的地方的报错出来并给出理由,直到所有问题解决。我们只需要一个个修复就可以了。
这里列举大部分场景可能出现的报错,方便大家评估适配工作量:
XXXX
: read system property ‘YYYY’原因是执行过程中读取了环境变量。
值得注意的是,只有读取存在的环境变量才会报错,如果脚本有读取环境变量逻辑,但实际上该环境变量不存在,则可以成功缓存。
org.jetbrains.kotlin.com.intellij.openapi.util.SystemInfoRt
: read system property ‘sun.arch.data.model’原因是 Kotlin 未完全适配 configuration cache。 但这个只在首次编译会出现一次,第二次就消失了,所以可以不管。
据说升级到 Kotlin 1.5 可以解决,但我这边工程已经是 1.7 还是可以偶尔报错,可能是依赖版本没有清理干净。
原因是 task 执行期间引用了 project 对象,导致无法缓存 configuration。
task 也分为初始化阶段和执行阶段,我们需要在 task 创建时,把需要的变量存储并声明为 @Input,从而实现执行阶段访问 project 对象。
:app:compressImages
of type com.tencent.karoke.ImageCompressionTask
: cannot serialize object of type ‘java.util.concurrent.ThreadPoolExecutor’, a subtype of ‘java.util.concurrent.Executor’, as these are not supported with the configuration cache.原因是 task 的变量无法被序列化 ,导致无法缓存 configuration。需要保证 task 的参数都是可以序列化的。
手 Q 的同学的这篇文章 另辟蹊径,超高性价比的通用型Configuration优化方案,通过闲时启动,Execution 前阻塞等待的方案,避免了 configuration cache 的改造成本,又同时实现了 configuration “无耗时” 的方案。
Gradle 会在 Sync 和 Configuration 的时候,请求 Maven 仓库下载未下载的依赖库,或检查是否有更新。
正常情况下,Gradle 会正确运行,不会有不合理的请求。但不正常才是正常,如果:
那么 Gradle 执行过程中,就会有不必要的网络请求。多的时候,每次编译可能都要花费 10s 以上时间去做不必要的网络请求。(Offline Mode 可以解决此问题但开开关关也麻烦)
网络请求优化的整套方案,包括检查,修复,防裂化的方案可以直接参考:gradle sync阶段依赖库耗时治理和防劣化
此外,减少不必要的 maven 库引入,和调整 maven 库顺序,来提高查找命中率,也可以有效地优化首次下载的耗时。
模块数量会导致 configuration 阶段耗时增加,和编译 task 增多。应避免过度增加 gradle 模块。
如果你的工程已经有很多模块了,可以考虑源码 aar 切换方案。方案大致如下:
此方案优点:
此方案缺点:
暂时没有投入。全民 K 歌团队有一个不错的实践:Android全量编译加速方案
Jetifier 是 Android 官方用来将 support 库迁移到 AndroidX 库的工具。
Jetifier 已内置到 AGP(Android Gradle Plugin)。通过声明android.enableJetifier=true
,AGP 会把你所有的依赖库都转换一遍,保证不会留下 support 库依赖。
Jetifier 有一定耗时,主要在下载新依赖库时需要进行一次转换。关闭 Jetifier 可以减少 Sync 和编译耗时。
大家可能看过一篇比较火的文章:哔哩哔哩 Android 同步优化•Jetifier,里面 Sync 耗时 10 分钟挺吓人的。但其实开发场景遇不到,因为就算你 clean 了 project,gradle 也有缓存的 jetified 产物。
所以这个操作在本地开发基本是增量的,只有库版本更新的时候才需要真正执行,耗时不高。如果是无缓存构建会对耗时一定优化。
工作量主要分三部分:
exclude group
干掉。(jetified 工具会告诉你没有需要转换的 API)选择 Migrate to AndroidX,IDE 会扫描出来。
如果存在未清理的 support 库,则会因为重复类而报错。(特别需要注意:你的工程依赖没有主动 exclude support 库,否则就检查不出来了)
通过 maven-publish
插件编写上传脚本,或 maven
命令行手动上传到内部仓库。
也是 Bilbili 文章提到的一个参数,关闭 jetifier 的检查。
看起来是蚊子肉,不太有感觉(你进来了吗?)。
Transitive R Classes 指的是:如果模块 A 依赖模块 B,那么你可以用模块 A 的 R 类,直接引用模块 B 的 资源(资源具有传递性)。
那么 Non-Transitive R Classes 指的是:模块 A 要引用模块 B 的资源,需要用 B 的 R 类来访问(因为都叫 R,这时候通常就需要指定 B 类 R 的完整类名)
Non-Transitive R Classes 对于大工程好处:
generateDebugRFile
耗时。传递性 R 会触发所有依赖模块的 R 文件生成 task。还没做,等刷 OKR 的时候再做吧。
generateDebugRFile
的编译耗时,和部分 compileJava
和 compileKotlin
的编译避免(一个模块增加了资源 ID,R 文件的变化不会再穿透到其他模块)。org.gradle.jvmargs
用于指定 gradle 进程的 JVM 参数,可以指定 JVM 初始堆内存大小,和最大堆内存大小等。
kotlin.daemon.jvm.options
用于指定 Kotlin 编译器守护进程的大小。
举例:org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M" -XX\:+UseParallelGC
这里的参数配置是:
设置过小的最大内存可能导致 OOM;设置过大的最大内存会使你的编译环境变得很卡。我们团队的工程曾经因为构建 release 包 OOM,把两个最大内存都改到了 8G,结果导致平时开发变得很卡。
所以这里也建议分开维护 CI 构建和本地开发的 org.gradle.jvmargs
参数。方式有两种:
gradle.properties
的内容;-Dorg.gradle.jvmargs="-Xmx8192M -Dkotlin.daemon.jvm.options\\=\"-Xmx8192M\""
。如果你感觉这次编译突然变慢了很多,而且出现了 Could not connect to Kotlin compile daemon
,那么说明 Kotlin 编译器的守护进程挂了(猜测是 OOM 导致)。失去了守护进程的 Kotlin 没有了复用能力,Kotlin 编译会慢很多倍。
这个时候取消编译重新跑一次,会比你老实等待编译完成更快。
这个参数据说可以增快 40% Kotlin 1.7 的编译速度(A new approach to incremental compilation)。
但我加到工程里之后,编译耗时大盘均值基本没有波动。
不仅如此,后面在复用 CI 缓存的时候发现这个参数还导致 CI 的 task 缓存和本地编译的 task 缓存无法复用。遂弃之。
开启 kapt 增量编译,需要 kapt 注解器支持。未发现副作用。
按需 configuration 模块。如果一个 task 不需要所有模块执行,那就不配置所有模块。未发现副作用。
复用 gradle 进程,复用的情况下,编译可以快约 30%。
但无论开关,Android Studio 都会开启一个常驻进程。
但还是建议开启,因为对云编译有效。
并行执行 task。未发现副作用。
感谢阅读,欢迎交流!