¿Cómo funciona la compilación incremental de Kotlin?

prefacio

Compilar y ejecutar es una Androidtarea que un desarrollador tiene que hacer todos los días. La compilación incremental también es extremadamente importante para los desarrolladores. La compilación incremental con una alta tasa de aciertos puede mejorar en gran medida la eficiencia de desarrollo y la experiencia de los desarrolladores.

Escribí algunos artículos antes para presentar Kotlinel principio de la compilación incremental y el Kotlin 1.7soporte para la compilación incremental entre módulos.

Después de comprender estos principios básicos, echemos Kotlinun vistazo al código fuente de la compilación incremental hoy para ver cómo Kotlinse implementa la compilación incremental.

conocimiento previo

Echemos un vistazo a la tecnología negra detrás de la compilación rápida de Kotlin ~
Nuevas funciones en Kotlin 1.7: se abandona la compatibilidad con la compilación incremental entre módulos
Transform, echemos un vistazo a TransformAction ~

Es principalmente Kotlinuna introducción al principio de compilación incremental, y debido a que se usa en el código fuente TransformAction, también es necesario comprender TransformActionel uso básico .

Proceso de compilación incremental

Paso 1: Compile la entrada

Si queremos usarlo en el proyecto Kotlin, debemos agregar un org.jetbrains.kotlin.androidcomplemento.Este complemento es Kotlinel punto de entrada de nuestra compilación, y su código está en el kotlin-gradle-plugincomplemento.

La clase de implementación de este complemento es KotlinAndroidPluginWrapper, se puede ver que KotlinAndroidPluginWrapperes un paquete, que es principalmente para crear y configurarKotlinAndroidPlugin

Paso 2: ConfiguraciónKotlinAndroidPlugin

KotlinAndroidPluginEs la entrada real del complemento, donde compileKotlin Taskse realiza el trabajo de configuración relacionado.

internal open class KotlinAndroidPlugin(
    private val registry: ToolingModelBuilderRegistry
) : Plugin<Project> {

    override fun apply(project: Project) {
        checkGradleCompatibility()

        project.dynamicallyApplyWhenAndroidPluginIsApplied() 
    }

    private fun preprocessVariant(
        variantData: BaseVariant,
        compilation: KotlinJvmAndroidCompilation,
        project: Project,
        rootKotlinOptions: KotlinJvmOptionsImpl,
        tasksProvider: KotlinTasksProvider
    ) {
        val configAction = KotlinCompileConfig(compilation)
        configAction.configureTask { task ->
            task.useModuleDetection.value(true).disallowChanges()
            // 将kotlin 编译结果存储在tmp/kotlin-classes/$variantDataName目录下,会作为java compiler的class-path输入
            task.destinationDirectory.set(project.layout.buildDirectory.dir("tmp/kotlin-classes/$variantDataName"))
        }
        tasksProvider.registerKotlinJVMTask(project, compilation.compileKotlinTaskName, compilation.kotlinOptions, configAction)
    }
}
复制代码

Se omite parte del código, principalmente para hacer algunas cosas:

  1. Verifique la compatibilidad KGPcon Gradlela versión, si no, lance una excepción y cancele la compilación
  2. Si el complemento projectya se ha agregado android, comience a configurar el kotlin-androidcomplemento
  3. 通过KotlinCompileConfig来配置KotlinCompile Task,设置destinationDirectory作为Kotlin编译结果存储目录,后续会作为java compilerclasspath输入

第三步:配置KotlinCompile的输入输出

要实现增量编译,最重要的一点就是配置输入输出,当输入输出没有发生变化时,Task就可以被跳过,而KotlinCompile输入输出的配置,主要是在KotlinCompileConfig中完成的

configureTaskProvider { taskProvider ->
	// 是否开启classpathSnapthot
    val useClasspathSnapshot = propertiesProvider.useClasspathSnapshot
    val classpathConfiguration = if (useClasspathSnapshot) {
    	// 注册 Transform
        registerTransformsOnce(project)
        project.configurations.detachedConfiguration(
            project.dependencies.create(objectFactory.fileCollection().from(project.provider { taskProvider.get().libraries }))
        )
    } else null

    taskProvider.configure { task ->
    	// 配置输入属性
        task.classpathSnapshotProperties.useClasspathSnapshot.value(useClasspathSnapshot).disallowChanges()
        if (useClasspathSnapshot) {
        	// 通过TransformAction读取输入
            val classpathEntrySnapshotFiles = classpathConfiguration!!.incoming.artifactView {
                it.attributes.attribute(ARTIFACT_TYPE_ATTRIBUTE, CLASSPATH_ENTRY_SNAPSHOT_ARTIFACT_TYPE)
            }.files
            task.classpathSnapshotProperties.classpathSnapshot.from(classpathEntrySnapshotFiles).disallowChanges()
            task.classpathSnapshotProperties.classpathSnapshotDir.value(getClasspathSnapshotDir(task)).disallowChanges()
        } else {
            task.classpathSnapshotProperties.classpath.from(task.project.provider { task.libraries }).disallowChanges()
        }
    }
}
复制代码

可以看出,主要做了这么几件事

  1. 判断是否开启了classpathSnapthot,这也是支持跨模块增量编译的开关,如果开启了就注册Transform
  2. 通过TransformAction获取输入,并配置给Task相应的属性

下面我们着重来看下TransformAction在这里做了什么工作?

第四步:跨模块增量编译支持

private fun registerTransformsOnce(project: Project) {
    val buildMetricsReporterService = BuildMetricsReporterService.registerIfAbsent(project)
    project.dependencies.registerTransform(ClasspathEntrySnapshotTransform::class.java) {
        it.from.attribute(ARTIFACT_TYPE_ATTRIBUTE, JAR_ARTIFACT_TYPE)
        it.to.attribute(ARTIFACT_TYPE_ATTRIBUTE, CLASSPATH_ENTRY_SNAPSHOT_ARTIFACT_TYPE)
    }
    project.dependencies.registerTransform(ClasspathEntrySnapshotTransform::class.java) {
        it.from.attribute(ARTIFACT_TYPE_ATTRIBUTE, DIRECTORY_ARTIFACT_TYPE)
        it.to.attribute(ARTIFACT_TYPE_ATTRIBUTE, CLASSPATH_ENTRY_SNAPSHOT_ARTIFACT_TYPE)
    }
}
复制代码

了解了前置知识中的TransformAction,可以看出这就是注册了只变换ArtifactType的变换,主要涉及JAR_ARTIFACT_TYPEDIRECTORY_ARTIFACT_TYPE转换为CLASSPATH_ENTRY_SNAPSHOT_ARTIFACT_TYPE

也就是说依赖的jar和类目录都会转换为CLASSPATH_ENTRY_SNAPSHOT_ARTIFACT_TYPE类型,也就可以获取我们依赖的所有classpathabi

接下来我们看下ClasspathEntrySnapshotTransform的实现

ClasspathEntrySnapshotTransform实现

abstract class ClasspathEntrySnapshotTransform : TransformAction<ClasspathEntrySnapshotTransform.Parameters> {
    @get:Classpath
    @get:InputArtifact
    abstract val inputArtifact: Provider<FileSystemLocation>

    override fun transform(outputs: TransformOutputs) {
        val classpathEntryInputDirOrJar = inputArtifact.get().asFile
        val snapshotOutputFile = outputs.file(classpathEntryInputDirOrJar.name.replace('.', '_') + "-snapshot.bin")

        val granularity = getClassSnapshotGranularity(classpathEntryInputDirOrJar, parameters.gradleUserHomeDir.get().asFile)

		 val snapshot = ClasspathEntrySnapshotter.snapshot(classpathEntryInputDirOrJar, granularity, metrics)
         ClasspathEntrySnapshotExternalizer.saveToFile(snapshotOutputFile, snapshot)
        
    }

    /**
    * 如果是anroid.jar或者aar依赖,粒度为class, 否则为class_member_level 
    /
    private fun getClassSnapshotGranularity(classpathEntryDirOrJar: File, gradleUserHomeDir: File): ClassSnapshotGranularity {
        return if (
            classpathEntryDirOrJar.startsWith(gradleUserHomeDir) ||
            classpathEntryDirOrJar.name == "android.jar"
        ) CLASS_LEVEL
        else CLASS_MEMBER_LEVEL
    }
}
复制代码

关于自定义TransformAction,其实跟Task一样,也主要看3个部分,输入,输出,执行方法体

  1. ClasspathEntrySnapshotTransform的输入就是模块依赖的jar或者文件目录
  2. 输出则是以-snapshot.bin结尾的文件
  3. 方法体只做了一件事,通过ClasspathEntrySnapshotter计算出claspath的快照并保存,如果是aar依赖,计算的粒度为class,如果是项目内的类,计算的粒度是class_member_level

ClasspathEntrySnapshotter内部是如何计算classpath快照的我们这就不看了,我们简单看下下面这样一个类计算的快照是怎样的

class MyTest {
    fun startTest(text: String) {
        println(text)
        test1(1)
    }

    private fun test1(index: Int) {
        println("here test126$index")
    }
}
复制代码

MyTest类计算出来的快照如图所示,主要classId,classAbiHash,classHeaderStrings等内容

可以看出private函数的声明也是abi的一部分,当public或者private的函数声明发生变化时,classAbiHash都会发生变化,而只修改函数体时,snapshot不会发生任何变化。

第五步:KotlinCompile Task执行编译

在配置完成之后,接下来我们就来看下KotlinCompile是怎么执行编译的

abstract class KotlinCompile @Inject constructor(
    override val kotlinOptions: KotlinJvmOptions,
    workerExecutor: WorkerExecutor,
    private val objectFactory: ObjectFactory
) : AbstractKotlinCompile<K2JVMCompilerArguments>(objectFactory {

	// classpathSnapshot入参
    @get:Nested
    abstract val classpathSnapshotProperties: ClasspathSnapshotProperties

    abstract class ClasspathSnapshotProperties {
        @get:Classpath
        @get:Incremental
        @get:Optional // Set if useClasspathSnapshot == true
        abstract val classpathSnapshot: ConfigurableFileCollection
    }

    // 增量编译参数
    override val incrementalProps: List<FileCollection>
        get() = listOf(
            sources,
            javaSources,
            classpathSnapshotProperties.classpathSnapshot
        )

    override fun callCompilerAsync(inputChanges: InputChanges) {
    	// 获取增量编译环境变量
        val icEnv = if (isIncrementalCompilationEnabled()) {
            IncrementalCompilationEnvironment(
                changedFiles = getChangedFiles(inputChanges, incrementalProps),
                classpathChanges = getClasspathChanges(inputChanges),
            )
        } else null
        val environment = GradleCompilerEnvironment(incrementalCompilationEnvironment = icEnv)
        compilerRunner.runJvmCompilerAsync(
            (kotlinSources + scriptSources).toList(),
            commonSourceSet.toList(),
            javaSources.files,
            environment,
        )
    }

    // 查找改动了的input
    protected fun getChangedFiles(
        inputChanges: InputChanges,
        incrementalProps: List<FileCollection>
    ) = if (!inputChanges.isIncremental) {
        ChangedFiles.Unknown()
    } else {
        incrementalProps
            .fold(mutableListOf<File>() to mutableListOf<File>()) { (modified, removed), prop ->
                inputChanges.getFileChanges(prop).forEach {
                    when (it.changeType) {
                        ChangeType.ADDED, ChangeType.MODIFIED -> modified.add(it.file)
                        ChangeType.REMOVED -> removed.add(it.file)
                        else -> Unit
                    }
                }
                modified to removed
            }
            .run {
                ChangedFiles.Known(first, second)
            }
    }

    // 查找改变了的classpath
    private fun getClasspathChanges(inputChanges: InputChanges): ClasspathChanges = when {
        !classpathSnapshotProperties.useClasspathSnapshot.get() -> ClasspathSnapshotDisabled
        else -> {
            when {
                !inputChanges.isIncremental -> NotAvailableForNonIncrementalRun(classpathSnapshotFiles)
                inputChanges.getFileChanges(classpathSnapshotProperties.classpathSnapshot).none() -> NoChanges(classpathSnapshotFiles)
                !classpathSnapshotFiles.shrunkPreviousClasspathSnapshotFile.exists() -> {
                    NotAvailableDueToMissingClasspathSnapshot(classpathSnapshotFiles)
                }
                else -> ToBeComputedByIncrementalCompiler(classpathSnapshotFiles)
            }
        }
    }
}
复制代码

对于KotlinCompile,我们也可以从入参,出参,TaskAction的角度来分析

  1. classpathSnapshotProperties是个包装类型的输入,内部包括@Classpath类型的输入,使用@Classpath输入时,如果输入文件名发生变化而内容没有发生变化时,不会触发Task重新运行,这对classpath来说非常重要
  2. incrementalProps是组件后的增量编译输入参数,包括kotlin输入,java输入,classpath输入等
  3. CompileKotlinTaskAction,它最后会执行到callCompilerAsync方法,在其中通过getChangedFilesgetClasspathChanges获取改变了的输入与classpath
  4. getClasspathChanges方法通过inputChanges获取一个已经改变与删除的文件的Pair
  5. getClasspathChanges则根据增量编译是否开启,是否有文件发生更改,历史snapshotFile是否存在,返回不同的ClassPathChanges密封类

在增量编译参数拼装完成后,接下来就是跟着逻辑走,最后会走到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编译有三种策略,分别是

  1. 守护进程编译:Kotlin编译的默认模式,只有这种模式才支持增量编译,可以在多个Gradle daemon进程间共享
  2. 进程内编译:Gradle daemon进程内编译
  3. 进程外编译:每次编译都是在不同的进程

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

val exitCode = try {
  val res = if (isIncremental) {
    incrementalCompilationWithDaemon(daemon, sessionId, targetPlatform, bufferingMessageCollector)
  } else {
    nonIncrementalCompilationWithDaemon(daemon, sessionId, targetPlatform, bufferingMessageCollector)
  }
} catch (e: Throwable) {
    null
}
复制代码

到这里会执行 org.jetbrains.kotlin.daemon.CompileServiceImplcompile 方法,这样就终于调到了Kotlin编译器内部

第六步:Kotlin 编译器计算出需重编译的文件

经过这么多步骤,终于走到Kotlin编译器内部了,下面我们来看下Kotlin编译器的增量编译逻辑

protected inline fun <ServicesFacadeT, JpsServicesFacadeT, CompilationResultsT> compileImpl(){
	//...
	CompilerMode.INCREMENTAL_COMPILER -> {
	    when (targetPlatform) {
	        CompileService.TargetPlatform.JVM -> withIC(k2PlatformArgs) {
	            doCompile(sessionId, daemonReporter, tracer = null) { _, _ ->
	                execIncrementalCompiler(
	                    k2PlatformArgs as K2JVMCompilerArguments,
	                    gradleIncrementalArgs,
	                    //...
	                )
	            }
        }	
}
复制代码

如上代码,会判断输入的编译参数,如果是增量编译并且是JVM平台的话,就会执行execIncrementalCompiler方法,最后会调用到sourcesToCompile方法

private fun sourcesToCompile(
    caches: CacheManager,
    changedFiles: ChangedFiles,
    args: Args,
    messageCollector: MessageCollector,
    dependenciesAbiSnapshots: Map<String, AbiSnapshot>
): CompilationMode =
    when (changedFiles) {
        is ChangedFiles.Known -> calculateSourcesToCompile(caches, changedFiles, args, messageCollector, dependenciesAbiSnapshots)
        is ChangedFiles.Unknown -> CompilationMode.Rebuild(BuildAttribute.UNKNOWN_CHANGES_IN_GRADLE_INPUTS)
        is ChangedFiles.Dependencies -> error("Unexpected ChangedFiles type (ChangedFiles.Dependencies)")
    }

private fun calculateSourcesToCompileImpl(
        caches: IncrementalJvmCachesManager,
        changedFiles: ChangedFiles.Known,
        args: K2JVMCompilerArguments,
        abiSnapshots: Map<String, AbiSnapshot> = HashMap(),
        withAbiSnapshot: Boolean
    ): CompilationMode {
      	val dirtyFiles = DirtyFilesContainer(caches, reporter, kotlinSourceFilesExtensions)
      	// 初始化dirtyFiles
        initDirtyFiles(dirtyFiles, changedFiles)

    	// 计算变化的classpath
        val classpathChanges = when (classpathChanges) {
            is NoChanges -> ChangesEither.Known(emptySet(), emptySet())
            //  classpathSnapshot可用时
            is ToBeComputedByIncrementalCompiler -> reporter.measure(BuildTime.COMPUTE_CLASSPATH_CHANGES) {
                computeClasspathChanges(
                    classpathChanges.classpathSnapshotFiles,
                    caches.lookupCache,
                    storeCurrentClasspathSnapshotForReuse,
                    ClasspathSnapshotBuildReporter(reporter)
                ).toChangesEither()
            }
            is NotAvailableDueToMissingClasspathSnapshot -> ChangesEither.Unknown(BuildAttribute.CLASSPATH_SNAPSHOT_NOT_FOUND)
            is NotAvailableForNonIncrementalRun -> ChangesEither.Unknown(BuildAttribute.UNKNOWN_CHANGES_IN_GRADLE_INPUTS)
            // classpathSnapshot不可用时
            is ClasspathSnapshotDisabled -> reporter.measure(BuildTime.IC_ANALYZE_CHANGES_IN_DEPENDENCIES) {
                val lastBuildInfo = BuildInfo.read(lastBuildInfoFile)   
                getClasspathChanges(
                    args.classpathAsList, changedFiles, lastBuildInfo, modulesApiHistory, reporter, abiSnapshots, withAbiSnapshot,
                    caches.platformCache, scopes
                )
            }
            is NotAvailableForJSCompiler -> error("Unexpected type for this code path: ${classpathChanges.javaClass.name}.")
        }
        // 将结果添加到dirtyFiles
        val unused = when (classpathChanges) {
            is ChangesEither.Unknown -> {
                return CompilationMode.Rebuild(classpathChanges.reason)
            }
            is ChangesEither.Known -> {
                dirtyFiles.addByDirtySymbols(classpathChanges.lookupSymbols)
                dirtyClasspathChanges = classpathChanges.fqNames
                dirtyFiles.addByDirtyClasses(classpathChanges.fqNames)
            }
        }

        // ...
        return CompilationMode.Incremental(dirtyFiles)
    }    
复制代码

calculateSourcesToCompileImpl的目的就是计算Kotlin编译器应该重新编译哪些代码,主要分为以下几个步骤

  1. 初始化dirtyFiles,并将changedFiles加入dirtyFiles,因为changedFiles需要重新编译
  2. classpathSnapshot可用时,通过传入的snapshot.bin文件,与Project目录下的shrunk-classpath-snapshot.bin进行比较得出变化的classpath,以及受影响的类。在比较结束时,也会更新当前目录的shrunk-classpath-snapshot.bin,供下次比较使用
  3. Cuando no classpathSnapshotestá disponible, el cambio getClasspathChangesse juzga por el método classpath, que en realidad se juzga por last-build.biny build-history.bin, y se actualizará cada vez que se complete la compilación.build-history.bin
  4. Agregue las clases afectadas por el classpathcambio tambiéndirtyFiles
  5. Regrese dirtyFilespara que el Kotlincompilador realmente comience a compilar

En este paso, el Kotlincompilador analiza los diversos parámetros de entrada y agrega los archivos que deben volver a compilarse dirtyFilespara el siguiente paso.

Paso 7: Kotlinel compilador realmente comienza a compilar

private fun compileImpl(): ExitCode {
    // ...
    var compilationMode = sourcesToCompile(caches, changedFiles, args, messageCollector, classpathAbiSnapshot)
    when (compilationMode) {
        is CompilationMode.Incremental -> {
            // ...
            compileIncrementally(args, caches, allSourceFiles, compilationMode, messageCollector, withAbiSnapshot)
        }
        is CompilationMode.Rebuild -> rebuildReason = compilationMode.reason
    }
    // ...
}

protected open fun compileIncrementally(): ExitCode {
   while (dirtySources.any() || runWithNoDirtyKotlinSources(caches)) {
        // ...
        val (sourcesToCompile, removedKotlinSources) = dirtySources.partition(File::exists)
        // 真正进行编译
        val compiledSources = runCompiler(
            sourcesToCompile, args, caches, services, messageCollectorAdapter,
            allKotlinSources, compilationMode is CompilationMode.Incremental
        )
        // ...
    }    

    if (exitCode == ExitCode.OK) {
        // 写入`last-build.bin`
        BuildInfo.write(currentBuildInfo, lastBuildInfoFile)
    }

    val dirtyData = DirtyData(buildDirtyLookupSymbols, buildDirtyFqNames)
    // 写入`build-history.bin`
    processChangesAfterBuild(compilationMode, currentBuildInfo, dirtyData)

    return exitCode
}
复制代码

Este código hace principalmente las siguientes cosas:

  1. Después sourcesToCompilede calcular los archivos modificados, si es posible la compilación incremental, ingrese elcompileIncrementally
  2. dirtySoucesDescubra los archivos que deben volver a runCompilermétodo para una compilación real
  3. Una vez completada la compilación, escriba last-build.biny build-history.binarchive para comparar en la próxima compilación

En este punto, el proceso de compilación incremental básicamente se completa.

Resumir

Este artículo presenta con más detalle Kotincómo iniciar la compilación incremental paso a paso desde el punto de entrada de la compilación. Comprender el Kotlinprincipio de la compilación incremental puede ayudarlo a localizar por qué Kotlina veces falla la compilación incremental, y también puede aprender a escribir una compilación incremental que sea más fácil de hit.code, espero que te ayude.

Hay más detalles sobre Kotlinla compilación incremental. Este artículo solo presenta el proceso principal. Los estudiantes interesados ​​pueden ver directamente el código fuente KGPdel Kotlincompilador.

Referencias

Una inmersión profunda en el proceso de compilación de Android: cómo se compila Kotlin

Supongo que te gusta

Origin juejin.im/post/7137089121989689351
Recomendado
Clasificación