一、KSP简介

Kotlin Symbol Processing (KSP) 是一个可用于开发轻量级编译器插件的API,与Kotlin Annotation Processing Tool(KAPT)相似,但是却可以更高效地处理注解,并提供更好的性能,使用 KSP 的注释处理器的运行速度最多可达两倍,而且可以支持多平台。

主要作用是为了让我们更轻松的编写代码,KSP广泛应用于元编程、自动化代码分析与代码生成,同时进行了性能优化。当基于KSP的插件处理处理源程序时,处理器可以访问类、类成员、函数和关联参数等结构。

二、为什么选择KSP

更快!更好的适配kotlin开发者!多平台的兼容性!

编程是一种编程技术,编写出来的程序能够将其他程序作为数据来处理,他能够在编译时处理源码、中间代码,可以读取类、函数来执行某种逻辑,甚至可以在运行时读取程序自身的信息以及修改其结构。

常见的元编程技术手段有

  • Kotlin反射/Java反射

  • Kotlin注解处理器(Kotlin Annotation Processor Tool,KAPT)

  • Kotlin符号处理器(Kotlin Symbol Processing,KSP)

  • Kotlin 编译器插件(Kotlin Compiler Plugin,KCP)

对于反射,就不再详细解释了。随着kotlin的应用广泛化,为了适配JavaAPT,KAPT才应运而生,但是因为KAPT需要先转换成Java Stubs,同时在运行未经修改的Java注解处理器时候,KAPT需要将Kotlin代码编译为Java stubs,以保留Java注释处理器关注的信息,他需要解析Kotlin程序中的所有符号,Stub 生成的成本大约占1/3的kotlinc分析处理时间、且拥有跟kotlinc代码一样的生成顺序,所以对于许多注释处理器来说,这比处理器本身花费的时间要长得多。

所以说KAPT和JavaAPT实际上就是就是同一类产物, KAPT 的本质还是基于 Java 注解处理器实现的一个Kotlin 编译器插件。

例如,Glide 使用预定义的注解查看数量非常有限的类,并且其代码生成速度相当快。几乎所有的构建开销都存在于JavaStub生成阶段。如果切换到 KSP的话,就可以编译器上花费的时间减少25%。

通过下图就能看出KSP的优势

打开网易新闻 查看精彩图片
打开网易新闻 查看精彩图片

KCP是在 kotlinc 过程中提供 Hook 时机,可以在期间解析 AST、修改字节码产物等,理论上来说, KCP 的能力是 KAPT 的超集,完全可以替代 KAPT 以提升编译速度。但是 KCP 的开发成本太高,KSP 简化了KCP的整个流程,所以在项目里,我们完全可以先用KSP即可。

打开网易新闻 查看精彩图片

通过三方统计的对比图就可以看出来KSP所具有的优势:

打开网易新闻 查看精彩图片

通过引用网络上的相关测试数据(截取自叶楠-2023Kotlin中文开发者大会)可以看到编译时长的改进

打开网易新闻 查看精彩图片

三、KSP能得到什么

下面是KSP当中,经过解析后所看到的文件的格式,它包含了我们所需的各类常见内容:类、函数、属性等

有了这些想生成文件,拿到属性值就轻而易举了。

KSFile
 packageName: KSName
 fileName: String
 annotations: List (File annotations) 
 declarations: List 
 KSClassDeclaration // class, interface, object
 simpleName: KSName
 qualifiedName: KSName
 containingFile: String
 typeParameters: KSTypeParameter
 parentDeclaration: KSDeclaration
 classKind: ClassKind
 primaryConstructor: KSFunctionDeclaration
 superTypes: List 
 // contains inner classes, member functions, properties, etc.
 declarations: List 
 KSFunctionDeclaration // top level function
 simpleName: KSName
 qualifiedName: KSName
 containingFile: String
 typeParameters: KSTypeParameter
 parentDeclaration: KSDeclaration
 functionKind: FunctionKind
 extensionReceiver: KSTypeReference?
 returnType: KSTypeReference
 parameters: List 
 // contains local classes, local functions, local variables, etc.
 declarations: List 
 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

除此之外,我们可以从下图清晰看到整个KSP的导图

打开网易新闻 查看精彩图片

四、kapt迁移到ksp

对于大部分的项目,我们可以将kapt迁移到ksp。在大多数情况下,迁移只需要更改项目的build配置

迁移步骤

1、检查使用的库是否支持ksp(以下是最新官方提供的支持ksp的库)

很遗憾,Arouter并不支持

可以尝试迁移到货拉拉开源项目TheRouter,已经完美支持ksp

Hll_TheRouter

