Android编译优化系列-kapt篇

·  阅读 4123
Android编译优化系列-kapt篇

作者:字节跳动终端技术———王龙海 封光 兰军健

一、背景

本文是编译优化系列文章之 kapt 优化篇,后续还会有 build cache, kotlin, dex 优化等文章,敬请期待。本文由Client Infra->Build Infra团队出品,powered by 王龙海,封光,兰军健

相信 android 开发对于 kapt 并不陌生,之前也有很多文章在编译优化过程中谈及过 Kapt,主要是针对增量编译场景。

抖音火山版同学在接入 hilt 过程中,遇到了更严重的问题: 在 16G 内存的电脑上触发 OOM。例如火山项目在执行 kapt 的过程中,不论采用 aar 依赖,还是全源码编译,均无法编译通过,可以认为 Kapt 会对内存产生比较大的影响。

在分析这个问题之前,先介绍下 kapt 的原理。

二、 Kapt 原理

  1. kapt的来源及使用

kapt 可以理解为就是在 kotlin 开发场景下进行注解处理的工具。至于作用可以完全等效于 java 的 apt。因为 java 的 apt 处理不了 kotlin 源码文件,所以才出现了kapt,来实现混合工程或者纯 kotlin 工程的 apt 任务。

使用起来非常简单:

你只需要引入 kapt 插件,将原来的 annotationProcessor 换成 kapt,即可让 kotlin 帮你完成原来 apt 的工作。

kapt "groupId:artifactId:version"

apply plugin: 'kotlin-kapt'

当你在某个 module 下引入了 'kotlin-kapt',相应的模块构建过程中就会自动生成kaptGenerateStu``b``s``${variant}k``otlinkapt``${variant}``Kotlin两个Task。所以要最小化引入原则,按需引入,避免带来较大的编译耗时影响。

  1. 原理分析

上文说到引入了 kapt 的模块会相应的增加两个 Task,这两个 Task 会完成处理注解生成类的功能。接下来我们简单的看一下这两个 Task 的工作原理。

这里可以看到,整个 kapt 的处理过程分为了两个步骤:"生成 Stub 文件"及"调用 apt 处理注解"。可以非常清晰的看到,其实kapt并没有新的东西,底层依然是调用的 java apt 来完成的整个任务。这里多说一句,

kotlin 团队为什么这么设计呢?

Java 的 apt 是通过实现 JSR269 来实现的。JSR269 为 apt 插件定义了 api,Java apt 实现了这套 api。

那么作为后起之秀,想要实现类似的功能可以很容易想到如下两种方式:

  • 重写一套 JSR269 api。同时实现对于 kotlin 文件和 java 文件的 apt
  • 想办法复用 java 的 apt

显然,第二种路径更简单且更成熟,再加上在 kotlin 考虑这件事之前,业界已有先例,比如 groovy 对于 apt 的支持也是这么干的。这就不难理解 kotlin 的设计思路了,只要想办法把 kotlin 的源码转成 java 源码即可。

到这里就不难理解 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 的原理就介绍到这里。接下来我们来分析一下 kapt 可能带来的问题。这里会花一部分篇幅来讲述下背景中提到的问题的解决过程。

三、kapt引发的内存问题

  1. 问题描述

这里再简单的描述下本文背景中提到的问题。

火山项目在接入 Hilt 的过程中,在 16G 的 mac 上打包无法通过,频繁报 OOM,对应的堆栈如下:

初看堆栈是由编译器内部报出来的问题,看起来是内存爆掉了,但是从堆栈上看不出明显的突破点。

  1. 排查及分析过程

既然是内存问题,我们先想办法复现下,推荐用 VisuaxlVM 进行分析,不了解该工具的同学可以点击链接学习下,算是比较好用的JVM问题排查工具了。

  • 内存分析

我们用 VisualVM 对 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 的完整执行流程,如下图所示:

最终确定了是在 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<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);

    }

...

显然,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: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 库
  1. 接入优化工具

本文只阐述了kapt关于内存问题的一个相关优化,其实 kapt 及 kotlin 编译还有很多的问题值得去优化。目前在字节内部,我们团队开发了一系列优化工具来无感知地解决此类问题来加快增量编译速度。受限于篇幅原因,这里不进行展开说明,后续会有单独的文章来阐述相关内容。

  1. 尽量寻找 kapt 的替代方案

在项目中使用 kapt 无非是需要一个通用的代码生成逻辑,减少重复代码的编写,能实现类似效果的方案不仅仅只有 kapt :

  • 可以使用 google 官方提供的 transform api ,在 java 代码编译成字节码后直接修改创建字节码,而且公司内已经有 byteX , any-register 等 transform 框架,可以很方便的基于这些框架写字节码插桩逻辑,同时利用这些框架的 io 复用能力,也能进一步的提升编译速度。
  • 可以在 debug 打包时用反射方案,在 release 打包时继续用 kapt ,这样可以兼顾开发体验和运行效率。
  1. 期待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 应用性能监控是火山引擎应用开发套件 MARS 下的性能监控产品。我们通过先进的数据采集与监控技术,为企业提供全链路的应用性能监控服务,助力企业提升异常问题排查与解决的效率。目前我们面向中小企业特别推出「APMPlus 应用性能监控企业助力行动」,为中小企业提供应用性能监控免费资源包。现在申请,有机会获得60天免费性能监控服务,最高可享6000万条事件量。

👉 点击这里,立即申请

分类:
Android
分类:
Android
收藏成功!
已添加到「」, 点击更改