KSP - 元编程编译提速的小助手

3,830 阅读6分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

apt&kapt&ksp

在接触元编程的时候,apt估计是很多开发者最先接触的,APT即为Annotation Processing Tool,它是javac的一个工具。如其字面意思,APT可以用来在编译时扫描和处理注解,注解处理器。在Arouter或者dagger等开源框架中普遍使用,当我们想要生成某些模版代码的时候,就可以通过apt去生成。

同样的,当kotlin语言逐渐风靡全世界后,同时也伴随着元编程的需求,因此kapt登场,它借助了apt,实现了处理kotlin注解的能力与生成代码的能力,但是随着时间的推移,我们发现kapt在编译生成代码的效率往往没有apt高,原因是kapt经过了以下几个阶段

image.png 可以看到,kotlin源码会被编译成一个叫javastubs的,从而被纳入虚拟机世界,因此多了这么一个步骤,往往在kotlin与java混编的环境下造成明显的编译瓶颈,此时,针对kotlin符号的编译插件出来了,它就是kcp(Kotlin Compiler Plugin)

image.png 它在kotlinc的编译阶段,提供了各种方法给开发者去修改kotlin符号。但是由于其难度的复杂,kcp很长一段时间处于beta阶段,同时由于直接涉及了编译过程,其使用的复杂度也随之上升,而我们聪明的工程师,在kcp的基础上进行了更加能力的聚合,对kotlin符号的处理进一步进行了封装,它就是我们今天要讲的主角ksp,ksp在代码生成上,进行了更多的步骤精简,如图 image.png

因此ksp在编译速度上得到了较大的提升(20%以上),因此我们下面来认识一下ksp的同时,也尝试用ksp生成一些我们自己的代码

ksp

一个ksp插件一般由以下部分组成SymbolProcessorProvider,SymbolProcessor,自定义处理逻辑

SymbolProcessorProvider

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

SymbolProcessorProvider是环境的提供者,主要为我们符号处理器SymbolProcessor提供了必要的环境(SymbolProcessorEnvironment),其中SymbolProcessorEnvironment里面有我们需要编译时用到的各种内容

class SymbolProcessorEnvironment(
    val options: Map<String, String>,

    val kotlinVersion: KotlinVersion,

    val codeGenerator: CodeGenerator,

    val logger: KSPLogger,

    val apiVersion: KotlinVersion,

    val compilerVersion: KotlinVersion,

    val platforms: List<PlatformInfo>,
) {
    // For compatibility with KSP 1.0.2 and earlier
    constructor(
        options: Map<String, String>,
        kotlinVersion: KotlinVersion,
        codeGenerator: CodeGenerator,
        logger: KSPLogger
    ) : this(
        options,
        kotlinVersion,
        codeGenerator,
        logger,
        kotlinVersion,
        kotlinVersion,
        emptyList()
    )
}

比如codeGenerator,用于用于生成与管理文件。logger提供了编译时打印log的入口等等。在使用时,我们需要继承SymbolProcessorProvider

class MySymbolProcessorProvider: SymbolProcessorProvider {
    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
         return MyProcessor(environment.codeGenerator, environment.logger)
    }
}

同时SymbolProcessorProvider是基于spi服务机制的,编写过spi机制的小伙伴可能比较熟悉,我们需要在resource/META-INF/services下定义一个com.google.devtools.ksp.processing.SymbolProcessorProvider文件 (spi机制中暴露的接口) image.png 内容就是我们实现接口的类,本例子是(MySymbolProcessorProvider)

image.png

SymbolProcessor

SymbolProcessor就是我们真正处理“符号”的地方

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

它是一个接口,关键的是process这个方法,这个方法提供了一个Resolver类型的参数,Resolver里面提供了非常多符号的处理方法,比如我们常用的getSymbolsWithAnnotation(获取带有某个注解的符号),getClassDeclarationByName(获取指定名称的class符号)等等,这里就不一一举例。

