前言
之前简单介绍了Kotlin
编译器的主要结构以及K2
编译器是什么,在此基础上,我们一起来看下如何开发第一个Kotlin
编译器插件(即KCP
),本文主要包括以下内容:
-
KCP
是什么?为什么使用KCP
? -
KCP
实战示例 -
KCP
接入与测试
KCP
是什么?为什么使用KCP
?
KCP
是什么?
Kotlin
的编译过程,简单来说就是将Kotlin
源代码编译成目标产物的过程,具体步骤如下图所示:
KCP
即Kotlin编译器插件,KCP
在编译过程中提供了Hook
时机,让我们可以在编译过程中插入自己的逻辑,以达到修改编译产物的目的。比如我们可以通过IrGenerationExtension
来修改IR
的生成,可以通过ClassBuilderInterceptorExtension
修改字节码生成逻辑
为什么使用KCP
?
在简单了解了KCP
是什么之后,你可能会问,那么KCP
与KSP
的区别是什么?我们为什么要使用KCP
呢?
KCP
的主要优势在于它的功能强大,KSP
只能生成代码,不能修改已有的代码,而KCP
不仅可以生成代码,也可以通过修改IR
,或者修改字节码等方式修改已有的代码逻辑
比如大家常用的kae
插件就是一个Kotlin
编译器插件,接入kae
插件后,我们通过控件的id
就可以获取对应的View
,其实控件的id
在编译后会被自动转化成findCacheViewById
方法,这是KSP
或者其他注解处理器工具所不能实现的
还有在Compose
中,给方法添加一个@Compose
注解就可以将普通函数转化为Compose
函数,这也是通过KCP
实现的
KCP
同时具有优秀的IDE
支持,比如kae
可以直接从id
跳转到布局,这是其它工具所不能实现的。比如ASM
同样可以修改字节码将id
转化成findCacheViewById
方法,却无法让IDE
支持
总得来说,KCP
的主要有以下优势
- 功能强大:不仅可以生成代码也可以修改已有的代码逻辑
- 优秀的
IDE
支持:Kotlin
毕竟是Jetbrians
的亲儿子
为什么不使用KCP
?
-
KCP
目前还没有稳定的公开API
,需要等K2
编译器正式发布后才会提供 - 开发成本较高,如下图所示,一个
KCP
插件通常包括Gradle
插件,编译器插件,IDE
插件(如果需要代码提示的话)三部分组成
总得来说,KCP
的优势在于功能强大,缺点则在于目前还没有稳定API
,以及开发成本较高,各位可根据情况选择是否使用
KCP
实战示例
接下来我们就一起来看看怎么一步步实现一个编译器插件,首先来看下目标
技术目标
@DebugLog
private fun simpleClick() {
Thread.sleep(2000)
}
我们要实现的目标很简单,就是给所有添加了@DebugLog
注解的方法,在方法执行前后打印一行日志,即编译后变成以下代码
private fun simpleClick() {
DebugLogHelper.startMethod("simpleClick")
Thread.sleep(2000)
DebugLogHelper.stopMethod("simpleClick")
}
代码其实很简单,用ASM
字节码插桩也可以实现同样的效果,我们在这里用KCP
实现
KCP
总体结构
如果我们的插件不需要代码提示的话,通常由两部分组成,即Gradle
插件与编译器插件,如下图所示:
-
Plugin
:Gradle
插件用来读取Gradle
配置传递给KCP
-
Subplugin
:为KCP
提供自定义KP
的maven
库地址等配置信息 -
CommandLineProcessor
:负责将Gradle Plugin
传过来的参数转换并校验 -
ComponentRegistrar
:负责将用户自定义的各种Extension
注册到KP中,并在合适时机调用 -
Extension
: 编译器提供的hook
时机,可在编译过程中插入自定义的逻辑
在了解了总体结构后,我们接下来就一步一步地实现一个KCP
插件
Gradle
插件部分
Gradle
插件部分之前分为Plugin
与Subplugin
两部分,现在在新版本中已经统一为KotlinCompilerPluginSupportPlugin
,代码如下所示:
class DebugLogGradlePlugin : KotlinCompilerPluginSupportPlugin {
// 1\. 读取Gradle扩展配置信息
override fun apply(target: Project): Unit = with(target) {
extensions.create("debugLog", DebugLogGradleExtension::class.java)
}
// 2\. 定义编译器插件的唯一`id`,需要与后面编译器插件中定义的`pluginId`保持一致
override fun getCompilerPluginId(): String = BuildConfig.KOTLIN_PLUGIN_ID
// 3\. 定义编译器插件的 `Maven` 坐标信息,便于编译器下载它
override fun getPluginArtifact(): SubpluginArtifact = SubpluginArtifact(
groupId = BuildConfig.KOTLIN_PLUGIN_GROUP,
artifactId = BuildConfig.KOTLIN_PLUGIN_NAME,
version = BuildConfig.KOTLIN_PLUGIN_VERSION
)
override fun applyToCompilation(
kotlinCompilation: KotlinCompilation<*>
): Provider<List<SubpluginOption>> {
// 4\. 将extension的配置写入`SubpluginOptions`,后续供kcp读取
val annotationOptions = extension.annotations.map { SubpluginOption(key = "debugLogAnnotation", value = it) }
val enabledOption = SubpluginOption(key = "enabled", value = extension.enabled.toString())
return project.provider {
annotationOptions + enabledOption
}
}
}
可以看出KotlinCompilerPluginSupportPlugin
的主要有以下作用:
- 添加
Gradle
入口 - 读取
Gradle
扩展配置信息 - 定义
KCP
插件id
与maven
坐标 - 将
Gradle
的扩展配置信息传递给KCP
自定义CommandLinProcessor
在定义了Gradle
插件之后,接下来就是编译器插件,编译器插件的入口是CommandLineProcessor
@AutoService(CommandLineProcessor::class)
class DebugLogCommandLineProcessor : CommandLineProcessor {
// 1\. 配置 Kotlin 插件唯一 ID
override val pluginId: String = BuildConfig.KOTLIN_PLUGIN_ID
// 2\. 读取 `SubpluginOptions` 参数,并写入 `CliOption`
override val pluginOptions: Collection<CliOption> = listOf(
CliOption(
optionName = OPTION_ENABLE, valueDescription = "<true|false>",
description = "whether to enable the debuglog plugin or not"
)
)
// 3\. 处理 `CliOption` 写入 `CompilerConfiguration`
override fun processOption(
option: AbstractCliOption,
value: String,
configuration: CompilerConfiguration
) {
return when (option.optionName) {
OPTION_ENABLE -> configuration.put(ARG_ENABLE, value.toBoolean())
OPTION_ANNOTATION -> configuration.appendList(ARG_ANNOTATION, value)
else -> throw IllegalArgumentException("Unexpected config option ${option.optionName}")
}
}
}
可以看出,CommandLinProcessor
的主要作用就是定义插件ID
与读取Gradle
插件传递过来的参数,并存储在CompilerConfiguration
中
你可能会好奇,为什么这个类的名字叫CommandLineProcessor
,这应该是因为Kotlin
编译器也可以直接通过命令行调用,然后可以通过参数调用编译器插件,比如官方提供的all-open
插件可通过以下方式调用
-Xplugin=$KOTLIN_HOME/lib/allopen-compiler-plugin.jar
-P plugin:org.jetbrains.kotlin.allopen:annotation=com.my.Annotation
-P plugin:org.jetbrains.kotlin.allopen:preset=spring
CommandLinProcessor
应该最开始就是用来处理命令行的输入参数的,因此起了这样的名字
自定义ComponentRegistrar
@AutoService(ComponentRegistrar::class)
class DebugLogComponentRegistrar : ComponentRegistrar {
override fun registerProjectComponents(
project: MockProject,
configuration: CompilerConfiguration
) {
ClassBuilderInterceptorExtension.registerExtension(
project,
DebugLogClassGenerationInterceptor(
debugLogAnnotations = configuration[ARG_ANNOTATION]
)
)
}
}
自定义ComponentRegistrar
的作用就是注册各种extension
,并在编译器编译的各种时机回调,常用的extension
包括:
-
IrGenerationExtension
:在编译器生成ir
时回调,可以在这个阶段对ir
进行修改 -
ClassBuilderInterceptorExtension
:在生成字节码时回调,可以在这个阶段对字节码进行修改
我们这里使用的是ClassBuilderInterceptorExtension
自定义ClassBuilderInterceptorExtension
class DebugLogClassGenerationInterceptor(
val debugLogAnnotations: List<String>
) : ClassBuilderInterceptorExtension {
override fun interceptClassBuilderFactory(): ClassBuilderFactory = object : ClassBuilderFactory by interceptedFactory {
override fun newClassBuilder(origin: JvmDeclarationOrigin) =
DebugLogClassBuilder(debugLogAnnotations, interceptedFactory.newClassBuilder(origin))
}
}
internal class DebugLogClassBuilder() : DelegatingClassBuilder(delegateBuilder) {
override fun newMethod(): MethodVisitor {
// ...
return object : MethodVisitor(Opcodes.ASM5, original) {
override fun visitCode() {
// 进入方法时
InstructionAdapter(this).onEnterFunction(function)
}
override fun visitInsn(opcode: Int) {
when (opcode) {
// 退出方法时
Opcodes.ARETURN-> {
InstructionAdapter(this).onExitFunction(function)
}
}
}
}
}
}
// 修改字节码
private fun InstructionAdapter.onEnterFunction(function: FunctionDescriptor) {
visitLdcInsn("${function.name}")
invokestatic("com/zj/kcp_start/DebugLogHelper", "startMethod", "(Ljava/lang/String;)V", false)
}
private fun InstructionAdapter.onExitFunction(function: FunctionDescriptor) {
visitLdcInsn("${function.name}")
invokestatic("com/zj/kcp_start/DebugLogHelper", "stopMethod", "(Ljava/lang/String;)V", false)
}
可以看出,这一步主要是通过字节码在方法进入与退出时分别插入了一段代码,而且这里操作字节码的API
与ASM
基本一致,只是换了包名,在这里就不缀述ASM
的用法了
最后,到了这里,一个简单的KCP
插件也就完成了
KCP
接入与测试
在KCP
插件开发完成后,该怎么接入与测试呢?
接入的话,其实比较简单,你可以直接把插件发布,或者includeBuild
插件项目,这两种方式都可以通过plugin id
引入
# build.gradle
plugins {
id("com.zj.debuglog.kotlin-plugin") apply false
}
不过如果你的插件还在开发阶段,通过以上方式测试就有些麻烦了,我们可以使用kotlin-compile-testing库来为自定义KCP
开发单元测试
该库允许你在测试中使用自定义KCP
编译Kotlin
源代码,这使调试变得容易,如果你想执行这些源文件,也可以使用ClassLoader
加载生成的编译产物
class PluginTest {
// 1\. 定义源代码
private val main = SourceFile.kotlin(
"main.kt", """
import com.zj.kcp_start.DebugLog
fun main() {
doSomething()
}
@DebugLog
fun doSomething() {
Thread.sleep(15)
}
"""
)
@Test
fun simpleTest() {
// 2\. 传入自定义编译器插件,调用`Kotlin`编译器编译源代码
val result = compile(
sourceFile = main,
DebugLogComponentRegistrar() // 自定义KCP
)
assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode)
// 3\. 执行编译生成的MainKt文件并获取输出
val out = invokeMain(result, "MainKt").trim().split("""\r?\n+""".toRegex())
// 4\. 验证输出是否与预期一致
assert(out.size == 2)
assert(out[0] == "doSomething 方法开始执行")
assert(out[1] == "doSomething 方法执行结束")
}
}
如上就是一个简单的单测,主要做了这么几件事:
- 定义测试用例源代码
- 传入自定义编译器插件,调用
Kotlin
编译器编译源代码 - 执行编译生成的
MainKt
字节码文件并获取输出 - 获取执行代码的输出,看看是否与预期一致,比如我们这里预期方法有两个输出,在方法开始与结束时会分别打印一串字符串
通过这种方式就可以在开发KCP
阶段快速验证,及时发现问题
总结
本文主要介绍了如何一步一步地开发自定义KCP
插件,自定义编译器插件功能非常强大,当你需要做一些“黑科技”操作的时候或许会用得上。
同时Kotlin
和Compose
的源码中也大量用到了KCP
插件,了解KCP
也可以方便你看懂它们的源码,了解它们到底是怎么实现的,希望本文对你有所帮助~
示例代码
本文所有代码可见:github.com/RicardoJian…
参考资料
Writing Your Second Kotlin Compiler Plugin, Part 1 — Project Setup
KotlinConf 2018 - Writing Your First Kotlin Compiler Plugin by Kevin Most
作者:程序员江同学
链接:https://juejin.cn/post/7144873690319028255
网友评论