目录

KCP 开发

最近,一直在搞 kmp(Kotlin Multiplatform),这个框架出发点倒还可以,可以实现一码多端

但是,路漫漫其修远兮,感觉就是在用 kotlin 写 c

各种提效工具比较落后,文档比较稀少,需要自己去研究(国内大厂:研究 + 魔改 = 自研 = 世界领先)

Kotlin Compiler Plugin

kotlin 如今已经不是简单的将 kotlin 代码编译成 java bytecode 了,kotlin/jvm 只是其中之一的 backend

还有诸如 kotlin/js kotlin/native 等等的 backend

在 android 开发中,我们可以使用 agp 提供的 transform 能力来实现代码的插桩,或者任意的修改

但是,如今,我们的 kotlin 代码还需要编译成 native binary

这其中,kotlin ir 会被转换为 llvm ir,那我们如何插桩呢?

这就需要 kotlin compiler plugin 了

开发一个 KCP 模块

基础依赖

implementation(kotlin("gradle-plugin-api"))
implementation(kotlin("compiler-embeddable", "2.0.21-4"))

// 如果需要 auto service 帮助 kcp services 生成 meta-inf,可以添加
ksp("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
implementation("com.google.devtools.ksp:symbol-processing-api:2.0.21-1.0.25")
compileOnly("com.google.auto.service:auto-service-annotations:1.1.1")

META-INF 配置

可以选择手写,也可以选择如下方式

这样会自动生成 meta-inf 下的 plugin 配置

gradlePlugin {
    plugins {
        create("test") {
            id =  rootProject.group.toString()
            implementationClass = "com.example.test.TestPlugin"
        }
    }
}

Compiler Plugin 注册方式

我们可以通过注册常规的 plugin 来加载 kotlin compiler plugin

class TestPlugin : KotlinCompilerPluginSupportPlugin {

    // 和普通的 gradle plugin 一样
    // 可以获取 extension 等等
    override fun apply(target: Project) {
        super.apply(target)
    }

    // options 用于传递参数
    override fun applyToCompilation(
        kotlinCompilation: KotlinCompilation<*>
    ): Provider<List<SubpluginOption>> {
        val project = kotlinCompilation.target.project
        return project.provider {
            val options = mutableListOf<SubpluginOption>()
            options.add(SubpluginOption(KEY_TARGET, kotlinCompilation.target.targetName))
            options
        }
    }

    // kcp plugin id
    override fun getCompilerPluginId(): String = BuildConfig.KOTLIN_PLUGIN_ID

    override fun getPluginArtifact(): SubpluginArtifact = SubpluginArtifact(
        groupId = BuildConfig.KOTLIN_PLUGIN_GROUP,
        artifactId = BuildConfig.KOTLIN_PLUGIN_NAME,
        version = BuildConfig.KOTLIN_PLUGIN_VERSION,
    )

    // 返回 true 才会走到 applyToCompilation
    // 可以添加一些特殊逻辑
    override fun isApplicable(kotlinCompilation: KotlinCompilation<*>): Boolean {
        return true
    }

}

Extension 能力注册

需要实现 CompilerPluginRegistrar

在 registerExtensions 中注册自己需要的 extension 能力

常见的,可以注册 fir 和 ir 能力

fir 是 kotlin 源码 和 ir 之间的一种状态, 新版 kotlin compiler 使用 k2,而 k2 就是使用 fir 来解析 kotlin 源码
ir 是 kotlin 转为多端产物的标准的中间表达式,它会通过 fir2ir 将 fir 转为 ir

所以,我们注册的 fir extension 会先执行,ir extension 会后执行

// 这里使用 auto service 就不用手写 meta-inf/services 了
@OptIn(ExperimentalCompilerApi::class)
@AutoService(CompilerPluginRegistrar::class)
class TestPluginRegistrar : CompilerPluginRegistrar() {
    override val supportsK2: Boolean
        get() = true

    override fun ExtensionStorage.registerExtensions(
        configuration: CompilerConfiguration
    ) {
        // 注册 fir
        FirExtensionRegistrarAdapter.registerExtension(
            FirAllPublicExtensionRegistrar()
        )
        // 注册 ir
        IrGenerationExtension.registerExtension(
            HotfixIrGenerationExtension()
        )
    }
}

处理 IR Extention Transform 的例子

kotlin ir extension 的逻辑很简单,主要是注册 transform

class TestIrGenerationExtension(private val config: HotfixConfig) : IrGenerationExtension {
    override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
        val transformers = mutableListOf<IrElementVisitorVoid>()
        // transform 逻辑
        transformers.add(TestTransformer(pluginContext))
        for (transformer in transformers) {
            moduleFragment.acceptChildrenVoid(transformer)
        }
    }
}

我们来看一下 transform 如何实现

class TestTransformer(val context: IrPluginContext) : IrElementVisitorVoid {
  override fun visitElement(element: IrElement) {
    // 这里选择自己感兴趣的 element
    // 调用 element.acceptChildrenVoid(this) 
    // 后续就会开始遍历 class funtion 等等
    super.visitElement(element)
  }

  // 遍历 class
  override fun visitClass(declaration: IrClass) {
    super.visitClass(declaration)
  }

  override fun visitSimpleFunction(declaration: IrSimpleFunction) {
    super.visitSimpleFunction(declaration)
  }
}

IR 语法较多,我这里了解也比较有限

在 IrBuilder 下,可以使用 IrCall 等等指令执行方法调用,context.reference 方法可以获取已经在编译环境中的 class/function 等信息

还有 irAs/irReturn 等众多方法,满足我们任何修方法体内代码的需求

处理 Options 和 CommanLineProcessor

kotlin gradle plugin 的参数可以通过 commandline processor 解析,传递给 configuration

在 compilerPluginRegistrar 调用 registerExtension 时,会回传 configuration,进而可以被 kotlin compiler plugin 获取到对应参数,可以用做定制逻辑

example:

// commandline processor
TestCommandLineProcessor : CommandLineProcessor {
    override val pluginId: String = BuildConfig.KOTLIN_PLUGIN_ID

    override val pluginOptions: Collection<AbstractCliOption> =
    // 处理哪些参数
        listOf(
            CliOption(KEY_TARGET, "target name", "platform target", required = false),
            CliOption(KEY_DEBUG, "debug mode", "debug mode", required = false)
        )


    override fun processOption(option: AbstractCliOption, value: String, configuration: CompilerConfiguration) {
        // 自己解析,塞到 configuration 中区
        when (option.optionName) {
            KEY_TARGET -> {
                configuration.put(TestConfigurationKeys.TARGET, value)
            }

            KEY_DEBUG -> {
                configuration.put(TestConfigurationKeys.DEBUG, value)
            }

            else -> {
                super.processOption(option, value, configuration)
            }
        }
    }
}

// key 需要是一个 CompilerConfigurationKey
object TestConfigurationKeys {
    val TARGET = CompilerConfigurationKey<String>(KEY_TARGET)
    val DEBUG = CompilerConfigurationKey<String>(KEY_DEBUG)
}



// CompilerPluginRegistrar 中获取参数
class TestPluginRegistrar : CompilerPluginRegistrar() {
      override fun ExtensionStorage.registerExtensions(
        configuration: CompilerConfiguration
    ) {
          val target = configuration.get(TestConfigurationKeys.TARGET)
        val isDebug = configuration.get(TestConfigurationKeys.DEBUG)
    }

}

给 KCP 添加单元测试

开发完一个插件,debug 过程是痛苦的

如果,需要发布、引用插件、gradle debug 那整个路径也太长了

好在,我们可以通过写 kotlin test 的方式,直接像单元测试一样,跑测试

这里我使用的是:dev.zacsweers.kctfork:core:0.6.0 这个开源库

gradle 中添加我们的 test 依赖即可

// 如果需要引用到你其他模块的代码
testImplementation(project(":test-runtime"))
testImplementation("org.junit.jupiter:junit-jupiter:5.8.2")
testImplementation("junit:junit:4.13.2")
// 添加 依赖
testImplementation("dev.zacsweers.kctfork:core:0.6.0")

test 代码示例:

@OptIn(ExperimentalCompilerApi::class)
class CompilerPluginTest {

    // 可以直接 run/debug
    @Test
    fun testExample() {
        // 读取要测试的 kt 文件
        val sourceFile = mutableListOf<SourceFile>()
        listOf("TestEnum.kt").forEach { it->
            sourceFile.add(SourceFile.kotlin(it, classLoader.getResourceAsStream(it)!!.bufferedReader().use {
                it.readText()
            }))
        }
        // 编译,详见下方 compile 方法
        compile(sourceFile)
    }

    private fun compile(sourceFile: MutableList<SourceFile>) {
        val result = KotlinCompilation().apply {
            sources = sourceFile
            supportsK2 = true
            // 注册我们写好的 kcp
            compilerPluginRegistrars = listOf(TestPluginRegistrar())
            inheritClassPath = true
            // 注册我们写好的 command line processor
            commandLineProcessors += TestCommandLineProcessor()
            // 添加我们的参数
            pluginOptions += listOf(PluginOption("your_plugin_id", "target","jvm"), PluginOption("your_plugin_id", "debug","true"))
            messageOutputStream = System.out
            // 指定最终产物目录
            workingDir = File("build", "kcp")
        }.compile()
    }

}

FAQ

log 无法打印的问题

在写 kcp 的时候,发现使用 println 打印不出日志,还不太清楚原因

总之,目前可以通过 MessageCollector 打印

// CompilerRegistrar 中,可以获取 messageCollector
val messageCollector = configuration.get(CommonConfigurationKeys.MESSAGE_COLLECTOR_KEY, MessageCollector.NONE)
        MSG_COLLECTOR = messageCollector


// 随便封装一个 log 方法
const val TAG = "TestPlugin: "
fun logInfo(msg: String) {
    MSG_COLLECTOR.report(CompilerMessageSeverity.INFO, TAG + msg)
}

fir 问题

如果,你的 CompilerRegistrar 中不仅注册了 irExtension 还注册了 firextension

那么 fir extension 肯定是优先执行的(因为 kotlin 文件编译流程为: .kt -> fir -> ir -> backend)

当你,强行修改一些 fir 的东西时,后续的 backend ir 阶段可能会报错,需要自己反查