运用 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. 在过程 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

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