还在用kapt吗? 试试ksp吧 | 项目复盘

2022-03-06 09:39:50 浏览数 (1)

theme: smartblue

前言

大家退后,今天我要开始表演一下装逼的艺术。这次我们尝试性的使用谷歌前一阵子公布的ksp(Kotlin Symbol Processing),一款专门拿来给Kotlin项目提升注解生成速度的。

ksp出来以前,对于这种注解解释器,我们使用的都是java所提供的AbstractProcessor,我在以前的文章介绍过关于AbstractProcessor相关的内容。这次我们就来啃一啃谷歌的新东西,毕竟只要我E的够快,谁的问好都追不上我。

Kotlin Symbol Processing (KSP) is an API that you can use to develop lightweight compiler plugins. KSP provides a simplified compiler plugin API that leverages the power of Kotlin while keeping the learning curve at a minimum. Compared to KAPT, annotation processors that use KSP can run up to 2x faster.

官方对于ksp的介绍就是,这是一个轻量级替换kapt的一个方案,优点就是速度更快,参数更少更简单一点。但是天下武功唯快不破啊,编译速度提升这种事情吧,毕竟都很难。

同时ksp相比于kapt接入方式也更清凉,还有就是它本身也支持增量编译等。有兴趣的老哥可以仔细阅读下谷歌对于ksp的介绍,下面是传送门。

开始项目介绍吧

这次我们从头开始撸一个实现了ksp的compiler,至于代码就还是放在我们的路由组件内,毕竟如果从头写一个带注解的也有点不知所措,还是在历史的Demo上开发吧,下面是地址哦。

Router地址地址如下

由于官方文档中推荐我们使用gradle.kts,所以这次对项目整体进行了升级。这部分后续会更新另外一个文章,凡事还是逃不开真香定律的,这东西相比于Groovy对开发也更友善一点,毕竟kts还是强类型语言。

第一步

第一步比较简单,主要是对项目的根目录的Gradle进行配置上的修改,让我们可以顺利的获取到ksp的引用。虽然比较简单但是也比较关键,我完成kts的改造和顺利的引入ksp大概花了一个周末的时间,虽然有一部分时间我在带娃。

首先在项目的根目录下settings.gralde.kts文件下加入如下代码,划重点哦是settings.gradle.kts哦。

代码语言:javascript复制
pluginManagement {
    repositories {
        gradlePluginPortal()
        google()
        maven("https://dl.bintray.com/kotlin/kotlin-eap")
    }
}

这个会帮助我们去获取ksp plugin的依赖。同时由于ksp的依赖于kotlin plugin 1.4.30版本,所以我们要对kt插件版本也进行一次升级。

build.gradle.kts下添加如下代码即可。

代码语言:javascript复制
buildscript {
    repositories {
        jcenter()
        google()
    }
    dependencies {
        classpath(kotlin("gradle-plugin", version = "1.4.30"))
    }
}

plugins { 
    // 生命kotlin 1.4.30  插件版本 但是并不直接引入
    kotlin("jvm") version "1.4.30" apply false
}

第二步

完成了第一步的准备工作之后,接下来我们可以创建一个kspCompiler的模块。然后我们可以开启ksp编写之旅了。

先简单看下我们的目录下的build.gradle。因为我们要获取到ksp的依赖,同时kspkapt一样,都是通过SPI的机制进行加载的,所以我们本身也要引入一个ksp的注解库。

代码语言:javascript复制
import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion

plugins {
    // 这其实就是个java 的plugin
    kotlin("jvm")
    // 因为我们要用ksp生成一个META-INF 所以也需要ksp插件
    id("com.google.devtools.ksp") version "1.4.30-1.0.0-alpha04"
}


dependencies {
    implementation("org.jetbrains.kotlin:kotlin-stdlib:${getKotlinPluginVersion()}")
    implementation(project(":RouterAnnotation"))
    implementation("com.squareup:kotlinpoet:1.7.2")
  
    implementation("com.google.auto.service:auto-service-annotations:1.0-rc7")
    //一定要加这个而且必须compileOnly  否则会出问题
    compileOnly("org.jetbrains.kotlin:kotlin-compiler-embeddable:${getKotlinPluginVersion()}")
    compileOnly("dev.zacsweers.autoservice:auto-service-ksp:0.3.2")
    compileOnly("com.google.devtools.ksp:symbol-processing-api:1.4.30-1.0.0-alpha04")
    // 生成META-INF 其实你可以自定义一个META-INF文件 就不需要ksp或者apt来生成了
    ksp("dev.zacsweers.autoservice:auto-service-ksp:0.3.2")
}

