擁抱Kotlin Symbol Processing(KSP),手把手帶你實現Kotlin的專有註解處理

好玩系列 | 擁抱Kotlin Symbol Processing(KSP),項目實戰

寫在最前

這一篇,咱們抱着擁抱新事物的心態,嘗試一些新事物。筆者在這一次歷程中,對三項事物進行了嚐鮮:html

  • 手動遷移一個小規模的Gradle項目,由 Groovy Script 轉爲 Kotlin Script
  • Kotlin Symbol Processing
  • Kotlin Poet

此次的 重點是KSP ,Kotlin Poet學習成本比較低,遷移 Kotlin Script 僅僅是比較繁瑣。java

既然要實戰,那麼就須要一個實際的項目來支持,筆者選取了我的的開源項目DaVinCi,.android

關注筆者動態的讀者可能注意到:筆者在過年時發佈過一篇文章好玩系列:擁有它,XML文件少一半--更方便的處理View背景, 在這篇文章中,咱們提到了一種 取代xml背景資源文件 的方案,而且提到以後會 實現Style機制。本篇文章中,以此爲目標,展開 KSP的實戰過程git

PS:對DaVinCi不瞭解並不影響本文內容的理解github

若是讀者對好玩系列感興趣,建議點個關注,查看 關於好玩系列 瞭解筆者創做該系列的初衷。express

KSP簡介

在正式瞭解KSP以前,咱們須要 複習 以前的知識:api

  • 編譯期處理
  • APT與KAPT
  • Transformer

由於一些特定的需求,在項目進行編譯的過程當中,須要增長必定的處理,例如:生成源碼文件並參與編譯;修改編譯的產物。markdown

基於Gradle編譯任務鏈中的 APT機制,能夠實現 Annotation Processor,常見於 代碼生成SPI機制實現如AutoService ,也能夠用來生成文檔。架構

而APT僅支持Java源碼,KAPT並無 專門的註解處理器 ,因此kotlin項目使用KAPT時,須要 生成代碼樁 即Java Stub 再交由APT 進行處理。app

基於Gradle編譯任務鏈中的 Transformer機制,能夠動態的修改編譯結果,例如利用 JavasistASM 等字節碼操縱框架 增長、修改字節碼中的業務邏輯。

這致使Kotlin項目想要針對註解進行處理時,要麼用力過猛,採用Transformer機制,要麼就使用KAPT並犧牲時間。Transformer機制並沒有時間優點,若KAPT能夠等價處理時, Transformer機制每每呈現力大磚飛之勢

那麼瓜熟蒂落,KSP用於解決純Kotlin項目下,無專門註解處理器的問題。

在KSP以前,Kotlin的編譯存在有 Kotlin Compiler Plugin 瞭解更多 ,下文簡稱KCP,KCP用於解決Kotlin 的關鍵詞和註解的編譯問題,例如 data class

而KCP的功能太過於強大,以致於須要 很大的學習成本 ,而將問題侷限於 註解處理 時,這一學習成本是多餘的,因而出現了KSP,它基於KCP,但 屏蔽了KCP的細節 , 讓咱們 專一於註解處理的業務

KCP開發的若干過程

KCP的複雜程度從其架構可見一斑

KSP-Alpha版本已於2月發佈

正式開始以前

在正式開始以前,咱們再簡要的闡明一下實戰的目標:DaVinCi中能夠定義 Style 和 StyleFactory:

推薦使用 StyleRegistry.Style.Factory,而不要直接定義 StyleRegistry.Style

@DaVinCiStyle(styleName = "btn_style.main")
class DemoStyle : StyleRegistry.Style("btn_style.main") {
    init {
        this.register(
            state = State.STATE_ENABLE_FALSE,
            expression = DaVinCiExpression.shape().rectAngle().solid("#80ff3c08").corner("10dp")
        ).register(
            state = State.STATE_ENABLE_TRUE,
            expression = DaVinCiExpression.shape().rectAngle().corner("10dp")
                .gradient("#ff3c08", "#ff653c", 0)
        )
    }
}

@DaVinCiStyleFactory(styleName = "btn_style.main")
class DemoStyleFactory : StyleRegistry.Style.Factory() {
    override val styleName: String = "btn_style.main"

    override fun apply(style: StyleRegistry.Style) {
        style.register(
            state = State.STATE_ENABLE_FALSE,
            expression = DaVinCiExpression.shape().rectAngle().solid("#80ff3c08").corner("10dp")
        ).register(
            state = State.STATE_ENABLE_TRUE,
            expression = DaVinCiExpression.shape().rectAngle().corner("10dp")
                .gradient("#ff3c08", "#ff653c", 0)
        )
    }
}
複製代碼

並利用

osp.leobert.android.davinci.StyleRegistry#register(style: Style)
osp.leobert.android.davinci.StyleRegistry#registerFactory(factory: Style.Factory)
複製代碼

進行全局註冊

咱們而且指望將 StyleName 生成常量,且分別檢查Style和StyleFactory是否有重複。

那麼咱們指望生成如下內容:

/** * auto-generated by DaVinCi, do not modify */
public object AppDaVinCiStyles {
  public const val btn_style_main: String = "btn_style.main"

  /** * register all styles and styleFactories */
  public fun register(): Unit {
    registerStyles()
    registerStyleFactories()
  }

  private fun registerStyles(): Unit {
    osp.leobert.android.davinci.StyleRegistry.register(com.example.simpletest.factories.DemoStyle())
  }

  private fun registerStyleFactories(): Unit {
    osp.leobert.android.davinci.StyleRegistry.registerFactory(com.example.simpletest.factories.DemoStyleFactory())
  }
}
複製代碼

正式開始

定義註解

@Target(AnnotationTarget.CLASS)
public annotation class DaVinCiStyle(val styleName: String)

@Target(AnnotationTarget.CLASS)
public annotation class DaVinCiStyleFactory(val styleName: String)
複製代碼

顯然,須要單獨創建Module,這並不複雜。

引入 com.google.devtools.ksp 插件

//project build.gradle.kts
plugins {
    id("com.google.devtools.ksp") version Dependencies.Kotlin.Ksp.version apply false
    kotlin("jvm") version Dependencies.Kotlin.version apply false
    id("org.jetbrains.dokka") version Dependencies.Kotlin.dokkaVersion  apply false
    id("com.vanniktech.maven.publish") version "0.15.1" apply false
}
複製代碼
//Module build.gradle.kts
plugins {
    id("com.google.devtools.ksp") //使用了ksp版本的autoservice,故而此處也須要使用ksp
    kotlin("jvm")
}

dependencies {
    compileOnly(Dependencies.Kotlin.Ksp.api)

    implementation(Dependencies.AutoService.annotations)
    ksp("dev.zacsweers.autoservice:auto-service-ksp:0.5.2")
    implementation(Dependencies.KotlinPoet.kotlinPoet)
    implementation(Dependencies.guava)
    

// todo use stable version when release
    implementation(project(":annotation"))
    //略
}
複製代碼

引入必要的依賴,這裏咱們使用ksp實現的auto-service實現SPI,具體可參考DaVinCi項目源碼,此處再也不贅述

實現 SymbolProcessorProvider 處理註解

必要的知識

核心很是簡要,實現SymbolProcessorProvider接口 ,提供一個 SymbolProcessor 接口的實例

package com.google.devtools.ksp.processing

/** * [SymbolProcessorProvider] is the interface used by plugins to integrate into Kotlin Symbol Processing. */
interface SymbolProcessorProvider {
    /** * Called by Kotlin Symbol Processing to create the processor. */
    fun create(environment: SymbolProcessorEnvironment): SymbolProcessor
}

複製代碼

處理註解的入口:

package com.google.devtools.ksp.processing

import com.google.devtools.ksp.symbol.KSAnnotated

/** * [SymbolProcessor] is the interface used by plugins to integrate into Kotlin Symbol Processing. * SymbolProcessor supports multiple round execution, a processor may return a list of deferred symbols at the end * of every round, which will be passed to proceesors again in the next round, together with the newly generated symbols. * Upon Exceptions, KSP will try to distinguish the exceptions from KSP and exceptions from processors. * Exceptions will result in a termination of processing immediately and be logged as an error in KSPLogger. * Exceptions from KSP should be reported to KSP developers for further investigation. * At the end of the round where exceptions or errors happened, all processors will invoke onError() function to do * their own error handling. */
interface SymbolProcessor {
    /** * Called by Kotlin Symbol Processing to run the processing task. * * @param resolver provides [SymbolProcessor] with access to compiler details such as Symbols. * @return A list of deferred symbols that the processor can't process. */
    fun process(resolver: Resolver): List<KSAnnotated>

