告別KAPT!使用 KSP 為 Kotlin 編譯提速
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
、 @Parcelize
、kotlin-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
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 注入了
options、
CodeGenerator、
logger` 等所需依賴
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.Int的
KSType, 在後面需要使用。
-
getSymbolsWithAnnotation獲取註解為
IntSummable` 的 symbols 列表
- 當 symbol 是 Class 時,使用 Visitor 對其進行處理
IntSummableVisitor
Visitor 的介面一般如下,D
和 R
代表 Visitor 的輸入和輸出,
```kotlin
interface KSVisitor
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
獲取了className
,packageName
,以及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
- Android Studio Electric Eel 起支援手機投屏
- Compose 為什麼可以跨平臺?
- 一看就懂!圖解 Kotlin SharedFlow 快取系統
- 深入淺出 Compose Compiler(2) 編譯器前端檢查
- 深入淺出 Compose Compiler(1) Kotlin Compiler & KCP
- Jetpack MVVM七宗罪之三:在 onViewCreated 中載入資料
- 為什麼說 Compose 的宣告式程式碼最簡潔 ?Compose/React/Flutter/SwiftUI 語法對比
- Compose 型別穩定性註解:@Stable & @Immutable
- Fragment 這些 API 已廢棄,你還在使用嗎?
- 告別KAPT!使用 KSP 為 Kotlin 編譯提速
- 探索 Jetpack Compose 核心:深入 SlotTable 系統
- 盤點 Material Design 3 帶來的新變化
- Compose 動畫邊學邊做 - 夏日彩虹
- Google I/O :Android Jetpack 最新變化(二) Performance
- Google I/O :Android Jetpack 最新變化(一) Architecture
- Google I/O :Android Jetpack 最新變化(四)Compose
- Google I/O :Android Jetpack 最新變化(三)UI
- 一文看懂 Jetpack Compose 快照系統
- 聊聊 Kotlin 代理的“缺陷”與應對
- AAB 扶正!APK 再見!