Android编译速度优化 - 升级Gradle/Kotlin

1,920 阅读10分钟

背景

对于Android APP 开发,绝大多数项目都是大量使用Kotlin开发。 与此同时,项目规模也越老越大, 进而导致APP编译时间越来越长,严重影响开发效率

目前有些公司仍用的较低版本的编译工具链, 如gradle: 7.2.0以下, kotlin: 1.5.x 等等

认为升级到较新版本一方面是成本高, 一方面收益不大, 进而一直维持在低版本上

实际上现在为止,编译工具链已经发展的越来越好了, 在编译速度上有一些很大的进步。 可以考虑升级到较新的版本,来加快编译速度

本篇文章简单的说明开发过程中哪些情况可能会导致编译时间变长, 以及在这些场景新版编译工具链已经解决了这些问题 来给大家升级项目中的编译工具链提供一些参考

现状

哪些步骤会导致编译速度变慢?

大家经常发现在开发过程中编译项目时有时很容易遇到编译时间变长, 或者突然编译时间变长

如在较低版本中,甚至在较高版本, 但是未开启 kotlin.incremental.useClasspathSnapshot 配置

如:

  1. 更改依赖版本,此时会导致所有Java类和Kotlin类重新编译, 编译时间变长
  2. 调整资源如新增layout, id, drawable等,资源类字段发生变化导致R.jar发生变化, 原因同1
  3. 组合构建模块代码变化, 由于旧的kotlin插件不支持组件构建, 仍会识别成普通的JAR/AAR发生变化, 同1

在日常开发过程中, 如果项目配置中大量使用SNAPSHOT版本,或者调整UI的时候进行编译, 很容易导致编译时间变得非常长

我们这里可以简单的验证一下 假设我们之前gradle.properties中已经添加了如下配置来优化编译速度

org.gradle.parallel=true
org.gradle.caching=true
kapt.use.worker.api=true
kapt.incremental.apt=true
kotlin.incremental=true
kotlin.incremental.java=true
kotlin.caching.enabled=true
Kapt.include.compile.classpath=false 

我们在 gradle.properties 中添加如下配置来检查Kotlin编译报告:

kotlin.build.report.enable=true
kotlin.build.report.verbose=true
kotlin.build.report.output=file

编译结束后在 /build/reports/kotlin-build/ 保存Kotlin相关任务build日志

场景一 调整依赖版本

如当把依赖从 androidx.appcompat:appcompat:1.4.1 变成 1.4.2

检查 Build -> Build Analyzer

kotlin-build 日志:

可以发现, 当更新依赖时,会造成整个模块 kotlin代码全部重新编译一遍

场景二: R.jar发生变化, 如新增layout/id/drawable/color等

比如在layout中新增一个id为text_view1的TextView

Build -> Build Analyzer :

Kotlin-build :

可以发现, 这里仍会导致整个模块kotlin代码全部重新编译一遍

场景三: 组合构建导致模块源码重新编译

对于普通的模块间依赖, Kotlin plugin会在build结束时在 /build/kotlin等目录保存大量的build信息,如该模块哪些类发生了变化, 因此上层模块可以知道下层模块变化的类/接口, 进而上层模块只编译受影响的类

但对于组合构建模块, Kotlin plugin并不支持, 因此一旦组合构建模块jar包发生变化,上层引用模块都会触发全量编译

可以参考链接

youtrack.jetbrains.com/issue/KT-47…

应该是字节的大佬提交到issue, 以及对应的patch:

KT-47511: Incremental build compatibility with gradle composing builds

上述描述的都是Kotlin代码

在Gradle 7.2.0 以下,对于java代码, 也存在这样的问题

执行 gradlew :app:assembleDebug --debug

调整依赖版本, 或者新增layout/id/string等导致R.jar变化

执行上述命令后 检查build log

搜索 Full recompilation is required because 关键字即可发现:

调整依赖: Full recompilation is required because Classpath has been changed.

新增layout: Full recompilation is required because 'R.jar' was changed.

这说明了对应上面的三个场景,当前模块所有的 Java文件也都会重新编译

参考实现:

github.com/gradle/grad…

github.com/gradle/grad…

此外我们还会发现, 在低版本kotlin 1.6.21以下 build cache bug

场景四: Kotlin 在命中 build cache 时, 再次编译会导致全量编译