我们以一个例子出发,假设我们要找到某个注解修饰的方法符号

annotation class TestFind()
class MyProcessor(
    private val codeGenerator: CodeGenerator,
    private val logger: KSPLogger
) : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> {

        val mySymbol = resolver.getSymbolsWithAnnotation(TestFind::class.qualifiedName!!)

        val ret = mySymbol.filter { !it.validate() }.toList()
        val list = mySymbol.filter {
            it is KSFunctionDeclaration
        }.map {
            it as KSFunctionDeclaration
        }.toList()

        logger.warn("list is ${list.toList()}")
        交给自定义逻辑处理
        MyFuncHandler.generate(codeGenerator,logger,list)
        return ret
    }
}

值得注意的是,我们有一个返回值,这里返回的是一个list集合,代表着那些不需要被修改的符号,因为我们注解修饰的符号不一定都有效

Returns: 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.

这里我们通过logger.warn("list is ${list.toList()}")打印一下所有的符号list(可在build控制台看到输出,如果直接用print则看不到)。

可能看到这里小伙伴有点懵逼,这里我们采用了filter函数去找到KSFunctionDeclaration类型的list,那么KSFunctionDeclaration是个啥,其实这个类就是代表了函数符号的声明,相对应的,还有类符号声明,属性符号声明等等,其都继承于KSDeclaration

image.png

自定义处理逻辑

通过上面的步骤,我们能拿到相关的符号对应的类了,比如本例子中,我们拿到了所以用@TestFind注解修饰的方法,分别是fun1,fun2


class TestClass {
    @TestFind
    fun fun1(){

    }
    @TestFind
    fun fun2(){

    }
}

我们这里利用注解生成一个简单的逻辑吧,就是用 @TestFind修饰的函数,我们都生成一个类,里面有一个方法可以打印我们函数所在的文件位置(比如我们需要检测某些函数有没有跨模块用的时候,其实就需要看这个函数的文件位置),生成kotlin文件的,一部我们都是采用KotlinPoet去生成,提高我们手动生成代码文件的容错性(不了解kotlinpoet的同学可以点这里,使用手册

object MyFuncHandler {
    var isInit = false
    @OptIn(KotlinPoetKspPreview::class)
    fun generate(
        codeGenerator: CodeGenerator,
        logger: KSPLogger,
        list: List<KSFunctionDeclaration>
    ) {
        if (isInit){
            return
        }
        isInit = true
        生成一个文件
        val file = FileSpec.builder("com.test.find.location", "MyFindLocation")
        val classBuilder = TypeSpec.classBuilder("FuncLocation")
        val fun2 = FunSpec.builder("myFunc")

        获取所有被注解修饰的函数的地址
        list.forEach {

            logger.warn("parent is " + it.parent.toString())
            val location = it.location
            if (location is FileLocation){
                注意这里有点区别哦,我们打印字符串用的是%S
                fun2.addStatement("Log.e(%S,%S)", "hello", location.filePath)
            }

        }
        classBuilder.addFunction(fun2.build())
        因为用到了Log,所以需要导入包,导包的写法如下
        file.addImport("android.util","Log")
        file.addType(classBuilder.build())
        codeGenerator写入文件,生成文件在build/ksp下
        file.build().writeTo(codeGenerator, false)

    }
}

值得注意的是,我们process方法可能会执行多次,所以这里加了一个标记位isInit,用于判断,避免生成多个文件导致冲突,最终生成文件如下:

image.png 生成的因为包含了个人信息,所以马赛克打上,生成的信息规则如下

生成的是全路径名称
/Users/xxx/包名/TestClass

总结

到这里我们应该对ksp有了一个初步的了解,只要apt能做到的,ksp同样也能做到,在kotlin占领主导的android开发世界中,生成kotlin文件的ksp,还不快学起来!!同时对于库开发者来说,迁移kapt到ksp也越来越主流了,相信大家都能把ksp用起来!