Library Status Room Officially supported Moshi Officially supported RxHttp Officially supported Kotshi Officially supported Lyricist Officially supported Lich SavedState Officially supported gRPC Dekorator Officially supported EasyAdapter Officially supported Koin Annotations Officially supported Glide Officially supported Micronaut Officially supported Epoxy Officially supported Paris Officially supported Auto Dagger Officially supported SealedX Officially supported DeeplinkDispatch Supported via airbnb/DeepLinkDispatch#323 Dagger Alpha Motif Alpha Hilt In progress Auto Factory Not yet supported
2、将KSP插件添加进项目中

注意要选择与项目的 Kotlin 版本一致的 KSP 版本,(这里demo使用kotlin版本是1.8.0,不同版本kotlin可能导致引入写法的区别)

可以在这里https://github.com/google/ksp/releases找到对应的KSP版本

//build.gradle.kts
plugins {
id( "org.jetbrains.kotlin.android" ) version "1.8.10" apply false
 id( "com.google.devtools.ksp" ) version "1.8.10-1.0.9" apply false
}

然后在对应的模块下添加

plugins {
 id( "com.google.devtools.ksp" )
}
dependencies {
 implementation( "com.google.devtools.ksp:symbol-processing-api:1.8.10-1.0.9" )
}
3、迁移支持KSP的项目依赖(举个栗子)

dependencies {
 kapt("com.google.dagger:dagger:3.5.0")
 ksp("com.google.dagger:dagger:3.5.0")
}
4、移除项目kapt相关等等

plugins {
 id("org.jetbrains.kotlin.kapt")
}
kapt {
 correctErrorTypes = true
 useBuildCache = true
}
5、可能遇到的问题

众所周知,在老的项目里,迁移过程大概率都不是一帆风顺的

部分问题:

1、在迁移过程中,首先我们的gradle需要支持gradle7.0+,这是一个大工程,如果我们要升级到最新的版本的话,还要同步升级java版本。如果你的项目里引入了热修复之类的科技,大概率也会有使用姿势的变更,这些问题在相关的github issue上一搜就是一堆(笔者祝你好运)

2、gradle的升级会面临一些插件的不兼容,比如笔者就曾遇到过项目里因为gradle tools的升级导致原本的方法失效,需要找到替代方案。比如asm的相关类:

org.objectweb.asm.tree.MethodNode org.objectweb.asm.tree.AnnotationNode org.objectweb.asm.tree.ClassNode

都已然失效。

3、另外gralde的升级,也少不了对Transform的升级改造,原本的类失效,以及未来可能不被兼容,都需要我们进行Transform->AsmClassVisitoFactory的迁移。据官方提供,AsmClassVisitoFactory会带来约18%的性能提升,同时可以减少约5倍代码,这是一个N全齐美的事情。

4、最后就是项目的三方架构,我们要统计好已支持ksp的库,并且升级到合适的版本,如果遇到不支持的,比如Arouter,欢迎使用Hll_TheRouter

当然了,之后我们还可以开启kotlin的增量编译,KSP支持增量编译,且比KAPT的增量编译更具有可配置性,下文我们会简单介绍一下ksp的增量编译,还有一个三方的适配工作,这些工作都做完后,相信我们的项目编译速度会有很大的提升。

五、使用

我们从以获取一个被注解的类的信息开始

1、创建一个ksp的module,存放我们测试ksp的代码

在module的build.gradle.kts下添加依赖,同时添加kotlinpoet,以便我们对文件进行编写

在ksp中,生成代码的方式是通过CodeGenerator 创建文件流后,进行字符串拼接,跟jsp一样都很繁琐,易出错。KotlinPoet是JavaPoet的Kotlin版本,通过生成代码,我们不用编写样板文件,同时还可以保留元数据的单一事实来源,使我们的开发更为简便,易读。

plugins {
id( "java-library" )
 id( "org.jetbrains.kotlin.jvm" )
 id( "com.google.devtools.ksp" )
}

dependencies {
 implementation("com.google.devtools.ksp:symbol-processing-api:1.9.22-1.0.16")
 implementation( "com.squareup:kotlinpoet:1.16.0" )
 implementation( "com.squareup:kotlinpoet-ksp:1.16.0" )
}

在项目的build.gradle.kts添加

plugins {
id( "org.jetbrains.kotlin.android" ) version "1.9.22" apply false
id( "com.google.devtools.ksp" ) version "1.9.22-1.0.16" apply false
}
2、实现 SymbolProcessorProvider

class DemoProcessorProvider: SymbolProcessorProvider {
 override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
 return DemoSymbolProcessor(environment)
 }
}

