Android编译优化系列-kapt篇
文章图片
【Android编译优化系列-kapt篇】作者:字节跳动终端技术———王龙海 封光 兰军健
一、背景
本文是编译优化系列文章之 kapt 优化篇,后续还会有 build cache, kotlin, dex 优化等文章,敬请期待。本文由Client Infra->Build Infra团队出品,powered by 王龙海,封光,兰军健相信 android 开发对于 kapt 并不陌生,之前也有很多文章在编译优化过程中谈及过 Kapt,主要是针对增量编译场景。 抖音火山版同学在接入 hilt 过程中,遇到了更严重的问题: 在 16G 内存的电脑上触发 OOM。例如火山项目在执行 kapt 的过程中,不论采用 aar 依赖,还是全源码编译,均无法编译通过,可以认为 Kapt 会对内存产生比较大的影响。 在分析这个问题之前,先介绍下 kapt 的原理。 二、 Kapt 原理
- kapt的来源及使用
annotationProcessor
换成 kapt
,即可让 kotlin 帮你完成原来 apt 的工作。 kapt "groupId:artifactId:version" apply plugin: 'kotlin-kapt'
当你在某个 module 下引入了 'kotlin-kapt',相应的模块构建过程中就会自动生成kaptGenerateStu
b
s
${variant}k
otlin
和kapt
${variant}
Kotlin
两个Task。所以要最小化引入原则,按需引入,避免带来较大的编译耗时影响。 - 原理分析
文章图片
这里可以看到,整个 kapt 的处理过程分为了两个步骤:"生成 Stub 文件"及"调用 apt 处理注解"。可以非常清晰的看到,其实kapt并没有新的东西,底层依然是调用的 java apt 来完成的整个任务。这里多说一句, kotlin 团队为什么这么设计呢? Java 的 apt 是通过实现 JSR269 来实现的。JSR269 为 apt 插件定义了 api,Java apt 实现了这套 api。 那么作为后起之秀,想要实现类似的功能可以很容易想到如下两种方式:
- 重写一套 JSR269 api。同时实现对于 kotlin 文件和 java 文件的 apt
- 想办法复用 java 的 apt
这个过程由
kaptGenerateStubs${variant}kotlin
承担。 如上图所示,A.kt 和 B.kt 经过处理后生成了 A.java 和 B.java。我们来看一下产物和我们想象的是否有不同之处。
左边是一个 .kt 文件,右边是 kaptGenetateStub 生成的 .java 文件,聪明的你应该知道 kotlin 想干嘛了吧? 文章图片
可以看到,这里并不是将 kotlin 源码生成与之等效的 java 源码,只是生成了类似 abi 形式的 java 源码,只要保证能找到对应的方法和字段的描述符即可,无需处理方法体的实现内容。 调用apt处理注解
这个过程的大致流程:
- KaptTask 找到 kapt 注册的 kapt 插件,找到所有的 processors。
- KaptTask 会调用 jdk 的方法,对源文件进行解析并生成对应的 AST(抽象语法树)。
- KaptTask 调用 jdk 进行 annotation processing,jdk 内部会 回调 #1 中找到的 processors。
- 业务方的 processors 里面会完成写入新 java 文件的逻辑,这时候,jdk 会带上新的 java 文件去进行第二轮、第三轮 process。(因为新 java 文件里面也可能引用了 processor 注册的注解)。
- 问题描述
文章图片
初看堆栈是由编译器内部报出来的问题,看起来是内存爆掉了,但是从堆栈上看不出明显的突破点。
- 排查及分析过程
- 内存分析
文章图片
为了能知道这些内存突然上涨的地方在代码里究竟发生了什么,我们得想办法进行代码调试。
- 准备工作
- in-process: 会在当前启动的进程里调用 kotlin compiler 的入口,这时候 gradle 和 kotlin 在同一个进程里。
- out-process: 通过命令行工具单独起一个进程进行编译,主进程会等待独立的进程编译完成。
- daemon: daemon 进程是一个长期运行在后台的守护进程,和 gradle daemon 进程一样,如果 gradle 发现有活着的 daemon 进程,那么就会复用它,否则就会起一个新的 daemon 进程。
- 详细分析
文章图片
最终确定了是在 enterTrees() 方法中发生了OOM,那只能继续跟进到 jdk 的代码中。 跟着 jdk 的代码走了一遍之后,我们大概知道了在 jdk 中是这样处理 apt 的。
文章图片
我们开始进行 heap dump,结果如下:
文章图片
从图中可以发现,Scope$Entry[] 对象创建了1000多万个,显然不正常。 但火山项目实在太庞大了,一个 heap dump 就达 10 几 G,如果直接选择某个 Scope$Entry[] 对象进行GC Root 分析的话,等一天也完不成。 所以采用一个接入了 hilt 的 demo 进行测试。 从第一轮开始,选择一个 Scope$Entry[] 对象,此时它的 GC Root 如下:
文章图片
此时它的 GC Root 是 Java Frame,应该是正在执行某个方法,并且要用到它,有 GC Root 是正常的。 第二轮,此时 GC Root 如下:
文章图片
还没有释放,这其实已经有点不符合预期了。 注意到 JavacProcessingEnvironment 中有这样一段代码: /** Create a new round. */ private Round(Round prev, Set
文章图片
显然,是有某个 JNI Global Reference 持有了它,导致它无法被释放。 到这里可以确定,由于 jdk8 的设计,导致每一轮处理注解而创建的符号表,都会一直保留在内存中,一直到全部处理完才释放,从而导致对于代码量大或者 processors 数量多(比如 hilt 引入了13个 processor )的项目,就很容易因为占用内存过大而导致 OOM。这个锅 jdk 得背着。
文章图片
实际上,kapt 也对于这种情况有所防范,所以会在结束 annotation processing 之后,进行内存泄漏检测:
文章图片
想判断 kapt 过程是否有内存泄漏,可配置打开 log 开关查看。 如果在 annotation processing 过程就发生了 OOM,那么它只能抛出异常,根本都不会走到内存泄漏探测这一步。可见这个内存泄漏检测,对于本文的排查工作起不到什么大的作用。 解决方案
虽然定位到了问题在 jdk 里面,但官方一时间也不可能给解决,更何况这还是一个比较老的 jdk 版本。那只能想想别的办法了。 由于 jdk 中进行 annotation processing,会先将输入的 java 文件进行语法分析,构建符号表,从而新建非常多类似 Scope$Entry[] 这样的对象。 在 debug 中发现,一个源文件对应一个JCCompilationUnit,而一个 JCCompilationUnit 中就包含一棵语法树。 从这里可以推断出,annotation processing 的内存占用与输入的源文件成正比关系。 那么是否可以通过过滤输入的源文件减少内存占用呢? 我们分析了一遍输入的文件,发现在 app module 中有大量的 R.java 参与了 kapt 编译,对于中大型项目而言,至少会存在几千到上万个,关于 R.java 在 app 编译中的作用,在这里就不赘述了。 其实对于 app module 来说,R.java 只是辅助编译的作用。一般来说,app module 都比较轻量,很少会放很多代码,但是由于 R.java 要参与辅助编译,所以 R.java 被 agp 塞到了
javaSourceRoots
。但是由于有非常多的 module,并且每个 module 都存了它底层 module 的 R 值,所以会导致 app module 的 R.java 非常多,非常庞大。似乎 google 官方也意识到了这一点,在 AGP 3.6.0 中,google 把 R.java 换成了 R.jar 来辅助编译。
在火山的项目中,有 95% 的输入文件都是 R.java,并且每个 R.java 都有大几千行的代码。因为 R.java 里面都是一些没有注解的 field。 文章图片
可以说,R.java 文件是与 kapt 无关的,完全没必要参与语法分析,增加额外的执行时间和内存。 所以,将 KaptTask 中 javaSourceRoots 的代码改为如下,过滤掉生成的 R.java。 @get:Internal protected val javaSourceRoots: Set
目前该 feature 一魔改版的 kotlin 已经接入火山,今日头条等项目。 对于火山来说,app:kapt task 从 18min 发生 OOM,变为 15s 编译通过,不仅减少了很多编译时间,而且节约了 13G+ 的内存空间。 而对于其他之前未发生 OOM 的 kapt task, 其实也一样有收益,如下图是在头条进行测试前后的对比图:
文章图片
其中左边是接入后的执行时间,可见,kapt task从 30.810s 减少到 1.431s,速度提升了 20 倍。 另外多说一句:在 debug jdk 的过程中,发现 jdk 8 无论从模块解耦,还是内存管理都做得并不好,不过也能理解,毕竟这主要是 2013 年完成的代码。所以,从编译优化的角度看,尽快升级项目中使用的 jdk 版本也是一件收益较大的事情(事实上使用 jdk9 就能编过,虽然还是慢)。 需要注意的是,以上优化适应于AGP 3.6.0之前,在AGP 3.6.0之后,由于参与编译的是R.jar而不是R.java, 不存在此问题,本文重点阐述的是kapt的原理,遇到相关问题的排查过程以及进行优化的思路。最后,针对 Kapt 相关优化给出几点建议。 四、Kapt的建议与优化 要想 kapt 的使用不引入大的编译相关负向收益,我们有以下几点建议:
- 收敛 kapt 作用域
- 尽量不要在类似于 library.gradle 的文件中为所有 module 添加统一的 kapt 依赖,改成具体模块按需使用。
- 或者有区分度的创建 library.gradle, library-api.gradle ,按照模块类型选择适当的模板文件,如api 类型的模块就不需要 apply kotlin-kapt 的 plugin,也不需要依赖 kapt 库
- 接入优化工具
- 尽量寻找 kapt 的替代方案
- 可以使用 google 官方提供的 transform api ,在 java 代码编译成字节码后直接修改创建字节码,而且公司内已经有 byteX , any-register 等 transform 框架,可以很方便的基于这些框架写字节码插桩逻辑,同时利用这些框架的 io 复用能力,也能进一步的提升编译速度。
- 可以在 debug 打包时用反射方案,在 release 打包时继续用 kapt ,这样可以兼顾开发体验和运行效率。
- 期待KSP,及时拥抱
点击这里,立即申请
推荐阅读
- Linux性能优化实战CPU篇之总结(四)
- 安卓源码探究|android源码学习-目录
- 程序员|Android面试题精选——再聊Android-Handler机制-2,android实战教程
- 膜拜大佬!三年工作经验,顺利通过阿里Android岗面试
- Python3|Python3 cpython优化 实现解释器并行
- 笔记|Android开发实战——计算器
- Android小项目——仿iPhone计算器
- Android|Android——一个神奇的计算器APP
- Pytest单元测试框架生成HTML测试报告及优化的步骤
- Android中高级面试必知必会(真题+答案解析)