Kotlin & R.jar 编译优化

date
Nov 19, 2020
slug
kotlin-incremental-build-R
status
Published
tags
kotlin
summary
Kotlin & R.jar 编译优化
type
Post

背景

在 kotlin 编译中,CompileKotlinTask 会拿到所有变动的输入,并且对变动的输入进行对应的编译。在一定程度上,kotlin 编译的 compile avoidance 已经做得相当不错了,但是还是会存在一些 badcase。
今天我们的主角是 bad case 之一:R.jar 的变动导致 kotlin 任务全量编译
在读本篇文章之前,可以先了解一下

Why

为什么 R.jar 的变动会让 kotlin fallback 到全量编译呢?
我们可以看一下 changesDetectionUtils 的代码。
notion image
Kotlin 会去 gradle 输入的 InputChanges 里面找到变动 & 删除的文件,并且去 classpathSet 中去查找里面是否有 inputchanges 相关的变动。
而 classpathSet 中,除了 android.jar、kotlin 等这些不怎么会变化的 jar 外,另外两个可能会经常变动的 jar 分别是
  • classes.jar
  • R.jar
也就是说,如果这俩个 jar 被删了的话,会直接全量编译,当然这种情况非常少。
如果发生变动的话,kotlin 会去调用 historyFilesFotChangedFiles(modifiedClasspath),来找到变动的 classpath 的「DirtySymbol」,对于 classed.jar kotlin 有自己的处理方式(详见 kotlin 增量编译策略 ),然而对于 R.jar,kotlin 就无能为力了。
也就是说 R.jar 只要发生了变动,一定会触发 kotlin 的全量编译。
但是资源的增删、改名都会引起 R.jar 的变动呀,那也就是说只要资源的变动就会触发 kotlin 的全量编译啦❓❓❓
确实是。
如果看过 关于 R 的一切 ,我们可以知道,在 AGP 的较高版本中,为了纯 kotlin 工程不启动 javaCompile,agp 把原来的 R.java 都改成了 R.jar,当然他们的内容是一样的。
假设有一个工程 app -> lib1 -> lib2,app 依赖 lib1,lib1 依赖 lib2,那么如果 lib2 的资源发生变动,会导致 lib2 编译出的 R.jar 发生变动,而 lib1 为了引用 lib2 的资源,也会把 lib2 的 R.jar 作为 classpath 作为编译依赖。相应的,输入 app 的 R.jar 也会发生变化。也就是说,最底层的 R.jar 变化会引起整个工程所有依赖它的 module 全量编译。

优化思路

既然资源的变动会引起 R.jar 的变动,R.jar 的变动会引起 kotlin 的全量编译,那么我们只要切掉其中的联系,让 kotlin 不觉得 R.jar 发生变动了不就行了吗?
资源的变动我们可以分为四种
  • 更改资源的值
  • 更改资源的名称
  • 增加一个(多个)资源
  • 删除一个(多个)资源
其中
  1. 更改资源的值,不会引起 R.jar 的变化,变化的是 resource.arsc
  1. 更改资源的名称,等同于 3 & 4,也就是删除一个资源、再新增一个资源
  1. 增加一个资源,会导致 R.jar 的变化,并且会引起这个资源后面的 id 会 +1
  1. 删除一个资源,会导致 R.jar 的变化,并且会引起这个资源后面的 id 会 -1
所以我们只要处理资源的增 & 删就可以了。
那么,首先增 & 删的变化不可避免的会引起 R.jar 的变化,那么我们只能剪断 R.jar 引起 kotlinc 全量编译的这条绳子✂️。
也就是说,即使 R.jar 发生了变化,我们在编译的时候,hook 住 KotlinCompile 的任务,如果有 R.jar 的变动,从变动的文件里面删掉 R.jar不就可以了么?这样 KotlinCompile 任务就不知道 R.jar 发生了变动,进而也不会引起全量编译。
🆗 这条绳子剪掉了,那我们不告诉 KotlinCompile 任务 R.jar 发生了变化,会不会引起「不符合预期的错误」呢?毕竟我们在优化编译的时候,正确性是一定要保证的,不能等再到运行期的时候才能发现错误,那样业务方也会很崩溃。
刚刚讲到,增 & 删都会引起 R.jar 的变动,我们分别来分析下。
首先我们明确一点:library module 的 R.jar 是辅助编译用的,并不会被打到 App 里面去,因为最后的 R 文件都是由 aapt 生成的
我们定义一个名词:「持有引用」,指的是有代码 or xml 文件对某个资源持有引用。
  • 增加资源
    • 只是增加了资源:这种情况可以直接处理掉,因为只增加了资源,并不需要其它文件参与编译。
    • 增加资源 & 持有引用:这种情况也可以直接处理掉,因为既然改动了代码并且对它有引用,这时候 gradle 也会告诉 kotlinCompile 需要编译这个文件,所以不会有任何问题。
  • 删除资源
    • 删除了没有对其「持有引用」的资源:这种情况可以直接处理,理由同上。
    • 删除了有对其「持有引用」的资源:这种情况有点特殊,一般来说,业务方在删除一个资源的时候,很多情况是直接编译,寄托于编译器帮他找出对其有引用的文件,编译挂了再去改。但是 gradle 这时候是不会告诉我们有哪些文件对它「持有引用」,如果不 hack 的话,R.jar 的变动会引起全量编译的,这时候一定能找到对它持有引用的代码,但是如果 hack,不告诉编译器有 R.jar 的变动,编译器只会编译变动的文件,当然就找不出来对它持有引用的文件,这样肯定会在运行时报错,提示找不到这个资源。不符合预期。