上面的标识了注释的地方各位一定要注意了,因为这几个点是我踩过的坑。其中compileOnly会导致你的ksp无法被执行到,同时编译流程报错。

这边我个人的猜测是因为implementation只会对当前Module生效,无法传递到别的地方,导致编译流程内无法引用到对应的jar包导致的,只是个人猜测没有去证实这部分猜测。

这里的ksp就是负责生成的META-INF文件夹的,编译流程中其实atuoservice其实还是有很多地方被使用到的。java中的jar包并不止有.class还有META-INFO,这个文件夹会附带一些额外的信息。举个例子,koltin中的版本信息就是放在这个文件夹下面的。而我在上篇文章也说过autoservice的服务发现机制也是基于这个文件的。

所以,其实你在编写ksp的时候,其实你也可以和编写plugin一样自己生成一个META-INFO也是可以的,文件名可以参考上面的图片。

第三步

到了这里我们可以正式的开始ksp的代码编写了。首先我们要先实现一个SymbolProcessor

代码语言:javascript复制
interface SymbolProcessor {
    /**
     * Called by Kotlin Symbol Processing to initialize the processor.
     *
     * @param options passed from command line, Gradle, etc.
     * @param kotlinVersion language version of compilation environment.
     * @param codeGenerator creates managed files.
     */
    fun init(options: Map<String, String>, kotlinVersion: KotlinVersion, codeGenerator: CodeGenerator, logger: KSPLogger)

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

ksp算是kapt的升级版,所以这部分注解解释器的实现的和AbstractProcessor基本是一样的。init方法获取构造的一些关键参数以及写入文件路径等等。process则是让我们可以获取到当前的抽象语法树,之后获取到所有加了路由注解的相应的语法树,之后进行后续开发了。下面我们看下代码吧。

代码语言:javascript复制
// 判断是否处理过了
private var isload = false
override fun process(resolver: Resolver): List<KSAnnotated> {
    if (isload) {
        return emptyList()
    }
    val symbols = resolver.getSymbolsWithAnnotation(BindRouter::class.java.name)
    routerBindType = resolver.getClassDeclarationByName(
            resolver.getKSNameFromString(BindRouter::class.java.name)
    )?.asType() ?: kotlin.run {
        logger.error("JsonClass type not found on the classpath.")
        return emptyList()
    }
    symbols.asSequence().forEach {
        add(it)
    }
    // logger.error("className:${moduleName}")
    try {
        ktGenerate.generateKt()
        isload = true
    } catch (e: Exception) {
        logger.error(
                "Error preparing :"   " ${e.stackTrace.joinToString("n")}"
        )
    }
    return symbols
}

首先我们可以先从resolver中获取到我路由的直接的语法树列表,这就是我们后续需要处理的注解列表了。

小贴士 由于process中如果有类生成 所有会重新触发process。 因为语法树变更 由于路由的特性 所以这种多次的都不需要进行处理

接下来我们只要遍历循环这个symbols列表,就可以继续路由表的生成了。

代码语言:javascript复制
private fun add(type: KSAnnotated) {
        logger.check(type is KSClassDeclaration && type.origin == Origin.KOTLIN, type) {
            "@JsonClass can't be applied to $type: must be a Kotlin class"
        }

        if (type !is KSClassDeclaration) return

        ktGenerate.addStatement(type, routerBindType)
        //class type
        //      val id: Array<String> = routerAnnotation.urls()
    }

然后我从KSAnnotated中先判断当前的类型,判断当前语法树中是不是有java的注解,如果有则抛出异常。如果都满足的情况下,我们就可以进行代码记录的操作了。

代码语言:javascript复制
class KtGenerate(private var logger: KSPLogger, name: String, private val codeGenerator: CodeGenerator) {

    private val initMethod: FunSpec.Builder = FunSpec.builder("register").apply {
        addAnnotation(AnnotationSpec.builder(JvmStatic::class).build())
    }

    private val className = "RouterInit${name.replace("[^0-9a-zA-Z_] ", "")}"
    private val specBuilder = FileSpec.builder("com.kronos.router.register", className)


    var index = 0