class DemoSymbolProcessor(
 private val environment: SymbolProcessorEnvironment
):SymbolProcessor {
 override fun process(resolver: Resolver): List { 
 //TODO 在这里处理我们的工作
 }
 }

在以下目录添加注册信息,是不是很熟悉的感觉,这跟我们使用transform编写插件的一种方式是一致的,都是用spi服务机制

打开网易新闻 查看精彩图片

在目录下将我们的provider进行注册即可

com.example.ksptest.DemoProcessorProvider

接下来我们就可以获取到注解信息了

3、定义注解和方法类

在我们的ksp module里自定义一个适用于function的注解

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.SOURCE)
annotation class TestFunAnno (
// val ids: Int = 0
)

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class TestClassAnno (

)

class DemoSymbolProcessor( private val environment: SymbolProcessorEnvironment ) : SymbolProcessor { //TODO }
Resolver:提供编译器和符号相关的各种操作 getSymbolsWithAnnotation:找到被注解标记的符号(KSFile,KSPropertyDeclaration,KSClassDeclaration,KSFunctionDeclaration等,在我们的第三部分简介已经详细的列出) 通过filter,筛选过滤得到我们想要的参数。
4、在使用的module内引入 ksp module以及ksp plugin

plugins {
 id( "com.google.devtools.ksp" )
}
//写入ksp编译的生成目录
kotlin {
sourceSets.main {
kotlin.srcDir( "build/generated/ksp/test/kotlin" )
 }
}
//引入依赖及module
dependencies {
 implementation(project(mapOf( "path" to ":kspTest" )))
 ksp(project( ":kspTest" ))
}

添加测试类

@TestClassAnno
class MyClass {
 @TestFunAnno
 fun testFun(){

 }

