好玩系列 | 拥抱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机制
,可以动态的修改编译结果,例如利用 Javasist
,ASM
等字节码操纵框架 增加、修改字节码中的业务逻辑。
这导致Kotlin项目想要针对注解进行处理时,要么用力过猛,采用Transformer机制,要么就使用KAPT并牺牲时间。Transformer机制并无时间优势,若KAPT可以等价处理时, Transformer机制往往呈现力大砖飞之势
那么顺理成章,KSP用于解决纯Kotlin项目下,无专门注解处理器的问题。
在KSP之前,Kotlin的编译存在有 Kotlin Compiler Plugin
了解更多
,下文简称KCP,KCP用于解决Kotlin 的关键词和注解的编译问题,例如 data class
,
而KCP的功能太过于强大,以至于需要 很大的学习成本
,而将问题局限于 注解处理
时,这一学习成本是多余的,于是出现了KSP,它基于KCP,但 屏蔽了KCP的细节
,
让我们 专注于注解处理的业务
KCP的复杂程度从其架构可见一斑
正式开始之前
在正式开始之前,我们再简要的阐明一下实战的目标: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机制,又将这一问题摆上桌面,笔者也将思考并尝试寻找一种 有效、有趣
的方式,来解决这一问题。如果各位读者对此有比较好的点子,非常希望能够分享一二。