告別KAPT!使用 KSP 為 Kotlin 編譯提速

語言: CN / TW / HK

theme: smartblue highlight: androidstudio


本文已參與好文召集令活動,點選檢視:後端、大前端雙賽道投稿,2萬元獎池等你挑戰!

今年初 Android 釋出了 Kotlin Symbol Processing(KSP)的首個 Alpha 版,幾個月過去,KSP 已經更新到 Beta3 了, 目前 API 已經基本穩定,相信距離穩定版釋出也不會很遠了。

為什麼使用 KSP ?

不少人吐槽 Kotlin 的編譯速度,KAPT 便是拖慢編譯的元凶之一。

很多庫都會使用註解簡化模板程式碼,例如 Room、Dagger、Retrofit 等,Kotlin 程式碼使用 KAPT 處理註解。 KAPT 本質上是基於 APT 工作的,APT 只能處理 Java 註解,因此需要先生成 APT 可解析的 stub (Java程式碼),這拖慢了 Kotlin 的整體編譯速度。

KSP 正是在這個背景下誕生的,它基於 Kotlin Compiler Plugin(簡稱KCP) 實現,不需要生成額外的 stub,編譯速度是 KAPT 的 2 倍以上

KSP 與 KCP

Kotlin Compiler Plugin 在 kotlinc 過程中提供 hook 時機,可以再次期間解析 AST、修改位元組碼產物等,Kotlin 的不少語法糖都是 KCP 實現的,例如 data class@Parcelizekotlin-android-extension 等, 如今火爆的 Compose 其編譯期工作也是藉助 KCP 完成的。

理論上 KCP 的能力是 KAPT 的超集,可以替代 KAPT 以提升編譯速度。但是 KCP 的開發成本太高,涉及 Gradle Plugin、Kotlin Plugin 等的使用,API 涉及一些編譯器知識的瞭解,一般開發者很難掌握。

一個標準 KCP 的開發涉及以下諸多內容:

  • Plugin:Gradle 外掛用來讀取 Gradle 配置傳遞給 KCP(Kotlin Plugin)
  • Subplugin:為 KCP 提供自定義 KP 的 maven 庫地址等配置資訊
  • CommandLineProcessor:將引數轉換為 KP 可識別引數
  • ComponentRegistrar:註冊 Extension 到 KCP 不同流程中
  • Extension:實現自定義的 KP 功能

KSP 簡化了上述流程,開發者無需瞭解編譯器工作原理,處理註解等成本像 KAPT 一樣低。

KSP 與 KAPT

KSP 顧名思義,在 Symbols 級別對 Kotlin 的 AST 進行處理,訪問類、類成員、函式、相關引數等型別的元素。可以類比 PSI 中的 Kotlin AST

一個 Kotlin 原始檔經 KSP 解析後的結果如下: yml KSFile packageName: KSName fileName: String annotations: List<KSAnnotation> (File annotations) declarations: List<KSDeclaration> KSClassDeclaration // class, interface, object simpleName: KSName qualifiedName: KSName containingFile: String typeParameters: KSTypeParameter parentDeclaration: KSDeclaration classKind: ClassKind primaryConstructor: KSFunctionDeclaration superTypes: List<KSTypeReference> // contains inner classes, member functions, properties, etc. declarations: List<KSDeclaration> KSFunctionDeclaration // top level function simpleName: KSName qualifiedName: KSName containingFile: String typeParameters: KSTypeParameter parentDeclaration: KSDeclaration functionKind: FunctionKind extensionReceiver: KSTypeReference? returnType: KSTypeReference parameters: List<KSVariableParameter> // contains local classes, local functions, local variables, etc. declarations: List<KSDeclaration> KSPropertyDeclaration // global variable simpleName: KSName qualifiedName: KSName containingFile: String typeParameters: KSTypeParameter parentDeclaration: KSDeclaration extensionReceiver: KSTypeReference? type: KSTypeReference getter: KSPropertyGetter returnType: KSTypeReference setter: KSPropertySetter parameter: KSVariableParameter KSEnumEntryDeclaration // same as KSClassDeclaration

這是 KSP 中的 Kotlin AST 抽象。 類似的, APT/KAPT 中有對 Java 的 AST 抽象,其中能找到一些對應關係,比如 Java 使用 Element 描述包、類、方法或者變數等, KSP 中使用 Declaration

|Java/APT| Kotlin/KSP| Description| |:--|:--|:--| |PackageElement|KSFile|表示一個包程式元素。提供對有關包及其成員的資訊的訪問| |ExecuteableElement|KSFunctionDeclaration|表示某個類或介面的方法、構造方法或初始化程式(靜態或例項),包括註釋型別元素| |TypeElement|KSClassDeclaration|表示一個類或介面程式元素。提供對有關型別及其成員的資訊的訪問。注意,列舉型別是一種類,而註解型別是一種介面| |VariableElement|KSVariableParameter / KSPropertyDeclaration|表示一個欄位、enum 常量、方法或構造方法引數、區域性變數或異常引數|

Declaration 之下還有 Type 資訊 ,比如函式的引數、返回值型別等,在 APT 中使用 TypeMirror 承載型別資訊 ,KSP 中詳細的能力由 KSType 實現。