 @TestFunAnno
 fun testFun2(){

 }
}
5、测试获取注解信息

class DemoSymbolProcessor( private val environment: SymbolProcessorEnvironment ) : SymbolProcessor { override fun process(resolver: Resolver): List { resolver.getSymbolsWithAnnotation(TestClassAnno::class.qualifiedName!!) .asSequence() .filterIsInstance () .forEach { it -> environment.logger.warn("DemoSymbolProcessor:" + it.toString()) } return emptyList() }

当我们筛选注解TestClassAnno 使用 KSClassDeclaration 时,打印的日志是

w: [ksp] DemoSymbolProcessor:MainActivity
w: [ksp] DemoSymbolProcessor:MyClass

当我们筛选注解TestFunAnno 使用 KSFunctionDeclaration 时,打印的日志是

w: [ksp] DemoSymbolProcessor:testFun
w: [ksp] DemoSymbolProcessor:testFun2
6、创造新的文件

基于上文的TestClassAnno, 我们利用原有信息创造一个被注解的类的副本

这里就需要用到kotlinpoet来协助生成文件,文档可参考:https://github.com/square/kotlinpoet

我们可以利用TypeSpec(创造文件)、PropertySpec(创造属性)、AnnotationSpec(创造注解)、FuncSpec(创造方法)、ParameterSpec(创造函数入参)等,来创造函数

override fun process(resolver: Resolver): List { 
 val list = resolver.getSymbolsWithAnnotation(TestClassAnno::class.qualifiedName!!)
 .asSequence()
 .filterIsInstance () 
 .forEach { it ->
environment.logger.warn( "DemoSymbolProcessor:" + it.toString())
 generate(environment, it)
 }

 return emptyList()
}

fun generate(environment: SymbolProcessorEnvironment, ksClassDeclaration: KSClassDeclaration) {
 //创建类文件
 val file = FileSpec.builder( "com.test.ksp.demo" , "My" +ksClassDeclaration.toString())
 //创建属性
 val proper = PropertySpec.builder( "properties" ,String::class,KModifier.PUBLIC) .initializer( "%S" , "properties" ) .addModifiers(KModifier.PUBLIC) .build()
 //创建函数定义
 val fun2 = FunSpec.builder( "fun2" )
 .build()
 //创建类的定义
 val classes = TypeSpec.classBuilder( "My" +ksClassDeclaration.toString())
 .addModifiers(KModifier.PUBLIC)
 .addFunction(fun2)
 .addProperty(proper)
 .build()

 //将所有创造的类信息塞到新创建的文件里
 file.addType(classes)
 .build()
 .writeTo(environment.codeGenerator,Dependencies(true))
 }

注意在这个demo里面,我们传了一个emptyList,简单的demo就不做过滤了。

正常情况下,process的返回值代表的意思是一个列表(处理器无法处理的延迟符号列表。仅应返回本轮无法处理的符号。已编译代码(库)中的符号始终有效,如果在延迟列表中返回,则会被忽略)

因为我们的符号可能并不都是合法的,要将未通过筛选的符号过滤一下并传进来,保证代码的正常运行。

val symbols = resolver.getSymbolsWithAnnotation(TestClassAnno::class.qualifiedName!!) .filterIsInstance () val ret = mutableListOf () symbols.toList().forEach { if (!it.validate()) 
 //收集未通过验证的符号 ret.add(it) else{ generate(environment, it) } } return ret

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. Only symbols that can't be processed at this round should be returned. Symbols in compiled code (libraries) are always valid and are ignored if returned in the deferral list.
*/
 fun process(resolver: Resolver): List 

 /**
* 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() {}
}

值得注意的是,我们之前引入kotlinpoet同时也引入了kotlinpoet-ksp

implementation( "com.squareup:kotlinpoet:1.16.0" )
implementation( "com.squareup:kotlinpoet-ksp:1.16.0" )

FileType.writeTo具有不同的方法

这是kotlinpoet里面的:

打开网易新闻 查看精彩图片

这是kotlinpoet-ksp里面的:

打开网易新闻 查看精彩图片

所以要import:

import com.squareup.kotlinpoet.ksp.writeTo

最终可以在build文件目录下看到生成的文件

打开网易新闻 查看精彩图片
打开网易新闻 查看精彩图片

利用编译期间对注解的跟踪文件,我们可以想到butterknife,dagger,Arouter等api的基本实现逻辑,同时我们自己也可以利用ksp生成重复代码、模版代码、成产语法糖。。

六、增量编译

前文我们介绍过,ksp支持增量编译,增量处理是一种尽可能避免对源文件进行重新编译的处理技术,默认情况下,增量处理当前处于启用状态。要禁用它,可以设置 Gradle 属性ksp.incremental=false。要启用根据依赖项和输出转储脏集的日志,可以使用 ksp.incremental.log=true。可以在具有.log文件扩展名的build输出目录中找到这些日志文件。

目前AS的每次编译会删除所有旧生成的代码,并添加新的代码,KSP为了减少工作量会使用增量编译的方式只更新有关联更改的文件,我们在代码中通过设置Dependencies(aggregating = true, Files),可以设置是开启aggregating还是isolating模式,true代表不开启增量编译,默认开启。

file.addType(classes)
 .build()
 .writeTo(environment.codeGenerator,Dependencies(aggregating = true))
1、 KAPT的增量编译设置

Gradle增量注解处理器可以在resources/META-INF/gradle/incremental.annotation.processors下进行声明是属于哪一种,声明的语法如下:

注解处理器的全限定名,类别: org.gradle.IsoLationProcessor,isolating org.gradle.AggregatProcessor,aggregating

然后通过方法createResource设置进去

class Filer{
 FileObject createResource(JavaFileManager.Location location,
 CharSequence pkg,
 CharSequence relativeName,
 Element... originatingElements)
}
2、 KSP的增量编译设置

通过Dependencies和writeTo

class Dependencies private constructor(
 val isAllSources: Boolean,
 val aggregating: Boolean,
 val originatingFiles: List 
) {

 /**
* Create a [Dependencies] to associate with an output.
*
* @param aggregating 只要有改动存在,就invalidate重构.
*/
 constructor(aggregating: Boolean, vararg sources: KSFile) : this(false, aggregating, sources.toList())

 companion object {
 /**
* A short-hand to all source files.
*
* Associating an output to [ALL_SOURCES] essentially disables incremental processing, as the tiniest change will clobber all files.
* This should not be used in processors which care about processing speed.
*/
 val ALL_FILES = Dependencies(true, true, emptyList())
 }
}

public fun FileSpec.writeTo(
 codeGenerator: CodeGenerator,
 dependencies: Dependencies,
) {
 val file = codeGenerator.createNewFile(dependencies, packageName, name)
 // Don't use writeTo(file) because that tries to handle directories under the hood
OutputStreamWriter(file, StandardCharsets.UTF_8)
 .use(::writeTo)
}

writeTo的好处显而易见,对于每一个单独的生成文件都可以区分,而kapt则只能全部开启或全部不开启。

七、Java开发者参看

KSP很友好的考虑到了以前的java开发者,提供了Java annotation processing to KSP的文档参考,具体可以参看文档进行处理。下图部分截图展示

打开网易新闻 查看精彩图片

八、最后

给自己的项目引入ksp看起来是一项简单的工作,但是众所周知,没有简单的工作,只有复杂的项目,引入ksp改造自己的项目,任重道远,动起手来,为你的编译时长加把劲!!!