    fun addStatement(type: KSClassDeclaration, routerBindType: KSType) {

        val routerAnnotation = type.findAnnotationWithType(routerBindType) ?: return
        var isRunnable = false

        type.getAllSuperTypes().forEach {
            if (!isRunnable) {
                isRunnable = it.toClassName().canonicalName == RUNNABLE
            }
        }
        // logger.error("classType:${isRunnable}")
        val urls = routerAnnotation.getMember<ArrayList<String>>("urls")
        if (urls.isEmpty()) {
            return
        }
        val weight: Int = try {
            routerAnnotation.getMember("weight")
        } catch (e: Exception) {
            0
        }
        val interceptors = try {
            routerAnnotation.getMember<ArrayList<ClassName>>("interceptors")
        } catch (e: Exception) {
            null
        }

        urls.forEach { url ->
            if (isRunnable) {
                callBackStatement(url, type.toClassName(), weight, interceptors)
            } else {
                normalStatement(url, type.toClassName(), weight, interceptors)
            }
            index  
        }
    }

    private val optionClassName by lazy {
        MemberName("com.kronos.router.model", "RouterOptions")
    }
    private val routerMemberName by lazy {
        MemberName("com.kronos.router", "Router")
    }

    private fun callBackStatement(url: String, callBack: ClassName, weight: Int, interceptors: ArrayList<ClassName>?) {
        val memberName = "option$index"
        initMethod.addStatement("val $memberName =  %M()", optionClassName)
        buildInterceptors(memberName, interceptors)
        initMethod.addStatement("$memberName.weight=$weight")
        initMethod.addStatement("$memberName.callback=%T()", callBack)
        initMethod.addStatement("%M.map(url=%S,options= $memberName)", routerMemberName, url)
    }

    private fun normalStatement(url: String, activity: ClassName, weight: Int, interceptors: ArrayList<ClassName>?) {
        val memberName = "option$index"
        initMethod.addStatement("val $memberName =  %M()", optionClassName)
        buildInterceptors(memberName, interceptors)
        initMethod.addStatement("$memberName.weight=$weight")
        initMethod.addStatement("%M.map(url=%S,mClass=%T::class.java,options= $memberName)",
                routerMemberName, url, activity)
    }


    private fun buildInterceptors(memberName: String, interceptors: ArrayList<ClassName>?) {
        interceptors?.forEach {
            initMethod.addStatement("$memberName.addInterceptor(%T())", it)
        }
    }

    fun generateKt() {
        val helloWorld = TypeSpec.objectBuilder(className)
                .addFunction(initMethod.build())
                .build()
        specBuilder.addType(helloWorld)
        val spec = specBuilder.build()
        val file = codeGenerator.createNewFile(Dependencies.ALL_FILES, spec.packageName, spec.name)
        file.use {
            val content = spec.toString().toByteArray()
            it.write(content)
        }
    }


    companion object {
        const val RUNNABLE = "com.kronos.router.RouterCallback"
    }

}

这部分就比较简单了,先判断类型是Activity还是RouterCallbac,然后根据不同的类型,插入不同的注册代码,只是相对于以前来说,这次我选择了KotlinPoet

最后,在完成循环之后只要完成generateKt就可以完成kt类的生成了。但是各位老铁请一定注意我上面说的小tips。

如何接入

上面我们就基本完成了ksp的解释器的开发了,那么就先看下如何在项目中使用吧。

代码语言:javascript复制
import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion
plugins {
    id("com.android.application")
    id("com.google.devtools.ksp") version "1.4.30-1.0.0-alpha04"
}
dependencies {
  ksp(project(":kspCompiler"))
}

简单的说就是只要加上一个和kapt类似的,这里顺便给各位不知道的科普个小知识。如果使用了这种ksp相关的generate的技术的话,可以在下图的目录中去查看是否有类生成。

耗时比较

我对其中一个module同时打开了ksp以及kapt,进行了数据测试。发现ksp的总耗时要比kapt要少。

kspDebugKotlin task的任务耗时是1262ms,而kapt因为被拆散成了两个任务,所以总耗时是1603ms。总体上来说,和谷歌说的25%的整体提速的预期还是符合的。

总结

希望这篇文章能帮助到各位,其实如果对编译速度要求比较高的项目,或者类似字节这种大厂。对于编译速度有要求,同时对kapt当前的表现贼不满意的,就可以尝试下先行开发下kspCompiler了,因为这部分都是类生成,所以ksp和kapt可以同时存在于项目中,进行灰度实验测试编译速度哦。

0 人点赞