复现步骤非常简单:

  1. 添加 kotlin 方法, 编译 -> 增量编译
  2. 注释掉该方法, 编译 -> 增量编译
  3. 去掉步骤2的注释, 编译 -> 命中build cache
  4. 更改该文件 -> 整个模块Kotlin代码全量编译

这是因为Kotlin 相关task 中部分属性没有写入build cache, 进而无法从build cache中恢复, 导致下次编译时所有Kotlin类都会重新编译

参考链接:

Restoring from build cache breaks Kotlin incremental compilation

Kotlin 在 1.6.21上也已经解决

新版 Gradle/Kotlin 优化

支持模块-依赖 类关系分析

鉴于此,很多大厂为了提升编译速度, 自己实现了模块与依赖间的依赖分析逻辑, 当依赖发生变化时,检查具体变化的类/方法, 只重新编译依赖这些变动的类, 而不是整个模块源码都重新编译, 极大的减小了编译耗时

目前公开的详细文章有:

QQ音乐Android编译提速之路

有赞 Android 编译进阶之路 —— 增量编译提效方案Savitar

有赞 Android 编译优化方案 Savitar 2.0

今日头条 Android '秒' 级编译速度优化 增量 java/kotlin 编译

这些文章一方面发布时间较早, 目前不清楚他们的最新的状态

另一方面没开源, 我们没法去用他们的成果, 并且这些方案可能与相应gradle/kotlin plugin 耦合较深,导致这些版本无法升级

此外对于常量内联等, 只简单的分析字节码是无法很好的解决的,存在一定的正确性问题

同时 Kotlin官方也在一直持续的优化编译速度

New IC in Gradle: youtrack.jetbrains.com/issue/KT-45…

从 kotlin 1.6.20 开始, 已经基本实现了 模块/依赖间 符号引用分析,

从而在依赖发生变化时,只重新编译必要的类

在 Kotlin 1.7.0 时 开始正式公布:

A new approach to incremental compilation

我们可以从这篇文章来大致了解该功能 kotlin.incremental.useClasspathSnapshot

blog.jetbrains.com/kotlin/2022…

在Kotlin官方的规划中, kotlin.incremental.useClasspathSnapshot后续会继续完善, 最终默认开启. 从实现上不再区分依赖是组合构建依赖还是普通module依赖或者第三方依赖, 直接使用该功能进行类引用分析与处理, 因此前面字节提的 关于Kotlin 支持组合构建的逻辑不一定会被merge

与此相应, Gradle从7.1.0开始也调整了Java代码增量编译逻辑

github.com/gradle/grad…

支持类间分析, 从而在依赖或者R.jar发生变化时只重新编译受影响的类

需要注意到是, Kotlin依赖分析的粒度是 字段/方法

而Gradle Java依赖分析的粒度是

比如, 当新增一个layout时, 不会触法Kotlin类编译

但是引用R.layout的相关Java 类都会重新编译

但是由于java文件编译速度比Kotlin快, 并且随着模块的开发,Java类占比越来越少

这种行为也是可以接受的

实践中可能存在的问题

需要注意的是, 在 Kotlin 1.7.0 - 1.7.10, 在实践中开启 kotlin.incremental.useClasspathSnapshot=true

可能效果仍会非常差,在某些场景下, 甚至比未开启还要慢, 容易退化至全量编译

原因是 Kotlin在分析 companion object 内常量时有bug, 导致symbols 对不上, 进而kotlin内部抛异, 导致退化至全量编译

具体可以参考:

youtrack.jetbrains.com/issue/KT-53…

这里有一些绕过方案, 可以参考链接中的patch

此外我们在使用 kotlin 1.7.10中还遇到了其他错误:

youtrack.jetbrains.com/issue/KT-53…

等等

幸运的是目前发现的这些bug都已在Kotlin 1.7.20中修复

因此如果大家想用上述的功能,

强烈建议大家把Kotlin升级至 1.7.20

Gradle 推荐使用 7.5.1

在Gradle 7.4.2 及之前的版本, 存在一些bug, 可能导致 IDE sync时无法拉取最新的snapshot aar/jar, 导致 代码爆红

github.com/gradle/grad…

实际上在Gradle 7.5.1版本上, snapshot版本与其对应的某些版本进行切换, 也会导致Android Studio在同步时拉不下来最新的snapshot aar

