Android编译优化系列-kapt篇
技术干货哪里找?
👆 点击上方蓝字关注我们!
一、背景
二、 Kapt 原理
1. kapt的来源及使用
kapt 可以理解为就是在 kotlin 开发场景下进行注解处理的工具。至于作用可以完全等效于 java 的 apt。因为 java 的 apt 处理不了 kotlin 源码文件,所以才出现了kapt,来实现混合工程或者纯 kotlin 工程的 apt 任务。
使用起来非常简单:
annotationProcessor
换成kapt
,即可让 kotlin 帮你完成原来 apt 的工作。kapt "groupId:artifactId:version"
apply plugin: 'kotlin-kapt'
kaptGenerateStubs${variant}kotlin
和kapt${variant}Kotlin
两个Task。所以要最小化引入原则,按需引入,避免带来较大的编译耗时影响。2. 原理分析
这里可以看到,整个 kapt 的处理过程分为了两个步骤:"生成 Stub 文件"及"调用 apt 处理注解"。可以非常清晰的看到,其实kapt并没有新的东西,底层依然是调用的 java apt 来完成的整个任务。这里多说一句,
kotlin 团队为什么这么设计呢?
Java 的 apt 是通过实现 JSR269[2] 来实现的。JSR269 为 apt 插件定义了 api,Java apt 实现了这套 api。
那么作为后起之秀,想要实现类似的功能可以很容易想到如下两种方式:
重写一套 JSR269 api。同时实现对于 kotlin 文件和 java 文件的 apt
想办法复用 java 的 apt
显然,第二种路径更简单且更成熟,再加上在 kotlin 考虑这件事之前,业界已有先例,比如 groovy[3] 对于 apt 的支持也是这么干的。这就不难理解 kotlin 的设计思路了,只要想办法把 kotlin 的源码转成 java 源码即可。
到这里就不难理解 kapt 的处理为什么分为了两个步骤:"生成 Stub 文件"及"调用 apt 处理注解"了。
下面说一下这两个步骤的大致流程。
生成Stub文件
kaptGenerateStubs${variant}kotlin
承担。如上图所示,A.kt 和 B.kt 经过处理后生成了 A.java 和 B.java。我们来看一下产物和我们想象的是否有不同之处。可以看到,这里并不是将 kotlin 源码生成与之等效的 java 源码,只是生成了类似 abi 形式的 java 源码,只要保证能找到对应的方法和字段的描述符即可,无需处理方法体的实现内容。
调用apt处理注解
这个过程的大致流程:
KaptTask 找到 kapt 注册的 kapt 插件,找到所有的 processors。
KaptTask 会调用 jdk 的方法,对源文件进行解析并生成对应的 AST[4](抽象语法树)。
KaptTask 调用 jdk 进行 annotation processing,jdk 内部会 回调 #1 中找到的 processors。
业务方的 processors 里面会完成写入新 java 文件的逻辑,这时候,jdk 会带上新的 java 文件去进行第二轮、第三轮 process。(因为新 java 文件里面也可能引用了 processor 注册的注解)。
三、kapt引发的内存问题
1. 问题描述
这里再简单的描述下本文背景中提到的问题。
火山项目在接入 Hilt 的过程中,在 16G 的 mac 上打包无法通过,频繁报 OOM,对应的堆栈如下:
初看堆栈是由编译器内部报出来的问题,看起来是内存爆掉了,但是从堆栈上看不出明显的突破点。
2. 排查及分析过程
既然是内存问题,我们先想办法复现下,推荐用 VisuaxlVM[5] 进行分析,不了解该工具的同学可以点击链接学习下,算是比较好用的JVM问题排查工具了。
内存分析
我们用 VisualVM[5] 对 Gradle daemon 进程进行了内存分析。发现在 kapt 过程中,内存确实一直在往上涨。
为了能知道这些内存突然上涨的地方在代码里究竟发生了什么,我们得想办法进行代码调试。
准备工作
kotlin 的 debug 比 gradle 稍微麻烦一些,kotlin compiler 在运行的时候,有三种模式。
in-process: 会在当前启动的进程里调用 kotlin compiler 的入口,这时候 gradle 和 kotlin 在同一个进程里。
out-process: 通过命令行工具单独起一个进程进行编译,主进程会等待独立的进程编译完成。
daemon: daemon 进程是一个长期运行在后台的守护进程,和 gradle daemon 进程一样,如果 gradle 发现有活着的 daemon 进程,那么就会复用它,否则就会起一个新的 daemon 进程。
默认情况下,kotlin compiler 的代码是运行在 kotlin 的 daemon 进程中的,这里我们为了方便,可以直接指定为 in-process 模式。这样一来,相当于在 gradle 的 daemon 进程中进行调试,岂不是方便很多,进行如下设置即可。
./gradlew app:assembleCnFullDebug --stacktrace -Dorg.gradle.debug=true -Dkotlin.compiler.execution.strategy=in-process
详细分析
能够断点调试后,通过 debug kotlin,很容易就梳理出 kapt 的完整执行流程,如下图所示:
从图中可以发现,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<JavaFileObject> newSourceFiles, Map<String,JavaFileObject> newClassFiles) {
this(prev.nextContext(),
prev.number+1,
prev.compiler.log.nerrors,
prev.compiler.log.nwarnings,
null);
this.genClassFiles = prev.genClassFiles;
List<JCCompilationUnit> parsedFiles = compiler.parseFiles(newSourceFiles);
roots = cleanTrees(prev.roots).appendList(parsedFiles);
// Check for errors after parsing
if (unrecoverableError())
return;
enterClassFiles(genClassFiles);
List<ClassSymbol> newClasses = enterClassFiles(newClassFiles);
genClassFiles.putAll(newClassFiles);
enterTrees(roots);
...
}
而 cleanTrees() 的操作如下:
private static <T extends JCTree> List<T> cleanTrees(List<T> 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);
}
...
显然,是有某个 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[] 这样的对象。
从这里可以推断出,annotation processing 的内存占用与输入的源文件成正比关系。
那么是否可以通过过滤输入的源文件减少内存占用呢?
我们分析了一遍输入的文件,发现在 app module 中有大量的 R.java 参与了 kapt 编译,对于中大型项目而言,至少会存在几千到上万个,关于 R.java 在 app 编译中的作用,在这里就不赘述了。
在火山的项目中,有 95% 的输入文件都是 R.java,并且每个 R.java 都有大几千行的代码。因为 R.java 里面都是一些没有注解的 field。
可以说,R.java 文件是与 kapt 无关的,完全没必要参与语法分析,增加额外的执行时间和内存。
所以,将 KaptTask 中 javaSourceRoots 的代码改为如下,过滤掉生成的 R.java。
@get:Internal
protected val javaSourceRoots: Set<File>
get() = 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 的使用不引入大的编译相关负向收益,我们有以下几点建议:
1. 收敛 kapt 作用域
之前遇到很多项目组,为了方便会创建一个 library.gradle/base.gradle 这样的文件,这个文件中定义了很多通用的 kapt 依赖,随着项目模块化组件化的改造,项目中模块数量越来越多,一些只包含 model 类和接口、完全不需要 kapt 的 api 模块也被统一的使用到了这些 kapt 依赖,使得项目中有大量模块进行了无意义的 kapt 耗时, 因此我们建议:
尽量不要在类似于 library.gradle 的文件中为所有 module 添加统一的 kapt 依赖,改成具体模块按需使用。
或者有区分度的创建 library.gradle, library-api.gradle ,按照模块类型选择适当的模板文件,如api 类型的模块就不需要 apply kotlin-kapt 的 plugin,也不需要依赖 kapt 库
2. 接入优化工具
本文只阐述了kapt关于内存问题的一个相关优化,其实 kapt 及 kotlin 编译还有很多的问题值得去优化。目前在字节内部,我们团队开发了一系列优化工具来无感知地解决此类问题来加快增量编译速度。受限于篇幅原因,这里不进行展开说明,后续会有单独的文章来阐述相关内容。
3. 尽量寻找 kapt 的替代方案
在项目中使用 kapt 无非是需要一个通用的代码生成逻辑,减少重复代码的编写,能实现类似效果的方案不仅仅只有 kapt :
可以使用 google 官方提供的 transform api ,在 java 代码编译成字节码后直接修改创建字节码,而且公司内已经有 byteX , any-register 等 transform 框架,可以很方便的基于这些框架写字节码插桩逻辑,同时利用这些框架的 io 复用能力,也能进一步的提升编译速度。
可以在 debug 打包时用反射方案,在 release 打包时继续用 kapt ,这样可以兼顾开发体验和运行效率。
4. 期待KSP,及时拥抱
kapt 需要先经过 kaptGenerateStub 将 kotlin 代码转换为 java 代码,然后再交给 jdk 处理,这样显然太麻烦了。那么,是否可以直接在 kotlin compiler 中就进行 annotation processing 呢?答案是肯定的,实际上 kotlin 官方在更高的版本上已经有了这样的方案,叫 Kotlin Symbol Processing (KSP),不过目前还处于 alpha 阶段,还需要等待各大 processor 进行适配。等稳定之后我们会推出关于 KSP 的最佳实践,帮助大家更好地进行 annotation processing 的开发。
五、加入我们
Build Infra 团队致力于解决 android 研发体验问题,提升 android 编译体验,负责保障和提升公司内各业务线的研发构建效率。如果你对技术充满热情,追求极致,欢迎加入我们,我们期待你与我们共同成长 。目前我们在北京、上海、杭州均有招聘需求,简历投递邮箱:lanjunjian@bytedance.com,邮件标题命名:姓名-Devops-Build Infra.
参考文献
目前我们面向中小企业特别推出「APMPlus 应用性能监控企业助力行动」,为中小企业提供应用性能监控免费资源包。现在申请,有机会获得60天免费性能监控服务,最高可享6000万条事件量。