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的来源及使用
使用起来非常简单:
你只需要引入 kapt 插件,将原来的
annotationProcessor
换成 kapt
,即可让 kotlin 帮你完成原来 apt 的工作。kapt "groupId:artifactId:version"apply plugin: 'kotlin-kapt'
当你在某个 module 下引入了 'kotlin-kapt',相应的模块构建过程中就会自动生成
kaptGenerateStu
`bs
${variant}kotlin`和`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
到这里就不难理解 kapt 的处理为什么分为了两个步骤:"生成 Stub 文件"及"调用 apt 处理注解"了。
下面说一下这两个步骤的大致流程。
生成Stub文件
这个过程由
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 注册的注解)。
三、kapt引发的内存问题
- 问题描述
火山项目在接入 Hilt 的过程中,在 16G 的 mac 上打包无法通过,频繁报 OOM,对应的堆栈如下:
初看堆栈是由编译器内部报出来的问题,看起来是内存爆掉了,但是从堆栈上看不出明显的突破点。
- 排查及分析过程
- 内存分析
为了能知道这些内存突然上涨的地方在代码里究竟发生了什么,我们得想办法进行代码调试。
- 准备工作
- in-process: 会在当前启动的进程里调用 kotlin compiler 的入口,这时候 gradle 和 kotlin 在同一个进程里。
- out-process: 通过命令行工具单独起一个进程进行编译,主进程会等待独立的进程编译完成。
- daemon: daemon 进程是一个长期运行在后台的守护进程,和 gradle daemon 进程一样,如果 gradle 发现有活着的 daemon 进程,那么就会复用它,否则就会起一个新的 daemon 进程。
./gradlew app:assembleCnFullDebug --stacktrace -Dorg.gradle.debug=true -Dkotlin.compiler.execution.strategy=in-process
- 详细分析
最终确定了是在 enterTrees() 方法中发生了OOM,那只能继续跟进到 jdk 的代码中。
跟着 jdk 的代码走了一遍之后,我们大概知道了在 jdk 中是这样处理 apt 的。
我们开始进行 heap dump,结果如下:
从图中可以发现,Scope$Entry[] 对象创建了1000多万个,显然不正常。
【Android 编译优化系列-kapt篇】但火山项目实在太庞大了,一个 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 newSourceFiles, Map newClassFiles) {this(prev.nextContext(),prev.number+1,prev.compiler.log.nerrors,prev.compiler.log.nwarnings,null);
this.genClassFiles = prev.genClassFiles;
List parsedFiles = compiler.parseFiles(newSourceFiles);
roots = cleanTrees(prev.roots).appendList(parsedFiles);
// Check for errors after parsingif (unrecoverableError())return;
enterClassFiles(genClassFiles);
List newClasses = enterClassFiles(newClassFiles);
genClassFiles.putAll(newClassFiles);
enterTrees(roots);
... }
而 cleanTrees() 的操作如下:
private static List cleanTrees(List nodes) {for (T node : nodes)treeCleaner.scan(node);
return nodes;
}
treeCleaner 的定义如下:
private static final TreeScanner treeCleaner = new TreeScanner() {public void scan(JCTree node) {super.scan(node);
if (node != null)node.type = null;
}public void visitTopLevel(JCCompilationUnit node) {node.packge = null;
super.visitTopLevel(node);
}public void visitClassDef(JCClassDecl node) {node.sym = null;
super.visitClassDef(node);
}public void visitMethodDef(JCMethodDecl node) {node.sym = null;
super.visitMethodDef(node);
}public void visitVarDef(JCVariableDecl node) {node.sym = null;
super.visitVarDef(node);
}public void visitNewClass(JCNewClass node) {node.constructor = null;
super.visitNewClass(node);
}public void visitAssignop(JCAssignOp node) {node.operator = null;
super.visitAssignop(node);
}public void visitUnary(JCUnary node) {node.operator = null;
super.visitUnary(node);
}...
显然,jdk 的设计者想通过遍历 JCTree,将语法树上包括符号表在内的各对象置为空,从而让这些对象有被释放的机会。
但是,这样的操作并没有释放掉符号表的引用,比如这里就保存在 log 的 diagFormatter 对象中。
不过如果仅仅是这样,问题也还不严重,因为从 GC Root 图可发现 log.diagFormatter 每次都只保存前一次的符号表。
第三轮,这个时候总该释放了吧,毕竟此时 log.diagFormatter 也没保存它了,但结果是它竟然还有 GC Root,如下:
显然,是有某个 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:Internalprotected val javaSourceRoots: Setget() = unfilteredJavaSourceRoots.filterTo(HashSet(), ::isRootAllowed).filterTo(HashSet(), {!(it.absolutePath.contains("generated/not_namespaced_r_class_sources/")) } )
收益
目前该 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,及时拥抱
五、加入我们 Build Infra 团队致力于解决 android 研发体验问题,提升 android 编译体验,负责保障和提升公司内各业务线的研发构建效率。如果你对技术充满热情,追求极致,欢迎加入我们,我们期待你与我们共同成长 。目前我们在北京、上海、杭州均有招聘需求,简历投递邮箱:
lanjunjian@bytedance.com , 邮件标题是:姓名-Devops-Build Infra.
火山引擎 APMPlus 应用性能监控是火山引擎应用开发套件 MARS 下的性能监控产品。我们通过先进的数据采集与监控技术,为企业提供全链路的应用性能监控服务,助力企业提升异常问题排查与解决的效率。目前我们面向中小企业特别推出「APMPlus 应用性能监控企业助力行动」,为中小企业提供应用性能监控免费资源包。现在申请,有机会获得60天免费性能监控服务,最高可享6000万条事件量。
点击这里,立即申请
推荐阅读
- Android编译优化系列-kapt篇
- Linux性能优化实战CPU篇之总结(四)
- 安卓源码探究|android源码学习-目录
- 程序员|Android面试题精选——再聊Android-Handler机制-2,android实战教程
- 膜拜大佬!三年工作经验,顺利通过阿里Android岗面试
- Python3|Python3 cpython优化 实现解释器并行
- 笔记|Android开发实战——计算器
- Android小项目——仿iPhone计算器
- Android|Android——一个神奇的计算器APP
- Pytest单元测试框架生成HTML测试报告及优化的步骤