kotlin 增量编译策略

date
Mar 19, 2020
slug
kotlin-incremental-build-strategy
status
Published
tags
kotlin
summary
Kotlin 增量编译策略
type
Post
首先,kotlin-compiler 的增量是靠检测 gradle 输入的 changed & removed Files 来判断是否需要增量的。
首先明确有几个文件,这些文件在 ${module}/build/kotlin/compileDebugKotlin 下面。
notion image
  • last-build.bin
  • build-history.bin
  • caches-jvm 里面三个文件夹
    • inputs
    • jvm-kotlin
    • lookups
  • build/tmp/kotlin-classes/META-INF/app_debug.kotlin_module
  1. last-build.bin 记录了当前 module 的「最后一次编译时间」,用时间戳记录。
  1. build-history.bin 记录了当前 module 的构建历史。它里面大概是 List<BuildDifference> 的二进制流。其中每新增一次构建,都会往这个 list 里面新增一个 BuildDifference。
notion image
其中,BuildDifference的结构是
// ts 时间戳,isIncremental 是否增量,dirtyData:脏数据?
data class BuildDifference(val ts: Long, val isIncremental: Boolean, val dirtyData: DirtyData)
data class DirtyData(
        val dirtyLookupSymbols: Collection&lt;LookupSymbol&gt; = emptyList(),
        val dirtyClassesFqNames: Collection&lt;FqName&gt; = emptyList()
)
data class LookupSymbol(val name: String, val scope: String)
data class FqName(val fqName: String, val parent: FqName) // 简化版
ts 表示构建的时间,isIncremental 表示构建是否是增量,dirtyData:脏数据,里面含有了这次变动的「脏数据」,比如某个类被 modified,某个类被 removed… 等,但是 kotlin 对相应的变动做了类似于「Abi avoidance」的策略。这里举个例子
notion image
我们改动一下一个 final 变量,这个变量编译的时候会被内联,那么,在这个 final 变量被改变之后,「脏数据」是这样的
notion image
这里就会把脏数据写到 build-history.bin,里面记录了 c 这个变量和 <SAM-CONSTRUCTOR> 发生了变化(因为 c 在 companion object,相当于 java 里面的 static 方法区)。
但是,如果改动了其它变量,比如一个不是 final 的常量,它照理说不应该引起下游重新编译的,kotlinc 就不会将它写入 DirtyData。
 
✅ 代表不会产生 DirtyData
❌ 会产生 DirtyData
Kotlin-ABI-Change
改(函数签名改变)
改(函数内容改变)
  1. caches-jvm 里面三个文件夹,其中 jvm-kotlin 在 js 平台下是 js 相关的缓存。inputs 和 lookups 是 kotlin-compiler 通用的缓存。
  1. .kotlin_module 会被打进 class.jar 中,以 {module}_{variant}.kotlin_module 命名。方便上层 module 去定位build-history.bin。举个例子,比如 app 依赖 lib。在构建 app 的时候,gradle 告诉 kotinc,lib/build/xxxxxx/classes.jar 这个依赖发生了变化。这时候会去解开 class.jar,找到 class.jar 里面的 META-INF/lib_debug.kotlin_module
notion image
知道了这个 jar 来自 lib 后,就可以找到打这个 jar 时候生成的 build-history.bin,就可以知道改变了一些什么,进行增量编译。

会导致全量编译的 badcase

val compilationMode = sourcesToCompile(caches, changedFiles, args)
val exitCode = when (compilationMode) {
    is CompilationMode.Incremental -> {
        compileIncrementally(args, caches, allSourceFiles, compilationMode, messageCollector)
    }
    is CompilationMode.Rebuild -> {
        rebuild { "Non-incremental compilation will be performed: ${compilationMode.reason}" }
    }
}
kotlin-compiler 根据 sourcesToCompile 函数去确定是否需要增量编译还是重新编译。下面列了一些 case。
首先,要明确「变动源」是哪些
  • .java 工程里的 java 文件,其中包含 apt 生成的。
  • .kt,工程里面的 kt 文件。
  • 三方库依赖里的 jar
  • 自己的 R.jar
  • 底层 module 生成的的 class.jar
  • kotlin runtime、jdk
  • android.jar
