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