那么现在我们需要一个这样的方法,输入被删除的 field,就能告诉我们有哪些 .kt 对它持有引用,我们把这些文件标记为「out-of-date」然后传给 KotlinCompileTask,这些文件就会被编译一遍,这样就可以在编译器找到「脏文件」了。那有没有这样的方法呢?还真有。
参考代码:
notion image
这边借用了 Kotlin Compiler 的代码,可以根据某个 field、function 定位到所有对它持有引用的 kt 文件。大概原理是,在每一次编译的时候,Kotlin 编译前端会在 caches-jvm/lookups 文件夹下面生成 kt 文件之间的引用关系,这个引用关系可以用来做 compile avoidance。这个后期我会开一个单独的文章讲一讲。(挖坑
所以我们的问题就解决了,无论是增、删资源都可以准确无误的找到需要编译的文件,并且可以在 out-of-date 中删掉 R.jar 的变动,防止 kotlin 的全量编译。

实现

首先我们来理一下我们要做哪些事情。
  1. 对比本次编译的 R.jar 和上次编译的 R.jar 有哪些资源发生了增、删。
  1. 通过分析增、删,分析需要编译的文件。
  1. 在 KotlinCompileTask 编译前获取到它的 InputChanges,把 R.jar 删掉,并且添加额外需要编译的 .kt 文件。
notion image

R.jar 变动检测

在检测 R.jar 的变动之前我们可以先看一下 R.jar 里面的内容。
notion image
反编译后:
notion image
每个有资源的都会在对应的包名下生成对应的 R.class、R$xxxx.class,其中 R$xxx.class 是 R 的内部类,R$xxxx.class 里面都是一些 static int 或者是 static int[]。
我们的目标是:找出本次编译对比上次编译增、删的资源。
首先我们要记录下上次编译的产物,
所以,后面的 id 我们可以不管,就算它变了,也不会影响编译,也不会影响正确性。
我们只需要找到所有的变量的 name,比如上面的 abc_fade_in,然后把它写到文件里去。
分析 R.jar 我们可以用 asm
 
文件格式可以自己随便定义,这里就不展开讲了。
下次编译的时候,我们再将 R.jar 拿出来和上一次的写入的文件去对比下。找到所有增、删的 field。
 

KotlinCompile InputFiles Hook

我们没办法直接通过反射来去改掉 input(因性能问题,更新第二版),但是我们可以在 kotlin compile task 之前插入一个代理的 task,然后将 kotlin 的 inputs 都拿过来,这样 gradle 就可以告诉我们 kotlin 的文件变动。在拿到变动的文件之后,我们可以进行自己的操作,生成最终要传给 kotlin 的文件变动。最后禁用掉 kotlin task,直接调用 kotlin 任务的 execute(IncrementalTaskInputs inputchanges) 方法。
先拿到所有的 kotlin 的 input
@get:Incremental
@get:InputFiles
@get:CompileClasspath
val jarFilesInput = project.provider {
    realKotlinCompileTask.inputs.files.files.filter {
        it.extension == "jar" && it.name != "R.jar"
    }
}
@get:InputFiles
val rJarInput = project.provider {
    realKotlinCompileTask.inputs.files.files.filter {
        it.name == "R.jar"
    }
}
@get:Incremental
@get:InputFiles
val otherInputs = project.provider {
    realKotlinCompileTask.inputs.files.files.filter {
        it.name != "R.jar"
    }
}
@Input
val inputProperties = project.provider { 
    realKotlinCompileTask.inputs.properties 
}
这里的 input 分为四种
  • 不是 R.jar 的 jar 包
  • R.jar
  • 其它 files input (比如 xml、java、kt)
  • Properties
这些 input 都用 gradle 提供的 provider (lazy_configuration) 包起来,防止在 configure 的阶段提前加载。
首先「不是 R.jar 的 jar 包」额外用 @CompileClasspath 来修饰。它可以将没有 abi 变动的 jar 包设为 up-to-date,原生的 kotlin 没有这个注解,可以在一定情况下节省编译时间,聊胜于无。✌️
R.jar 我们单独拎出来方便等会单独处理
其它的文件我们就照搬过来 kotlin 的,这四个 input 基本就可以覆盖全原始 kotlin task 的所有 inputs 了。保证了编译的正确性。
下面就可以进行 kotlin task input 的 hook 了。在 execute 的时候调用 process 对 inputs 进行处理,然后传给真正的 kotlin。
这里注意要把真实的 kotlin 任务禁用掉,因为如果用 fallback 的方式,有一些情况下交给 kotlin 自己去处理的话,会导致有时候用代理 task 编译,有时候用真实的 task 编译,导致不必要的全量编译。因为如果交替执行的话, 因为他们的输入输出是共用的同一个文件夹,会互相影响对方的增量判断。fallback 到全量,浪费时间。所以我们一定要处理好这个代理的任务的输入输出。

KotlinCompile InputFiles Hook V2

上面提到插入一个任务来进行 InputFiles 的 hook,但是在实际操作的时候,发现会带来很大的负向优化。插入新的任务,gradle 需要计算 InputFiles 的所有 Jar 的 hash 值,本来只需要计算一次,当插入了一个和 Kotlin 原生任务同样的任务之后,就会计算两次, 白白浪费一次计算的时间。在增量编译的时候这个时间可能还好,但是在全量编译的时候,由于编译资源有限,这个劣化的时间可能在 10-60s。这是不可取的。
既然插入一个新的任务一定会带来劣化,我们能不能通过 hook 的方案来实现对 InputFiles 的修改呢?
可以:
大概原理就是,在 gradle plugin 还没加载起来的时候,提前加载一个被修改过的类,这样在真正的类要加载的时候,就会被忽略。
我们需要在 AbstractKotlinCompile 的 executeImpl 里面插入如下的代码
notion image
我们通过调用 KotlinInputTaskHook.transform() 函数,来修改 inputFiles,然后 kotlin 拿到的 InputFiles 就是被我们修改过的 InputFiles 了。
我们用 ASM 来在运行时修改 Kotlin 的代码,插入上面的逻辑。
notion image
这样就可以直接替换上面的插入任务的方案啦,实测下来,因为这种方案只需要插入一次代码,所以带来的负面开销可以忽略不计。

修改文件变动

修改文件变动的逻辑就不展开了,看下面的流程图把👇
notion image
 

© Guang Feng 2022 - 2024