開發你的第一個 Kotlin 編譯器外掛
theme: smartblue highlight: a11y-dark
⚠️本文為稀土掘金技術社群首發簽約文章,14天內禁止轉載,14天后未獲授權禁止轉載,侵權必究!
前言
之前簡單介紹了Kotlin
編譯器的主要結構以及K2
編譯器是什麼,在此基礎上,我們一起來看下如何開發第一個Kotlin
編譯器外掛(即KCP
)。
其實Kotlin
編譯器外掛也不是特別新的東西了,早在KotlinConf 2018
上就有人做了相關的分享,本文主要內容也是學習這個分享的輸出,感興趣的同學可以直接去看視訊,連結見文末。
本文主要包括以下內容:
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
實戰示例
接下來我們就一起來看看怎麼一步步實現一個編譯器外掛,首先來看下目標
技術目標
kotlin
@DebugLog
private fun simpleClick() {
Thread.sleep(2000)
}
我們要實現的目標很簡單,就是給所有添加了@DebugLog
註解的方法,在方法執行前後列印一行日誌,即編譯後變成以下程式碼
kotlin
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
,程式碼如下所示:
```kotlin
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> {
// 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
```kotlin @AutoService(CommandLineProcessor::class) class DebugLogCommandLineProcessor : CommandLineProcessor { // 1. 配置 Kotlin 外掛唯一 ID override val pluginId: String = BuildConfig.KOTLIN_PLUGIN_ID
// 2. 讀取 SubpluginOptions
引數,並寫入 CliOption
override val pluginOptions: Collection
// 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
```kotlin @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
```kotlin
class DebugLogClassGenerationInterceptor(
val debugLogAnnotations: List
}
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
載入生成的編譯產物
```kotlin 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 方法執行結束")
}
} ```
如上就是一個簡單的單測,主要做了這麼幾件事:
1. 定義測試用例原始碼
2. 傳入自定義編譯器外掛,呼叫Kotlin
編譯器編譯原始碼
3. 執行編譯生成的MainKt
位元組碼檔案並獲取輸出
4. 獲取執行程式碼的輸出,看看是否與預期一致,比如我們這裡預期方法有兩個輸出,在方法開始與結束時會分別列印一串字串
通過這種方式就可以在開發KCP
階段快速驗證,及時發現問題
總結
本文主要介紹瞭如何一步一步地開發自定義KCP
外掛,自定義編譯器外掛功能非常強大,當你需要做一些“黑科技”操作的時候或許會用得上。
同時Kotlin
和Compose
的原始碼中也大量用到了KCP
外掛,瞭解KCP
也可以方便你看懂它們的原始碼,瞭解它們到底是怎麼實現的,希望本文對你有所幫助~
示例程式碼
本文所有程式碼可見:https://github.com/RicardoJiang/KCP-Start
參考資料
Writing Your Second Kotlin Compiler Plugin, Part 1 — Project Setup
KotlinConf 2018 - Writing Your First Kotlin Compiler Plugin by Kevin Most
- kotlin-android-extensions 外掛到底是怎麼實現的?
- 江同學的 2022 年終總結,請查收~
- kotlin-android-extensions 外掛將被正式移除,如何無縫遷移?
- 學習一下 nowinandroid 的構建指令碼
- Kotlin 預設可見性為 public,是不是一個好的設計?
- 2022年編譯加速的8個實用技巧
- 落地 Kotlin 程式碼規範,DeteKt 瞭解一下~
- Gradle 進階(二):如何優化 Task 的效能?
- 開發一個支援跨平臺的 Kotlin 編譯器外掛
- 開發你的第一個 Kotlin 編譯器外掛
- Kotlin 增量編譯是怎麼實現的?
- Gradle 都做了哪些快取?
- K2 編譯器是什麼?世界第二高峰又是哪座?
- Android 效能優化之 R 檔案優化詳解
- Kotlin 快速編譯背後的黑科技,瞭解一下~
- 別了 KAPT , 使用 KSP 快速實現 ButterKnife
- Android Apk 編譯打包流程,瞭解一下~
- 如何優雅地擴充套件 AGP 外掛
- ASM 插樁採集方法入參,出參及耗時資訊
- Transform 被廢棄,ASM 如何適配?