KSP 的開發流程和 KAPT 類似: 1. 解析原始碼AST 2. 生成程式碼 3. 生成的程式碼與原始碼一起參與 Kotlin 編譯

需要注意 KSP 不能用來修改原始碼,只能用來生成新程式碼

KSP 入口:SymbolProcessorProvider

KSP 通過 SymbolProcessor 來具體執行。SymbolProcessor 需要通過一個 SymbolProcessorProvider 來建立。因此 SymbolProcessorProvider 就是 KSP 執行的入口

kotlin interface SymbolProcessorProvider { fun create(environment: SymbolProcessorEnvironment): SymbolProcessor } SymbolProcessorEnvironment 獲取一些 KSP 執行時的依賴,注入到 Processor

kotlin interface SymbolProcessor { fun process(resolver: Resolver): List<KSAnnotated> // Let's focus on this fun finish() {} fun onError() {} } process() 提供一個 Resolver , 解析 AST 上的 symbols。 Resolver 使用訪問者模式去遍歷 AST。

如下,Resolver 使用 FindFunctionsVisitor 找出當前 KSFile 中 top-level 的 function 以及 Class 成員方法:

```kotlin class HelloFunctionFinderProcessor : SymbolProcessor() { ... val functions = mutableListOf() val visitor = FindFunctionsVisitor()

override fun process(resolver: Resolver) {
    //使用 FindFunctionsVisitor 遍歷訪問 AST
    resolver.getAllFiles().map { it.accept(visitor, Unit) }
}

inner class FindFunctionsVisitor : KSVisitorVoid() {
    override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
        //訪問 Class 節點
        classDeclaration.getDeclaredFunctions().map { it.accept(this, Unit) }
    }

    override fun visitFunctionDeclaration(function: KSFunctionDeclaration, data: Unit) {
        // 訪問 function 節點
        functions.add(function)
    }

    override fun visitFile(file: KSFile, data: Unit) {
        //訪問 file
        file.declarations.map { it.accept(this, Unit) }
    }
}
...

} ```

KSP API 示例

舉幾個例子看一下 KSP 的 API 是如何工作的

訪問類中的所有成員方法

kotlin fun KSClassDeclaration.getDeclaredFunctions(): List<KSFunctionDeclaration> { return this.declarations.filterIsInstance<KSFunctionDeclaration>() }

判斷一個類或者方法是否是區域性類或區域性方法

kotlin fun KSDeclaration.isLocal(): Boolean { return this.parentDeclaration != null && this.parentDeclaration !is KSClassDeclaration }

判斷一個類成員是否對其他Declaration可見

kotlin fun KSDeclaration.isVisibleFrom(other: KSDeclaration): Boolean { return when { // locals are limited to lexical scope this.isLocal() -> this.parentDeclaration == other // file visibility or member this.isPrivate() -> { this.parentDeclaration == other.parentDeclaration || this.parentDeclaration == other || ( this.parentDeclaration == null && other.parentDeclaration == null && this.containingFile == other.containingFile ) } this.isPublic() -> true this.isInternal() && other.containingFile != null && this.containingFile != null -> true else -> false } }

獲取註解資訊

kotlin // Find out suppressed names in a file annotation: // @file:kotlin.Suppress("Example1", "Example2") fun KSFile.suppressedNames(): List<String> { val ignoredNames = mutableListOf<String>() annotations.forEach { if (it.shortName.asString() == "Suppress" && it.annotationType.resolve()?.declaration?.qualifiedName?.asString() == "kotlin.Suppress") { it.arguments.forEach { (it.value as List<String>).forEach { ignoredNames.add(it) } } } } return ignoredNames }

程式碼生成的示例

最後看一個相對完整的例子,用來替代APT的程式碼生成

kotlin @IntSummable data class Foo( val bar: Int = 234, val baz: Int = 123 )

我們希望通過KSP處理@IntSummable,生成以下程式碼 kotlin public fun Foo.sumInts(): Int { val sum = bar + baz return sum }

Dependencies

開發 KSP 需要新增依賴: ```groovy plugins { kotlin("jvm") version "1.4.32" }

repositories { mavenCentral() google() }

dependencies { implementation(kotlin("stdlib")) implementation("com.google.devtools.ksp:symbol-processing-api:1.5.10-1.0.0-beta01") } ```

IntSummableProcessorProvider

我們需要一個入口的 Provider 來構建 Processor ```kotlin import com.google.devtools.ksp.symbol.*

class IntSummableProcessorProvider : SymbolProcessorProvider {

override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
    return IntSummableProcessor(
        options = environment.options,
        codeGenerator = environment.codeGenerator,
        logger = environment.logger
    )
}

} `` 通過SymbolProcessorEnvironment可以為 Processor 注入了optionsCodeGeneratorlogger` 等所需依賴

IntSummableProcessor

```kotlin class IntSummableProcessor() : SymbolProcessor {

private lateinit var intType: KSType

override fun process(resolver: Resolver): List<KSAnnotated> {
    intType = resolver.builtIns.intType
    val symbols = resolver.getSymbolsWithAnnotation(IntSummable::class.qualifiedName!!).filterNot{ it.validate() }

    symbols.filter { it is KSClassDeclaration && it.validate() }
        .forEach { it.accept(IntSummableVisitor(), Unit) }

    return symbols.toList()
}

}
`` -builtIns.intType獲取到kotlin.IntKSType, 在後面需要使用。 -getSymbolsWithAnnotation獲取註解為IntSummable` 的 symbols 列表 - 當 symbol 是 Class 時,使用 Visitor 對其進行處理

IntSummableVisitor

Visitor 的介面一般如下,DR 代表 Visitor 的輸入和輸出, ```kotlin interface KSVisitor { fun visitNode(node: KSNode, data: D): R

fun visitAnnotated(annotated: KSAnnotated, data: D): R

// etc.

} ```

我們的需求沒有輸入輸出,所以實現KSVisitorVoid即可,本質上是一個 KSVisitor<Unit, Unit>

```kotlin inner class Visitor : KSVisitorVoid() {

override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
    val qualifiedName = classDeclaration.qualifiedName?.asString()

    //1. 合法性檢查
    if (!classDeclaration.isDataClass()) {
        logger.error(
            "@IntSummable cannot target non-data class $qualifiedName",
            classDeclaration
        )
        return
    }

    if (qualifiedName == null) {
        logger.error(
            "@IntSummable must target classes with qualified names",
            classDeclaration
        )
        return
    }

    //2. 解析Class資訊
    //...

    //3. 程式碼生成
    //...

}

private fun KSClassDeclaration.isDataClass() = modifiers.contains(Modifier.DATA)

} ```

如上,我們判斷這個Class是不是data class、其類名是否合法

解析Class資訊

接下來需要獲取 Class 中的相關資訊,用於我們的程式碼生成: ```kotlin inner class IntSummableVisitor : KSVisitorVoid() {

private lateinit var className: String
private lateinit var packageName: String
private val summables: MutableList<String> = mutableListOf()

override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
    //1. 合法性檢查
    //...

    //2. 解析Class資訊
    val qualifiedName = classDeclaration.qualifiedName?.asString()
    className = qualifiedName
    packageName = classDeclaration.packageName.asString()

    classDeclaration.getAllProperties()
        .forEach {
            it.accept(this, Unit)
        }

    if (summables.isEmpty()) {
        return
    }

    //3. 程式碼生成
    //...
}

override fun visitPropertyDeclaration(property: KSPropertyDeclaration, data: Unit) {
    if (property.type.resolve().isAssignableFrom(intType)) {
        val name = property.simpleName.asString()
        summables.add(name)
    }
}

} ```

  • 通過 KSClassDeclaration 獲取了classNamepackageName,以及 Properties 並將其存入 summables
  • visitPropertyDeclaration 中確保 Property 必須是 Int 型別,這裡用到了前面提到的 intType

程式碼生成

收集完 Class 資訊後,著手程式碼生成。 我們引入 KotlinPoet 幫助我們生成 Kotlin 程式碼 groovy dependencies { implementation("com.squareup:kotlinpoet:1.8.0") }

```kotlin override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {

//1. 合法性檢查
//...


//2. 解析Class資訊
//...


//3. 程式碼生成
if (summables.isEmpty()) {
    return
}

val fileSpec = FileSpec.builder(
    packageName = packageName,
    fileName = classDeclaration.simpleName.asString()
).apply {
    addFunction(
        FunSpec.builder("sumInts")
            .receiver(ClassName.bestGuess(className))
            .returns(Int::class)
            .addStatement("val sum = ${summables.joinToString(" + ")}")
            .addStatement("return sum")
            .build()
    )
}.build()

codeGenerator.createNewFile(
    dependencies = Dependencies(aggregating = false),
    packageName = packageName,
    fileName = classDeclaration.simpleName.asString()
).use { outputStream ->
    outputStream.writer()
        .use {
            fileSpec.writeTo(it)
        }
}

} `` - 使用 KotlinPoet 的FunSpec生成 function 程式碼 - 前面SymbolProcessorEnvironment 提供的CodeGenerator用來建立檔案,並寫入生成的FileSpec`程式碼

總結

通過 IntSummable 的例子可以看到 KSP 完全可以替代 APT/KAPT 進行註解處理,且效能更出色。

目前,已有不少使用 APT 的三方庫增加了對 KSP 的支援

|Library|Status|Tracking issue for KSP| |---|---|---| |Room|Experimentally supported| | |Moshi|Experimentally supported| | |Kotshi|Experimentally supported| | |Lyricist|Experimentally supported| | |Auto Factory|Not yet supported|Link| |Dagger|Not yet supported|Link| |Hilt|Not yet supported|Link| |Glide|Not yet supported|Link| |DeeplinkDispatch|Not yet supported|Link|

將 KAPT 替換為 KSP 也非常簡單,以 Moshi 為例

當然,也可以在專案中同時使用 KAPT 和 KSP ,他們互不影響。KSP 取代 KAPT 的趨勢越來越明顯,果你的專案也處理註解的需求,不妨試試 KSP ?

https://github.com/google/ksp