告別KAPT,使用KSP為Android編譯提速

xiangzhihong發表於2021-12-29

一、KSP

在進行Android應用開發時,不少人吐槽 Kotlin 的編譯速度慢,而KAPT 便是拖慢編譯的元凶之一。我們知道,Android的很多庫都會使用註解簡化模板程式碼,例如 Room、Dagger、Retrofit 等,而預設情況下Kotlin 使用的是 KAPT 來處理註解的。KAPT沒有專門的註解處理器,需要藉助APT實現的,因此需要先生成 APT 可解析的 stub (Java程式碼),這拖慢了 Kotlin 的整體編譯速度。

KSP 正是在這個背景下誕生的,它基於 Kotlin Compiler Plugin(簡稱KCP) 實現,不需要生成額外的 stub,編譯速度是 KAPT 的 2 倍以上。除了 大幅提高 Kotlin 開發者的構建速度,該工具還提供了對 Kotlin/Native 和 Kotlin/JS 的支援。

二、KSP 與 KCP

這裡提到了Kotlin Compiler Plugin ,KCP是在 kotlinc 過程中提供 Hook 時機,可以在期間解析 AST、修改位元組碼產物等,Kotlin 的不少語法糖都是 KCP 實現的。例如, data class、 @Parcelize、kotlin-android-extension 等,如今火爆的 Jetpack Compose也是藉助 KCP 完成的。

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

在這裡插入圖片描述
上圖中涉及到幾個具體的概念:

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

KSP 簡化了KCP的整個流程,開發者無需瞭解編譯器工作原理,處理註解等成本也變得像 KAPT 一樣低。

三、KSP 與 KAPT

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

在這裡插入圖片描述

可以看到,一個 Kotlin 原始檔經 KSP 解析後得到的 Kotlin AST如下所示。

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<KSValueParameter>
      // 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: KSValueParameter

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

Java/APTKotlin/KSP描述
PackageElementKSFile一個包程式元素,提供對有關包及其成員的資訊的訪問
ExecuteableElementKSFunctionDeclaration某個類或介面的方法、構造方法或初始化程式(靜態或例項),包括註釋型別元素
TypeElementKSClassDeclaration一個類或介面程式元素。提供對有關型別及其成員的資訊的訪問。注意,列舉型別是一種類,而註解型別是一種介面
VariableElementKSVariableParameter / KSPropertyDeclaration一個欄位、enum 常量、方法或構造方法引數、區域性變數或異常引數

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

四、KSP 入口SymbolProcessorProvider

KSP的入口在SymbolProcessorProvider ,程式碼如下:

interface SymbolProcessorProvider {
    fun create(environment: SymbolProcessorEnvironment): SymbolProcessor
}

SymbolProcessorEnvironment 主要用於獲取 KSP 執行時的依賴,注入到 Processor:

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 成員方法。

class HelloFunctionFinderProcessor : SymbolProcessor() {
    ...
    val functions = mutableListOf<String>()
    val visitor = FindFunctionsVisitor()

    override fun process(resolver: Resolver) {
        resolver.getAllFiles().map { it.accept(visitor, Unit) }
    }

    inner class FindFunctionsVisitor : KSVisitorVoid() {
        override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
            classDeclaration.getDeclaredFunctions().map { it.accept(this, Unit) }
        }

        override fun visitFunctionDeclaration(function: KSFunctionDeclaration, data: Unit) {
            functions.add(function)
        }

        override fun visitFile(file: KSFile, data: Unit) {
            file.declarations.map { it.accept(this, Unit) }
        }
    }
    ...
    
    class Provider : SymbolProcessorProvider {
        override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = ...
    }
}

五、快速上手

5.1 建立processor

首先,建立一個空的gradle工程。
在這裡插入圖片描述
然後,在根專案中指定Kotlin外掛的版本,以便在其他專案模組中使用,比如。

plugins {
    kotlin("jvm") version "1.6.0" apply false
}

buildscript {
    dependencies {
        classpath(kotlin("gradle-plugin", version = "1.6.0"))
    }
}

不過,為了統一專案中Kotlin的版本,可以在gradle.properties檔案中進行統一的配置。

kotlin.code.style=official
kotlinVersion=1.6.0
kspVersion=1.6.0-1.0.2

接著,新增一個用於承載處理器的模組。並在模組的build.gradle.kts檔案中新增如下腳步。

plugins {
    kotlin("jvm")
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.google.devtools.ksp:symbol-processing-api:1.6.0-1.0.2")
}

