深入研究Android编译流程-Kotlin是如何编译的

2,064 阅读4分钟

当前我们常用的 Android 开发语言为 Kotlin,日常的项目也基本是 Kotlin 和 Java 共存。那么 Android 编译的时候会如何编译 Kotlin 呢,本篇文章我会对 Kotlin 编译的触发流程做一个介绍。

编译流程

Kotlin 的编译流程也不在 AGP 中,而是在 KGP(kotlin gradle plugin) 中,我们可以从 GitHub 中直接拉取 Kotlin 的源码。Kotlin 的工程里就包括了 KGP 和 Kotlin compiler 的源码。

通过插件demo里用

taskContainer.findByName("compileDebugKotlin")

我们可以找到负责 Kotlin 编译任务的类:org.jetbrains.kotlin.gradle.tasks.KotlinCompile。 这是一个抽象类,编译相关的逻辑都维护在这个类里。这里 @TaskAction 注解标记在 execute 方法内。

execute 的入参和 JavaCompile 一样也是 inputChanges 对象。里面包括了变化的 Kotlin 文件和 Jar 文件。

image.png 这里顺着逻辑走会走到 GradleKotlinCompilerWorkcompileWithDaemmonOrFailbackImpl

private fun compileWithDaemonOrFallbackImpl(messageCollector: MessageCollector): ExitCode {
  val executionStrategy = kotlinCompilerExecutionStrategy()
  if (executionStrategy == DAEMON_EXECUTION_STRATEGY) {
    val daemonExitCode = compileWithDaemon(messageCollector)
    if (daemonExitCode != null) {
      return daemonExitCode
    }
  }
  val isGradleDaemonUsed = System.getProperty("org.gradle.daemon")?.let(String::toBoolean)
  return if (executionStrategy == IN_PROCESS_EXECUTION_STRATEGY || isGradleDaemonUsed == false) {
    compileInProcess(messageCollector)
   } else {
    compileOutOfProcess()
   }
}

这里kotlin编译自身有三种策略,分别是

  • 守护进程编译 Android编译的默认模式,只有这种模式才支持增量编译
  • 进程内编译, 进程内编译
  • 进程外编译,直接调用kotlinc在其他进程执行完后返回结果

compileWithDaemon 会调用到 Kotlin Compile 里执行真正的编译逻辑:

val exitCode = try {
  val res = if (isIncremental) {
    incrementalCompilationWithDaemon(daemon, sessionId, targetPlatform, bufferingMessageCollector)
  } else {
    nonIncrementalCompilationWithDaemon(daemon, sessionId, targetPlatform, bufferingMessageCollector)
  }
  bufferingMessageCollector.flush(messageCollector)
  exitCodeFromProcessExitCode(log, res.get())
} catch (e: Throwable) {
    bufferingMessageCollector.flush(messageCollector)
    log.error("Compilation with Kotlin compile daemon was not successful")
    log.error(e.stackTraceAsString())
    null
}

这里会执行 org.jetbrains.kotlin.daemon.CompileServiceImplcompile 方法。这里面就会调用真正的编译过程。

增量编译

和Java一样,我们也来关注一下 Kotlin 的增量编译逻辑,对应代码的这一部分: image.pngexecIncrementalCompiler 方法里面的逻辑。这里会执行 IncrementalJvmCompilerRunner: image.pngcompile里面,会根据变化的文件来推算出需要的编译模式:

val compilationMode = sourcesToCompile(caches, changedFiles, args, messageCollector, classpathAbiSnapshot)
  
protected sealed class CompilationMode {
  class Incremental(val dirtyFiles: DirtyFilesContainer) : CompilationMode()
  class Rebuild(val reason: BuildAttribute) : CompilationMode()
}

编译模式无非就是增量编译和全量编译。

image.pngsourcesToCompile 里面能看到,当不知道变化文件的时候会触发全量编译,正常情况下会根据 calculateSourcesToCompile 的结果进一步得到准确的模式结果。即 org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunnercalculateSourcesToCompileImpl 方法。

在这个方法里面也会发现一些触发 rebuild 的逻辑,因为整体代码比较多,所以我这里贴几个:

val lastBuildInfo = BuildInfo.read(lastBuildInfoFile) ?: return CompilationMode.Rebuild(BuildAttribute.NO_BUILD_HISTORY)

如果读取不到 lastBuildinfo, 会触发全量。 lastBuildInfo 是从 build/kotlin 目录下面读取的 last-build.bin 文件,主要是记录了上次编译开始的时间戳。 然后接下来是类似Java增量编译一样的判断,判断 classpath 和 文件变动。 当 classpath 被移除或者依赖的 buildHistory 找不到的时候,会触发全量编译(这两种其实就包括了aar依赖修改的这种情况),这里可以查看哪些地方返回的是 ChangesEither.Unknown:

// Jar file is removed form dependency
if (removedClasspath.isNotEmpty()) {
  reporter.report { "Some files are removed from classpath: $removedClasspath" }
  return ChangesEither.Unknown(BuildAttribute.DEP_CHANGE_REMOVED_ENTRY)
}


//Dependency history not found
val historyFiles = when (historyFilesEither) {
  is Either.Success<Set<File>> -> historyFilesEither.value
  is Either.Error -> {
    reporter.report { "Could not find history files: ${historyFilesEither.reason}" }
    return ChangesEither.Unknown(BuildAttribute.DEP_CHANGE_HISTORY_IS_NOT_FOUND)
  }
}

