运用 KSP 简化 Compose Navigation
简介
KSP(Kotlin Symbol Processing)是 Kotlin 提供的对源码进行预处理的工具。具有以下特性:
- KSP 自身是一个编译器插件。
- KSP 介入的时机在源码进行编译之前。
- KSP 只能新增源码不能修正源码。
- KSP 答应重复处理,即答应上一轮的输出作为下一轮的输入。
- KSP 支撑在 Gradle 中配置参数以操控处理逻辑。
根本运用
导入
- 在项目等级的 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
- 在过程 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
}
完成详细逻辑
- 完成
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)
}
}
- 在以下途径创立文件 resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider
- 在过程 2 的文件中输入你自己的 ProcessorProvider 的 qualifiedName
在项目中运用你的 Processor
- 在需求运用 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
内,开发过程中可能会忘了手动增加,下降开发功率。 -
Destination
的route
只能是字符串,存在呈现传错的风险。 - 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,是另一个故事了。