其中前五个比较容易变化,下面一个一个介绍。其实上面可以分为三种。
  • kt 变化
  • java 变化
  • jar 变化
Java 文件变动
代码在 ChangedJavaFilesProcessor
  • 如果有 java 文件删除,那么需要重新编译。
val removedJava = filesDiff.removed.filter(File::isJavaFile)
if (removedJava.any()) {
	 reporter.report { "Some java files are removed: [${removedJava.joinToString()}]" }
	 return ChangesEither.Unknown()
}
  • 如果有 java 文件变动,会用 java 文件构造一个 PsiJavaFile,输入它的所有 method、field 到 dirtyData中。这里是所有,没有做 abi 判断。
kt 文件变动
dirtyFiles.add(changedFiles.modified, “was modified since last time”)
dirtyFiles.add(changedFiles.removed, “was removed since last time”)
if (dirtySourcesSinceLastTimeFile.exists()) {
    val files = dirtySourcesSinceLastTimeFile.readLines().map(::File)
    dirtyFiles.add(files, “was not compiled last time”)
}
.kt 文件有单独的处理方式,会将 modified 和 removed 文件塞入 DirtyFileContainer里面,后面一起做处理。
jar 变动
jar 变动可能是三方库 jar 变动,可能是底层 module jar 变动,可能是 R.jar 变动,都在 classpath 中能找到。
val modifiedClasspath = changedFiles.modified.filterTo(HashSet()) { it in classpathSet }
val removedClasspath = changedFiles.removed.filterTo(HashSet()) { it in classpathSet }
  • jar removed
if (removedClasspath.isNotEmpty()) return ChangesEither.Unknown(“Some files are removed from classpath $removedClasspath”)
可见,jar 被删除掉的话,会导致全量编译
  • jar modified
  • jar 被改动之后,会去 jar 里面的 META-INF/x.kotlin_module 找到 history-build.bin,找到最后一次编译之后的所有编译的 BuildDiff,从而搜集到所有的 DirtyData
val historyFilesEither = modulesApiHistory.historyFilesForChangedFiles(modifiedClasspath)
val historyFiles = when (historyFilesEither) {
    is Either.Success<Set<File>> -> historyFilesEither.value
    is Either.Error -> return ChangesEither.Unknown(historyFilesEither.reason)
}
可以看出,如果 jar 改动,并且找不到 history-build.bin,就会重新全量编译。也就是说,除了 R.jar(待验证)、底层 module 的 classes.jar 会有 history-build.bin 生成,三方库是没有 history-build.bin 的,所以按理说三方库的依赖改变会导致全量编译。

开始增量构建

caches-jvm 里面有三个文件夹
  • inputs
  • jvm-kotlin
  • lookups
  • 其中 jvm-kotlin 在 js 平台下是 js 相关的缓存。inputs 和 lookups 是平台通用的缓存。
  • 这边涉及到另一个比较复杂的工程了,没有深入看。 caches-jvm 里面存的主要是 class 的信息。kotlin 拿到sourcesToCompile中计算好的 DirtyData,和 caches-jvm 里面的信息来决定哪些类需要被真正编译
  • 其中 .kt 部分可能有涉及 abi 相关的逻辑。
sealed class ChangeInfo(val fqName: FqName) {
    open class MembersChanged(fqName: FqName, val names: Collection<String>) : ChangeInfo(fqName) 
    class Removed(fqName: FqName, names: Collection<String>) : MembersChanged(fqName, names)
    class SignatureChanged(fqName: FqName, val areSubclassesAffected: Boolean) : ChangeInfo(fqName)
}
这个类里面有 MembersChanged SignatureChanged Removed 等信息,猜测是基于函数签名、field 的变化去判断是否需要被编译。
因为这里已经不会再 fallback 到全量编译了,所以这块的优化投入产出比不是很高,比如它计算出了三四十个类需要重新编译,对于 kotlinc 也就三四秒的事,优化空间不是很足。

总结

可以优化的点
  • .java 文件被删除时,会触发全量编译。
  • .jar 文件被删除时,会触发全量编译。
  • 没有 history-build.bin的三方库 jar 变动,会引起全量编译。
  • 其它 gradle input output 会引起 gradle 全量编译 kotlin 任务的 case(待梳理)。

© Guang Feng 2022 - 2024