还会处理 Java 文件的输入变化:

val javaFilesChanges = javaFilesProcessor!!.process(changedFiles)
  val affectedJavaSymbols = when (javaFilesChanges) {
    is ChangesEither.Known -> javaFilesChanges.lookupSymbols
    is ChangesEither.Unknown -> return CompilationMode.Rebuild(javaFilesChanges.reason)
}

ChangedJavaFilesProcessorprocess可以看到:

if (removedJava.any()) {
  reporter.report { "Some java files are removed: [${removedJava.joinToString()}]" }
  return ChangesEither.Unknown(BuildAttribute.JAVA_CHANGE_UNTRACKED_FILE_IS_REMOVED)
}

如果 Java 文件删除,会触发全量编译。 在 BuildAttribute 这个 enum 里面,几乎定义了全部的全量编译的 case, 感兴趣的朋友可以单独去查看一下: image.png

如果不触发上面这些场景,那么Kotlin编译则会进行正常的增量编译。返回的 CompilationMode.Incremental(dirtyFiles), 不过新的疑惑来了,这里的 dirtyFlles 表示的是什么呢?

增量编译缓存

在我们计算编译是否增量模式的过程中,几乎每个步骤都会处理这个 dirtyFiles:

// 初始化
val dirtyFiles = DirtyFilesContainer(caches, reporter, kotlinSourceFilesExtensions)
initDirtyFiles(dirtyFiles, changedFiles)
  
// 处理classpath
dirtyFiles.addByDirtySymbols(classpathChanges.lookupSymbols)
dirtyClasspathChanges = classpathChanges.fqNames
dirtyFiles.addByDirtyClasses(classpathChanges.fqNames)
  
// java
dirtyFiles.addByDirtySymbols(affectedJavaSymbols)
  
// 其他的内容 
// 布局文件变化  
dirtyFiles.addByDirtySymbols(androidLayoutChanges)
dirtyFiles.addByDirtySymbols(removedClassesChanges.dirtyLookupSymbols)
// class变化  
dirtyFiles.addByDirtyClasses(removedClassesChanges.dirtyClassesFqNames)
dirtyFiles.addByDirtyClasses(removedClassesChanges.dirtyClassesFqNamesForceRecompile)

compileIncrementally 中通过一系列逻辑,最后会生成一个 DirtyData存储到 buildHistory 里面:

private fun processChangesAfterBuild(
  compilationMode: CompilationMode,
  currentBuildInfo: BuildInfo,
  dirtyData: DirtyData
) {
  val prevDiffs = BuildDiffsStorage.readFromFile(buildHistoryFile, reporter)?.buildDiffs ?: emptyList()
  val newDiff = if (compilationMode is CompilationMode.Incremental) {
    BuildDifference(currentBuildInfo.startTS, true, dirtyData)
  } else {
    val emptyDirtyData = DirtyData()
    BuildDifference(currentBuildInfo.startTS, false, emptyDirtyData)
  }
  
  BuildDiffsStorage.writeToFile(buildHistoryFile, BuildDiffsStorage(prevDiffs + newDiff), reporter)
}

这里实际上是把每次编译相关代码的变化都写入到了 build/kotlin 的 build-history.bin 文件。例如某个函数签名被修改之类的,这样才可以让 Kotlin 知道自己的增量编译的范围。 这部分存储在文件后可以在下一次增量编译的时候进行读取:

for (historyFile in historyFiles) {
  val allBuilds = BuildDiffsStorage.readDiffsFromFile(historyFile, reporter = reporter)
    ?: return run {
      reporter.report { "Could not read diffs from $historyFile" }
      ChangesEither.Unknown(BuildAttribute.DEP_CHANGE_HISTORY_CANNOT_BE_READ)
    }
  val (knownBuilds, newBuilds) = allBuilds.partition { it.ts <= lastBuildTS }
}

Kotlin增量编译的时候会根据这些内容来确定改变的文件: image.png 返回的mode是增量的时候会包括这些 dirtyFiles: image.png 在编译前,还会通过 dirtySources 和 CacheManager 共同决定传递给编译器的文件:

while (dirtySources.any() || runWithNoDirtyKotlinSources(caches)) {
  val complementaryFiles = caches.platformCache.getComplementaryFilesRecursive(dirtySources)
  dirtySources.addAll(complementaryFiles)
    
  val (sourcesToCompile, removedKotlinSources) = dirtySources.partition(File::exists)
  exitCode = reporter.measure(buildTimeMode) {
    runCompiler(sourcesToCompile.toSet(), args, caches, services, messageCollectorAdapter)
  }  
}

此处的 CacheManager 也是本地的一个缓存内容,对应我们的 build/kotlin/caches-jvm 目录,这里没有太多的纠结细节,大致能看出来这里存着的是一些class信息,用来帮助决定最后的编译内容,例如处理classpath的过程中会获取删除的class: image.png 大致的流程如图所示: image.png 在build目录里面,我们也是可以找到上面提到的几个缓存文件的: image.png

总结

到这里 Kotlin 的编译触发流程也介绍完了。具体细节比较复杂,我们看个大概也能解释一些我们关于平时为什么代码忽然编译很慢的疑惑。 相关可以深入了解的细节也很多,感兴趣的朋友们可以自行阅读 kgp 和 kotlin compiler 的源码。