美文网首页
利用 KSP 简化 Compose Navigation

利用 KSP 简化 Compose Navigation

作者: BlueSocks | 来源:发表于2024-02-24 19:13 被阅读0次

    利用 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

    image.png
    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
    image.png
    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,是另一个故事了。

    相关文章

      网友评论

          本文标题:利用 KSP 简化 Compose Navigation

          本文链接:https://www.haomeiwen.com/subject/pksoadtx.html