利用 KSP 简化 Compose Navigation

利用 KSP 简化 Compose Navigation

简介

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

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

基本使用

导入

  1. 在项目级别的 build.gradle 中添加 KSP 插件
bash 复制代码
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'
    }
}
  1. 新增一个 Kotlin Module 作为 KSP 的承载 module
  1. 在步骤 2 中创建的 module 下的 build.gradle 中添加 KSP 依赖
bash 复制代码
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
kotlin 复制代码
class MyProcessor(
    private val codeGenerator: CodeGenerator,
    private val logger: KSPLogger
) : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> {
        //主要逻辑的代码
    }
}
kotlin 复制代码
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
  1. 在步骤 2 的文件中输入你自己的 ProcessorProvider 的 qualifiedName

在项目中使用你的 Processor

  1. 在需要应用 Processor 的 module 下的 build.gradle 中添加 KSP 插件
bash 复制代码
plugins {
    ...
    id 'com.google.devtools.ksp'
}
  1. 使用关键字ksp将你的 Processor 添加到dependencies块中
java 复制代码
dependencies {
    ...
    ksp project(':your ksp lib name')
}
  1. 构建项目,如无意外你的 Processor 将会被应用

具体项目中应用

需求背景

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

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

解决思路

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

由此定下最终的方案:

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

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

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

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

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

使用示例:

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

KSP 处理的代码如下:

kotlin 复制代码
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
    }
}

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

kotlin 复制代码
#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) }
}
kotlin 复制代码
#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 处理。

ini 复制代码
NavHost(
    navController = ...,
    startDestination = ...
) {
    autoImportNavi()
}

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

相关推荐
雨白8 小时前
Jetpack系列(二):Lifecycle与LiveData结合,打造响应式UI
android·android jetpack
kk爱闹10 小时前
【挑战14天学完python和pytorch】- day01
android·pytorch·python
每次的天空11 小时前
Android-自定义View的实战学习总结
android·学习·kotlin·音视频
恋猫de小郭12 小时前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
断剑重铸之日13 小时前
Android自定义相机开发(类似OCR扫描相机)
android
随心最为安13 小时前
Android Library Maven 发布完整流程指南
android
岁月玲珑13 小时前
【使用Android Studio调试手机app时候手机老掉线问题】
android·ide·android studio
还鮟17 小时前
CTF Web的数组巧用
android
小蜜蜂嗡嗡18 小时前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio
aqi0018 小时前
FFmpeg开发笔记(七十一)使用国产的QPlayer2实现双播放器观看视频
android·ffmpeg·音视频·流媒体