关于android:Android-编译优化系列kapt篇

11次阅读

共计 8079 个字符,预计需要花费 21 分钟才能阅读完成。

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

一、背景

本文是编译优化系列文章之 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`bs${variant}kotlin` 和 `kapt${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 万条事件量。

👉 点击这里,立刻申请

正文完
 0