    /** * Called by Kotlin Symbol Processing to finalize the processing of a compilation. */
    fun finish() {}

    /** * Called by Kotlin Symbol Processing to handle errors after a round of processing. */
    fun onError() {}
}

複製代碼

環境能夠給到的內容:

class SymbolProcessorEnvironment(
    /** * passed from command line, Gradle, etc. */
    val options: Map<String, String>,
    /** * language version of compilation environment. */
    val kotlinVersion: KotlinVersion,
    /** * creates managed files. */
    val codeGenerator: CodeGenerator,
    /** * for logging to build output. */
    val logger: KSPLogger
)
複製代碼

這裏注意,ksp還沒法像APT同樣進行debug,因此開發階段還有一些障礙,僅能 靠日誌進行排查

codeGenerator 用於生成kotlin源碼文件,注意寫入時 必須分離到子線程 ,不然KSP會進入無限等待。 options 用於手機、獲取配置參數

開始編碼

略去獲取配置參數的部分

定義目標註解的信息:

val DAVINCI_STYLE_NAME = requireNotNull(DaVinCiStyle::class.qualifiedName)
val DAVINCI_STYLE_FACTORY_NAME = requireNotNull(DaVinCiStyleFactory::class.qualifiedName)
複製代碼

利用Resolver獲得目標註解的KSName,e.g.:

resolver.getKSNameFromString(DAVINCI_STYLE_NAME)
複製代碼

並進一步獲得被註解的類

resolver.getClassDeclarationByName(
    resolver.getKSNameFromString(DAVINCI_STYLE_NAME)
)
複製代碼

此刻,代碼示例以下:

private class DaVinCiSymbolProcessor(
    environment: SymbolProcessorEnvironment,
) : SymbolProcessor {

    //忽略

    companion object {
        val DAVINCI_STYLE_NAME = requireNotNull(DaVinCiStyle::class.qualifiedName)
        val DAVINCI_STYLE_FACTORY_NAME = requireNotNull(DaVinCiStyleFactory::class.qualifiedName)
    }

    override fun process(resolver: Resolver): List<KSAnnotated> {


        val styleNotated = resolver.getClassDeclarationByName(
            resolver.getKSNameFromString(DAVINCI_STYLE_NAME)
        )?.asType(emptyList())

        val factoryNotated = resolver.getClassDeclarationByName(
            resolver.getKSNameFromString(DAVINCI_STYLE_FACTORY_NAME)
        )?.asType(emptyList())

        //暫未涉及


        return emptyList()
    }
}
複製代碼

進行必要的檢查,若是沒有任意的目標註解,按照本身的計劃進行拋錯或者其餘;

此刻咱們獲得了目標註解的類型,即 KSClassDeclaration實例

掃描被註解的目標

利用 Resolver目標註解的類型 進行掃描

而咱們的既定目標是尋找被註解的類,因此直接過濾被註解的目標爲 KSClassDeclaration,直接排除掉 Method 和 Property

factoryNotated?.let {
    handleDaVinCiStyleFactory(resolver = resolver, notationType = it)
}

private fun handleDaVinCiStyleFactory(resolver: Resolver, notationType: KSType) {
    resolver.getSymbolsWithAnnotation(DAVINCI_STYLE_FACTORY_NAME)
            .asSequence()
            .filterIsInstance<KSClassDeclaration>()
            .forEach { style ->
                //解析類上的註解信息、保存以待後續處理
                /*end @forEach*/
            }
}
複製代碼

解析註解信息

這裏和APT有一點差別,沒法直接將 KSAnnotation 轉換爲實際註解,但並不影響咱們操做,能夠判斷註解的類型、獲取註解中方法的返回值。

前面已經獲得了被註解的 KSClassDeclaration實例,能夠直接獲得對於它的註解,並經過 KSType 比對獲得目標註解,並進一步解析其 arguments, 獲得註解中的值。