接著,我們需要實現com.google.devtools.ksp.processing.SymbolProcessor和com.google.devtools.ksp.processing.SymbolProcessorProvider。SymbolProcessorProvider的實現作為一個服務載入,以例項化實現的SymbolProcessor。使用時需要注意以下幾點:

  • 使用SymbolProcessorProvider.create()來建立一個SymbolProcessor。processor需要的依賴則可以通過SymbolProcessorProvider.create()提供的引數進行傳遞。
  • 主要邏輯應該在SymbolProcessor.process()方法中執行。
  • 使用resoler.getsymbolswithannotation()來獲得我們想要處理的內容,前提是給出註釋的完全限定名稱,比如com.example.annotation.Builder
  • KSP的一個常見用例是實現一個定製的訪問器,介面com.google.devtools.ksp.symbol.KSVisitor,用於操作符號。
  • 有關SymbolProcessorProvider和SymbolProcessor介面的使用示例,請參見示例專案中的以下檔案:src/main/kotlin/BuilderProcessor.ktsrc/main/kotlin/TestProcessor.kt
  • 編寫自己的處理器之後,在resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider中包含處理器提供商的完全限定名,將其註冊到包中。

5.2 使用processor

5.2.1 使用Kotlin DSL

再建立一個模組,包含處理器需要處理的工作。然後,在build.gradle.kts檔案中新增如下程式碼。

pluginManagement {
    repositories {
       gradlePluginPortal()
    }
}

在新模組的build.gradle中,我們主要完成以下事情:

  • 應用帶有指定版本的com.google.devtools.ksp外掛。
  • 將ksp新增到依賴項列表中

比如:

plugins {
        id("com.google.devtools.ksp") version kspVersion
        kotlin("jvm") version kotlinVersion
    }

執行./gradlew命令進行構建,可以在build/generated/source/ksp下找到生成的程式碼。下面是一個build.gradle.kts將KSP外掛應用到workload的示例。

plugins {
    id("com.google.devtools.ksp") version "1.6.0-1.0.2"
    kotlin("jvm") 
}

version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    implementation(kotlin("stdlib-jdk8"))
    implementation(project(":test-processor"))
    ksp(project(":test-processor"))
}

5.2.2 使用Groovy

在您的專案中構建。Gradle檔案新增了一個包含KSP外掛的外掛塊

plugins {
  id "com.google.devtools.ksp" version "1.5.31-1.0.0"
}

然後,在dependencies新增如下依賴。

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
    implementation project(":test-processor")
    ksp project(":test-processor")
}

SymbolProcessorEnvironment提供了processors選項,選項在gradle構建指令碼中指定。

  ksp {
    arg("option1", "value1")
    arg("option2", "value2")
    ...
  }

5.3 使用IDE生成程式碼

預設情況下,IntelliJ或其他ide是不知道生成的程式碼的,因此對這些生成符號的引用將被標記為不可解析的。為了讓IntelliJ能夠對生成的程式碼進行操作,需要新增如下配置。

build/generated/ksp/main/kotlin/
build/generated/ksp/main/java/

當然,也可以是資源目錄。

build/generated/ksp/main/resources/

在使用的時候,還需要在KSP processor模組中配置這些生成的目錄。

kotlin {
    sourceSets.main {
        kotlin.srcDir("build/generated/ksp/main/kotlin")
    }
    sourceSets.test {
        kotlin.srcDir("build/generated/ksp/test/kotlin")
    }
}

如果在Gradle外掛中使用IntelliJ IDEA和KSP,那麼上面的程式碼片段會給出以下警告:

Execution optimizations have been disabled for task ':publishPluginJar' to ensure correctness due to the following reasons:

對於這種警告,我們可以在模組中新增下面的程式碼。

plugins {
    // …
    idea
}
// …
idea {
    module {
        // Not using += due to https://github.com/gradle/gradle/issues/8749
        sourceDirs = sourceDirs + file("build/generated/ksp/main/kotlin") // or tasks["kspKotlin"].destination
        testSourceDirs = testSourceDirs + file("build/generated/ksp/test/kotlin")
        generatedSourceDirs = generatedSourceDirs + file("build/generated/ksp/main/kotlin") + file("build/generated/ksp/test/kotlin")
    }
}

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

LibraryStatusTracking issue for KSP
RoomExperimentally supported
MoshiOfficially supported
RxHttpOfficially supported
KotshiOfficially supported
LyricistOfficially supported
Lich SavedStateOfficially supported
gRPC DekoratorOfficially supported
Auto FactoryNot yet supportedLink
DaggerNot yet supportedLink
HiltNot yet supportedLink
GlideNot yet supportedLink
DeeplinkDispatchSupported via airbnb/DeepLinkDispatch#323

相關文章