注意kotlin.incremental.useClasspathSnapshot可以动态的配置

如配置在gradle.properties或local.properties,或者

根据各模块动态设置

rootProject.ext.set("kotlin.incremental.useClasspathSnapshot", "true")

此外Kotlin 在增量编译时会备份上一次的缓存结果, 当本次编译失败时会从备份中恢复至上一次编译缓存, 避免本次编译失败(如代码中有语法错误等)后下次再编译导致全量编译

但是该备份逻辑在旧版本上是比较低效的

在机械硬盘上表现更差, 甚至可能导致99%的时间都是在备份

从Kotlin 1.6.20 开始, Kotlin优化了该过程, 减少了备份时间, 可以提升编译速度

参考patch:

github.com/JetBrains/k…

但是从上面的实现可以看到, 每次备份都是全量备份相关的文件到对应zip中

如果追求极限编译速度, 仍可以优化该过程

如进行增量备份,只复制变动的文件等等, 对应模块比较大的项目,优化效果也很明显

总结

目前较新的gradle, kotlin 在编译速度上都有较大的优化

因此如果感到项目中编译速度较慢,并且存在上述瓶颈, 可以考虑排期进行升级

这可能是目前成本最低的较快提升编译速度路径

建议gradle: 7.5.1+, Kotlin: 1.7.20+, AGP版本参考Android官网对应版本

目前Kotlin也在持续的优化编译速度,如 K2 compiler等

kotlinlang.org/docs/whatsn…

目前仍在alpha阶段, 后续跟进一下进度

实际上在整个编译过程中, 不止有代码编译, 还有资源编译, 链接, class 到 dex过程等等

我们可以发现整个APP编译过程中仍有些任务对增量支持不友好,尤其是jar/aar发生变化时, build cache不友好等

对于小公司来说, 通过升级编译工具链是最稳妥的快速提升编译速度的方式

比如对于AGP来说, 我们可能迟早要升级至相关版本, 于此同时也需要升级Gradle, Kotlin版本等

越晚升级, 可能带来的负担越大, 风险越高

另一方面,可以针对性的优化其他任务

比如字节的 dexBuilder 优化 等等,同时期待他们关于编译速度优化的新的分享

备注 - 调试技巧

我们在优化编译速度时可以通过

Kotlin build报告, Android Studio Build Analyzer, gradle-profiler等工具进行分析和优化

同时很多时候要调试 Gradle/Kotlin 相关Task, 或者自己编写的Task

在编译过程中, 涉及到如下进程

Gradle daemon: 运行Gradle Task

Kotlin daemon: 计算需要哪些Kotlin文件需要进行编译,以及编译相关文件, 如compileDebugKotlin task运行在Gradle daemon进程, 它在执行时会把当前哪些文件, classpath, 配置等传递给Kotlin daemon进程, 然后等待Kotlin daemon进程执行完毕

我们可以通过jps -v, jps -l等指令获取目前jvm的启动参数配置

我们在调试相关Task时, 为了调试方便, 往往是直接

设置Kotlin daemon策略为

kotlin.compiler.execution.strategy=in-process

同时执行:

gradlew <task> -Dorg.gradle.debug=true --no-daemon

但是这样的方法对于调试 Kotlin 增量编译过程是行不通的

因为Kotlin 编译支持 DAEMON(默认), IN_PROCESS, OUT_OF_PROCESS 三种方式

但是只有在 daemon 模式下才会触发增量编译

如果设置上面的参数,是无法调试Kotlin 增量编译逻辑的

一个JVM进程是否可以调试, 是由于其启动参数上是否配置jdwp等配置, 从而支持调试

因此我们可以在gradle.properties 或者 Run/Debug Configurations 中配置kotlin daemon jvm启动参数

如在gradle.properties中配置

kotlin.daemon.jvmargs=-XX:+UseParallelGC -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005

当需要调试 IDE sync过程时

在org.gradle.jvmargs中添加

-agentlib:"jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"

进而相应的Gradle daemon进程或Kotlin daemon进程就都可以被调试了

此时打开IDEA导入gradle或kotlin源码, attach 到 对应进程就可以进行调试了

第一次写文章,内容可能不太通顺,并且结论可能随着不同的版本有不同之处,如果大家有问题可以在评论中指出