val annotation =
        style.annotations.find { it.annotationType.resolve() == notationType }
                ?: run {
                    logE("@DaVinCiStyleFactory annotation not found", style)
                    return@forEach
                }

//structure: DaVinCiStyle(val styleName: String, val parent: String = "")

val styleName = annotation.arguments.find {
    it.name?.getShortName() == "styleName"
}?.value?.toString() ?: kotlin.run {
    logE("missing styleName? version not matched?", style)
    return@forEach
}

//下面是簡要的信息處理和保存
val constName = generateConstOfStyleName(styleName)
if (styleFactoryProviders.containsKey(constName)) {
    logE(
            "duplicated style name:${styleName}, original register:${styleFactoryProviders[constName]?.clzNode}",
            style
    )
    return@forEach
}

styleFactoryProviders[constName] = MetaInfo.Factory(
        constName = constName,
        styleName = styleName,
        clzNode = style
)

複製代碼

基於信息生成Kotlin源碼

參考 kotlin poet 進行學習

鑑於此部分代碼徹底與DaVinCi的業務相關,故略去。

生成源碼文件

//daVinCiStylesSpec 爲利用Kotlin Poet編寫的源碼信息
val fileSpec = FileSpec.get(packageName ?: "", daVinCiStylesSpec)

val dependencies = Dependencies(true)
thread(true) {
    codeGenerator.createNewFile(
        dependencies = dependencies,
        packageName = packageName ?: "",
        fileName = "${moduleName ?: ""}DaVinCiStyles",
        extensionName = "kt"
    ).bufferedWriter().use { writer ->
        try {
            fileSpec.writeTo(writer)
        } catch (e: Exception) {
            logE(e.message ?: "", null)
        } finally {
            writer.flush()
            writer.close()
        }
    }
}
複製代碼

再次注意,須要在子線程中執行

若對碎片化的代碼不太敏感,能夠下載DaVinCi的源碼進行對照閱讀

在項目中使用

前面咱們已經完成了KSP的核心邏輯,如今咱們須要配置並使用它

這裏注意,ksp的生成目錄不屬於默認sourceSets,須要單獨配置

plugins {
    id("com.android.application")
    id("com.google.devtools.ksp") //version Dependencies.Kotlin.Ksp.version
    //略
}

android {
    //略

    buildTypes {
        getByName("release") {
            sourceSets {
                getByName("main") {
                    java.srcDir(File("build/generated/ksp/release/kotlin"))
                }
            }
            //略
        }

        getByName("debug").apply {

            sourceSets {
                getByName("main") {
                    java.srcDir(File("build/generated/ksp/debug/kotlin"))
                }
            }
        }
    }
}

ksp {
    arg("daVinCi.verbose", "true")
    arg("daVinCi.pkg", "com.examole.simpletest")
    arg("daVinCi.module", "App")
}

dependencies {

    implementation(project(":davinci"))
    ksp(project(":anno_ksp"))
    implementation(project(":annotation"))
    //略

}

複製代碼

運行 kspXXXXKotlin 任務便可

結語

至此,KSP的實戰已告一段落,相信讀者朋友們必定產生了濃厚的興趣,並準備嘗試一番了,趕忙開始吧!


寫給對DaVinCi感興趣的讀者朋友們

繼DaVinCi開源以及相關文章發佈後,也引發了部分讀者朋友們的討論和關注,這次結合實戰KSP的機會,對DaVinCi的功能進行了升級,這裏也簡單的交代一下, DaVinCi目前已經支持:

  • GradientDrawable , StateListDrawable 的背景設置 (原有)
  • ColorStateList 文字色設置(新增)
  • 以上二者的Style定義和使用 (新增)

開發DaVinCi時,個人初衷是:"既然難以管理Style和Shape資源,那索性就用一種更加方便的方式來處理",但這件自己是違背"優秀代碼、優秀項目管理"的。

而本次爲DaVinCi添加Style機制,又將這一問題擺上桌面,筆者也將思考並嘗試尋找一種 有效、有趣 的方式,來解決這一問題。若是各位讀者對此有比較好的點子,很是但願可以分享一二。

相關文章
相關標籤/搜索