好玩系列 | 拥抱Kotlin Symbol Processing(KSP),项目实战

写在最前

这一篇,我们抱着拥抱新事物的心态,尝试一些新事物。笔者在这一次历程中,对三项事物进行了尝鲜:

  • 手动迁移一个小规模的Gradle项目,由 Groovy Script 转为 Kotlin Script
  • Kotlin Symbol Processing
  • Kotlin Poet

这次的 重点是KSP ,Kotlin Poet学习成本比较低,迁移 Kotlin Script 仅仅是比较繁琐。

既然要实战,那么就需要一个实际的项目来支持,笔者选取了个人的开源项目DaVinCi,.

关注笔者动态的读者可能注意到:笔者在过年时发布过一篇文章好玩系列:拥有它,XML文件少一半--更方便的处理View背景, 在这篇文章中,我们提到了一种 取代xml背景资源文件 的方案,并且提到之后会 实现Style机制。本篇文章中,以此为目标,展开 KSP的实战过程

PS:对DaVinCi不了解并不影响本文内容的理解

如果读者对好玩系列感兴趣,建议点个关注,查看 关于好玩系列 了解笔者创作该系列的初衷。

KSP简介

在正式了解KSP之前,我们需要 复习 之前的知识:

  • 编译期处理
  • APT与KAPT
  • Transformer

因为一些特定的需求,在项目进行编译的过程中,需要增加一定的处理,例如:生成源码文件并参与编译;修改编译的产物。

基于Gradle编译任务链中的 APT机制,可以实现 Annotation Processor,常见于 代码生成SPI机制实现如AutoService ,也可以用来生成文档。

而APT仅支持Java源码,KAPT并没有 专门的注解处理器 ,所以kotlin项目使用KAPT时,需要 生成代码桩 即Java Stub 再交由APT 进行处理。

基于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")
    kotlin("jvm")
//  kotlin("kapt")
}

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机制,又将这一问题摆上桌面,笔者也将思考并尝试寻找一种 有效、有趣 的方式,来解决这一问题。如果各位读者对此有比较好的点子,非常希望能够分享一二。