当前位置: 首页>后端>正文

利用 KSP 简化 Compose Navigation

利用 KSP 简化 Compose Navigation

简介

KSP(Kotlin Symbol Processing)是 Kotlin 提供的对源码进行预处理的工具。具有以下特性:

  • KSP 本身是一个编译器插件。
  • KSP 介入的时机在源码进行编译之前
  • KSP 只能新增源码不能修改源码。
  • KSP 允许重复处理,即允许上一轮的输出作为下一轮的输入。
  • KSP 支持在 Gradle 中配置参数以控制处理逻辑。

基本使用

导入

  1. 在项目级别的 build.gradle 中添加 KSP 插件
plugins {
    id 'com.google.devtools.ksp' version '1.8.10-1.0.9' apply false
    id 'org.jetbrains.kotlin.jvm' version '1.8.10' apply false
}

buildscript {
    dependencies {
        classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.21'
    }
}

2. 新增一个 Kotlin Module 作为 KSP 的承载 module

利用 KSP 简化 Compose Navigation,第1张
  1. 在步骤 2 中创建的 module 下的 build.gradle 中添加 KSP 依赖
plugins {
    id 'java-library'
    id 'org.jetbrains.kotlin.jvm'
}

java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
}

dependencies {
    implementation("com.google.devtools.ksp:symbol-processing-api:1.9.10-1.0.13")//引入ksp
}

实现具体逻辑

  1. 实现SymbolProcessor以及SymbolProcessorProvider
class MyProcessor(
    private val codeGenerator: CodeGenerator,
    private val logger: KSPLogger
) : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> {
        //主要逻辑的代码
    }
}

class MyProcessorProvider : SymbolProcessorProvider {
    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
        //基本上是固定写法
        return MyProcessor(environment.codeGenerator, environment.logger)
    }
}

  1. 在以下路径创建文件 resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider
利用 KSP 简化 Compose Navigation,第2张
  1. 在步骤 2 的文件中输入你自己的 ProcessorProvider 的 qualifiedName

在项目中使用你的 Processor

  1. 在需要应用 Processor 的 module 下的 build.gradle 中添加 KSP 插件
plugins {
    ...
    id 'com.google.devtools.ksp'
}

2. 使用关键字ksp将你的 Processor 添加到dependencies块中

dependencies {
    ...
    ksp project(':your ksp lib name')
}

3. 构建项目,如无意外你的 Processor 将会被应用

具体项目中应用

需求背景

Compose 中的 Navigation 库的使用相对繁琐,直接使用不利于代码的健壮性以及高效开发,主要有以下几点问题:

  • 所有需要路由的 Composable 页面都必须写在NavHost内,开发过程中可能会忘了手动添加,降低开发效率。
  • Destinationroute只能是字符串,存在出现传错的风险。
  • Navigation 的带参跳转使用路径拼接的方式,繁琐且容易出错,非基础对象的参数还需要特殊处理。

解决思路

  • 在需要路由的 Composeable 方法上打上一个注解,自动将这些页面导入到NavHost中。
  • 在上述方案中的注解中添加一个参数,根据该参数生成 route。
  • 舍弃路径拼接的传参方式,改为共享数据的形式传递数据,并且使用密封类来承载不同页面的数据。

由此定下最终的方案:

创建密封类Routes作为跳转的入参,不同页面需实现各自的子类。

classDiagram
    class Routes{
        <<interface>>
    }
    
    class ARoute{
        +String param1
    }
    
    class BRoute{
        +String param1
    }
    
    class CRoute{
        +String param1
    }
    
    class A["..."]
    
    Routes <|.. ARoute
    Routes <|.. BRoute
    Routes <|.. CRoute
    Routes <|.. A

创建注释UINavi作为标记,并必须传入对应页面的Routes子类的类型。

@Target(AnnotationTarget.FUNCTION) //只能标记方法
annotation class UINavi(val route: KClass<out Routes>)

由于qualifiedName具有唯一性,为了减少所需的参数,直接使用传入的 KClass 的qualifierName作为路由路径。

使用示例:

@Composable
@UINavi(ARoute::class) //使用 UINavi 注解病传入对应的 Routes 的子类
internal fun AScreenNavi(it: NavBackStackEntry) { //由于可能会用到NavBackStackEntry所以统一保留这个参数
    //页面内容代码...
}

KSP 处理的代码如下:

internal class MyProcessor(
    private val codeGenerator: CodeGenerator,
    private val logger: KSPLogger
) : SymbolProcessor {
    //由于可能会多次调用 process 方法,添加一个标志位防止重复处理
    private var isProcessed = false
    override fun process(resolver: Resolver): List<KSAnnotated> {
        //获取 @UINavi 注解的方法
        val symbols = resolver.getSymbolsWithAnnotation("com.example.demo.annotations.UINavi")
        //筛选无效的 symbols 用于返回
        val ret = symbols.filter { !it.validate() }.toList()
        //重复处理则跳过
        if (isProcessed) return ret
        val list = symbols
            //筛选有效并且是方法的 Symbols
            .filter { it is KSFunctionDeclaration && it.validate() }
            //转换为方法声明
            .map { it as KSFunctionDeclaration }

        //创建文件
        val file = FileSpec.builder(
            this::class.java.`package`.name,
            "AutoNavi"
        )

        //创建一个 NavGraphBuilder 的扩展方法,名为 autoImportNavi
        val func = FunSpec.builder("autoImportNavi")
            .receiver(ClassName("androidx.navigation", "NavGraphBuilder"))

        //创建 routeName 扩展方法
        val routeNameFile = FileSpec.builder(
            this::class.java.`package`.name,
            "RouteNameHelper"
        )
        routeNameFile.addImport("com.example.demo.core.ui.route", "Routes")

        //处理过的 symbol 记录下来用于添加符号依赖
        val symbolList = mutableListOf<KSNode>()

        //遍历目标 Symbols
        list.forEach {
            //创建方法
            it.annotations
                //找到该方法中的 @UINavi 注解声明
                .find { a -> a.shortName.getShortName() == "UINavi" }
                ?.let { ksAnnotation ->
                    //找到注解中的第一个参数(即 Routes 的具体子类)
                    ksAnnotation.arguments
                        .first().let { arg ->
                            //记录下这个 symbol
                            symbolList.add(arg)
                            //使用 qualifiedName 作为路径
                            val routeName = (arg.value as KSType).toClassName().canonicalName
                            //这个是需要被路由的 Composable 方法的调用
                            val memberName = MemberName(it.packageName.asString(), it.toString())
                            //这个是 Navigation 库中需要在 NavHost 指定界面的 composable 方法
                            val composableName =
                                MemberName("androidx.navigation.compose", "composable")
                            func.addStatement(
                                "%M("$routeName"){ %M(it) }",//%M 表示方法调用,按后面的参数顺序放入
                                composableName,
                                memberName
                            )

                            //给 Routes 接口的伴生对象创建扩展属性以便获取各个界面的路径
                            val routeSimpleName = (arg.value as KSType).toClassName().simpleName
                            routeNameFile.addProperty(
                                PropertySpec.builder(routeSimpleName, String::class)
                                    .receiver(
                                        ClassName(
                                            "com.example.demo.core.ui.route",
                                            "Routes.Companion"
                                        )
                                    )
                                    .getter(
                                        FunSpec.getterBuilder().addModifiers(KModifier.INLINE)
                                            .addStatement("return %S", routeName).build()
                                    )
                                    .build()
                            )
                        }
                }
        }

        //写入文件
        file.addFunction(func.build())
            .build()
            .writeTo(codeGenerator, true, symbolList.mapNotNull { it.containingFile })

        routeNameFile.build()
            .writeTo(codeGenerator, true, symbolList.mapNotNull { it.containingFile })
        isProcessed = true
        return ret
    }
}

最终生成两个文件,分别如下:

#AutoNavi.kt

public fun NavGraphBuilder.autoImportNavi() {
  composable("com.example.demo.core.ui.screen.ARoute"){AScreenNavi(it) }
  composable("com.example.demo.core.ui.screen.BRoute"){BScreenNavi(it) }
  composable("com.example.demo.core.ui.screen.CRoute"){CScreenNavi(it) }
}

#RouteNameHelper.kt

public fun NavGraphBuilder.autoImportNavi() {
  public inline val Routes.Companion.ARoute: String
      get() = "com.example.demo.core.ui.screen.ARoute"
      
  public inline val Routes.Companion.BRoute: String
      get() = "com.example.demo.core.ui.screen.BRoute"
      
  public inline val Routes.Companion.CRoute: String
      get() = "com.example.demo.core.ui.screen.CRoute"
}

接下来只需要在NavHost中调用autoImportNavi()即可,其他交给 KSP 处理。

NavHost(
    navController = ...,
    startDestination = ...
) {
    autoImportNavi()
}

以上 KSP 中用于便捷生成文件和方法的库为Kotlinpoet,是另一个故事了。


https://www.xamrdz.com/backend/3bx1940831.html

相关文章: