Kotlin 元编程之 KSP 实战:通过自定义注解配置Compose导航路由

Posted 川峰

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Kotlin 元编程之 KSP 实战:通过自定义注解配置Compose导航路由相关的知识,希望对你有一定的参考价值。

在上一篇 Kotlin 元编程之 KSP 全面突破 中,通过几个设计模式相关的例子来生成代码,其逻辑都比较简单,没有涉及到android相关的API 业务类,而本文的例子会涉及到使用 Android API 相关的代码。

在之前Jetpack Compose中的导航路由一文中,我提到开源库 compose-destination 就是借助 KSP 来生成代码的,如果你去看它的源码,就会发现它是通过纯KSP的方式生成代码的,没有使用KotlinPoet。下面通过 KSP + KotlinPoet 的方式也来自定义实现一个下简单的可以通过注解配置的Compose导航路由框架。

当然,实现思路最终还是要通过Compose原生的导航路由API来实现,只不过我们可以对其进行一些封装,隐藏那些烦人又麻烦的配置操作细节。

具体的思路是这样的:我们只需要要为每个屏幕级别的Composable添加一个注解,通过该注解配置路由地址,类似ARouter那样,然后提供一个工具类进行路由跳转,并且可以在跳转起始页面和目标页面之间传递任何参数。

例如,现在有四个文件,每个文件中都有一个屏幕的Composable:

Screen01.kt :

@Router(path = "/main/firstScreen/", isStart = true)
@Composable
fun FirstScreen() 
   ......

Screen02.kt :

@Router(path = "/main/secondScreen/")
@Composable
fun SecondScreen() 
   ......

Screen03.kt :

@Router(path = "/main/thirdScreen/")
@Composable
fun ThirdScreen() 
	......

Screen04.kt :

@Router(path = "/main/fourthScreen/")
@Composable
fun FourthScreen(
    @Key(name = "useName") useName: String,
    @Key(name = "age") age: Int,
    @Key(name = "user") user: Person
) 
	......

这样就可以了,接下来就是通过一个管理类来根据注解的路径进行跳转。注意到上面的 FourthScreen 的参数也添加了注解,在进行跳转的时候,我们可以传递参数,而在接受页面通过这些注解自动接受参数值。

注解类非常简单:

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.SOURCE)
annotation class Router(
    val path: String,
    val isStart: Boolean = false,
)

@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.SOURCE)
annotation class Key(val name: String)

这里 Router 注解类中用 isStart = true 来表示是否是启动屏幕,对应 NavHost 组件中的startDestination 参数。

然后定义一个管理工具类RouterManager,内容如下:

class RouterManager(
    private val navController: NavHostController,
    private val collectors: Map<IScreen, ParamsCollector>,
    private val onNavigateFailed: ((IllegalArgumentException, IScreen) -> Unit)? = null
) 

    fun navigateTo(route: IScreen) 
        try 
            navController.navigate(route.name)
         catch (e : IllegalArgumentException) 
            onNavigateFailed?.invoke(e, route)
        
    

    fun navigateTo(route: IScreen, withParams: MutableMap<String, Any?>.() -> Unit) 
        val map = mutableMapOf<String, Any?>()
        map.withParams()
        collectors[route]?.apply 
             emit(map)
        
        navigateTo(route)
    

    fun navigateBack(route: IScreen? = null, inclusive: Boolean = false, saveState: Boolean = false) 
        if (route == null) 
            navController.popBackStack()
         else 
            navController.popBackStack(route.name, inclusive, saveState)
        
    

    @OptIn(ExperimentalLifecycleComposeApi::class)
    @Composable
    fun getParams(route: IScreen): Map<String, Any?> 
        val collector = collectors[route]
        if (collector != null) 
            val state = collector.getStateFlow().collectAsStateWithLifecycle()
            return state.value
        
        return mapOf()
    


val LocalComposeRouter = staticCompositionLocalOf<RouterManager>  error("没有找到RouterManager") 

fun MutableMap<String, Any?>.with(param: Pair<String, Any?>) 
    this[param.first] = param.second


fun Context.showToast(msg: String) 
    Toast.makeText(this, msg, Toast.LENGTH_LONG).show()

RouterManager 没有几个方法,就是对 NavHostController 的简单封装,然后对于参数,是通过一个Map来保存的,Map的键是一个IScreen抽象接口,Map的值是一个ParamsCollector对象,该对象是参数的载体。

IScreen抽象接口内容如下:

interface IScreen 
    val name : String

它只有一个属性,就是路由地址。在最终实现生成代码之后,我们会收集添加@Router注解的每个屏幕级别的Composable来生一个密封类,让密封类实现IScreen抽象接口,密封类的实现子类的名字就是对应的每个屏幕级的Composable函数名。最终希望以密封类的子类形式提供给路由框架的使用者在进行路由导航地址的时候选择使用。

ParamsCollector类的实现如下:

class ParamsCollector(
    private val flow: MutableStateFlow<Map<String, Any?>> = MutableStateFlow(mapOf())
) 

    fun emit(map: Map<String, Any?>) 
        flow.value = map
    

    fun getStateFlow(): StateFlow<Map<String, Any?>> 
        return flow.asStateFlow()
    

该类主要是一个StateFlow的封装,没有什么具体内容。参数形式是通过Flow持有的Map值,并且这里Map的value类型是Any?, 即我们期望在导航时可以传任意类型的参数。

有了上面的工具类以后,我们期望的最终使用方式如下:

路由跳转:通过 router.navigateTo(SomeScreen)

例如:

@Router(path = "/main/firstScreen/", isStart = true)
@Composable
fun FirstScreen() 
    val router = LocalComposeRouter.current
    Column  
        Button(onClick =  router.navigateTo(Screen.SecondScreen) ) ...
    

路由传参:通过 router.navigateTo(SomeScreen)后面的 lambda 中追加 with(key to value) 的形式

例如:

@Router(path = "/main/secondScreen/")
@Composable
fun SecondScreen() 
    val router = LocalComposeRouter.current
    Column  
        Button(onClick = 
            router.navigateTo(Screen.ThirdScreen) 
                with("name" to "张三")
                with("id" to 123)
                with("person" to Person("jack", 23))
            
        ) 
            ......
        
    

路由参数接收:我们可以提供两种方式,一种是直接在对应需要接受参数的屏幕级的Composable函数上添加参数,然后对参数添加类似 @Key(name = "useName") 的注解,导航到该页面时,由框架自动设置参数的值。另一种方式是,用户可以借助框架提供的 API router.getParams() 自己手动解析参数,这样灵活性更好。例如:

@Router(path = "/main/thirdScreen/")
@Composable
fun ThirdScreen() 
    val router = LocalComposeRouter.current
    val params = router.getParams(Screen.ThirdScreen)
    val name : String? = params["name"] as String?
    val id : Int = (params["id"] ?: -1) as Int
    val person : Person? = params["person"] as Person?

    Column 
        ......
    

在这些期望的方式和行为定义好之后,那么我们需要生成的代码应该是什么呢?

需要生成的主要是下面的代码:

import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.fly.compose.ksp.application.entity.Person
import com.fly.mycompose.test.router.controller.*

sealed class Screen(override val name: String): IScreen 
    object First : Screen("FirstScreen")
    object Second : Screen("SecondScreen")
    object Third : Screen("ThirdScreen")
    object Fourth : Screen("FourthScreen")


private val collectors: Map<IScreen, ParamsCollector> = mapOf(
    Screen.First to ParamsCollector(),
    Screen.Second to ParamsCollector(),
    Screen.Third to ParamsCollector(),
    Screen.Fourth to ParamsCollector()
)

@Composable
fun MainScreen() 
    val navController = rememberNavController()
    val context = LocalContext.current
    val routerManager by remember 
        mutableStateOf(
            RouterManager(navController, collectors)  _, route ->
                context.showToast("路由:$route.name不存在")
            
        )
    
    CompositionLocalProvider(LocalComposeRouter provides routerManager) 
        NavHost(navController = navController, startDestination = Screen.First.name) 
            composable(Screen.First.name)  FirstScreenWrapper() 
            composable(Screen.Second.name)  SecondScreenWrapper() 
            composable(Screen.Third.name)  ThirdScreenWrapper() 
            composable(Screen.Fourth.name)  FourthScreenWrapper() 
        
    


@Composable
fun FirstScreenWrapper() 
    FirstScreen()


@Composable
fun SecondScreenWrapper() 
    SecondScreen()


@Composable
fun ThirdScreenWrapper() 
    ThirdScreen()


@Composable
fun FourthScreenWrapper() 
    val router = LocalComposeRouter.current
    val params = router.getParams(Screen.Fourth)
    val useName = params["useName"]
    val age = params["age"]
    val user = params["user"]
    if (useName == null || age == null || user == null) 
        val context = LocalContext.current
        LaunchedEffect(Unit) 
            context.showToast("参数为空, 请检查参数")
            router.navigateBack()
        
    
    else 
        FourthScreen(useName as String, age as Int, user as Person)
    

上面代码搭配前面的工具类是可以直接运行的。其实就是Compose原生的路由导航使用方式,只不过这里将导航跳转和传参委托给了RouterManager对象而已。

此外注意到,在上面代码中,NavHost 中的每个 composable 中配置的是 XXXScreenWrapper() 对象,而不是我们真正的屏幕级的Composable组件。这里为什么要包装一层呢?答案是为了传参。在包装层中,我们还可以进行参数的检查校验、用户提醒操作等等,如果将来功能要做的更丰富一点,我们可以做拦截器。

这里使用了 CompositionLocalProvider 来提供 RouterManager 对象,在用户最终使用时,跟 LocalContext 的获取方式一样,通过 LocalComposeRouter.current 获取到路由管理对象。

关于参数的获取,实际上是通过RouterManager拿到 StateFlow 对象再调用其 collectAsStateWithLifecycle() ,在之前 Flow 相关的文章中提到过,使用这个API会更加安全的收集流中的数据。 如需使用它,你需要在项目中添加下面的依赖:

implementation "androidx.lifecycle:lifecycle-runtime-compose:2.6.0-alpha03"

下面的图很好的解释了我要传参的过程,你可以想象为每个屏幕级的Composable上都插了一根管子:

另外需要注意的是,stateFlow 的所有的 collectAsStatexxx 的API都需要在Composable函数中调用,因此在前面 RouterManager 代码中 getParams 方法上添加有 @Composable 注解。

回过头来再分析一下要生成的代码,很明显要收集一个包含特定注解信息的Composable函数集合,根据该集合要生成一个Screen密封类,一个map对象,一个配置NavHostMainScreen Composable函数,以及根据集合的每个函数生成一个Wrapper函数。

接下来在 Android Studio 中新建一个 java/kotlin library module 作为开发生成 router 代码的 ksp 模块,然后另外新建一个 android library module 模块将前面的 RouterManager 相关的代码放入其中。

按照上一篇文章配置好KSP相关的依赖之后,在app 模块添加模块依赖:

dependencies 
    implementation project(':test_router')
    implementation project(':router_processor')
    ksp project(':router_processor')

由于本文实现的功能使用的注解比较少,只是编写一些demo代码,所以注解相关的类直接放到了 ksp 模块中,在实际项目中,注解类模块管理类模块ksp模块 三者应该分开来放置。建议按照如下方式来组织它们:

一切准备就绪后,下面开始编写 KSP 的 Processor 代码:

import com.google.devtools.ksp.processing.*
import com.google.devtools.ksp.symbol.*
import com.google.devtools.ksp.validate
import com.google.devtools.ksp.visitor.KSDefaultVisitor
import com.squareup.kotlinpoet.*
import com.squareup.kotlinpoet.MemberName.Companion.member
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import com.squareup.kotlinpoet.ksp.toTypeName
import com.squareup.kotlinpoet.ksp.writeTo
import java.util.*

/**
 * 应用ksp的模块未配置options参数时,默认的生成包名和文件名
 */
const val DEFAULT_CODE_GEN_PKG = "com.fly.compose.router.screen"
const val DEFAULT_CODE_GEN_FILE = "NavHostMain"

const val PKG_COMPOSE_RUNTIME = "androidx.compose.runtime"
const val PKG_COMPOSE_NAVIGATION = "androidx.navigation.compose"
const val PKG_COMPOSE_PLATFORM = "androidx.compose.ui.platform"
const val PKG_MY_ROUTER = "com.fly.mycompose.test.router.controller"

val rememberNavController = MemberName(PKG_COMPOSE_NAVIGATION, "rememberNavController")
val NavHost = MemberName(PKG_COMPOSE_NAVIGATION, "NavHost")
val remember = MemberName(PKG_COMPOSE_RUNTIME, "remember")
val mutableStateOf = MemberName(PKG_COMPOSE_RUNTIME, "mutableStateOf")
val CompositionLocalProvider = MemberName(PKG_COMPOSE_RUNTIME, "CompositionLocalProvider")
val Composable = ClassName(PKG_COMPOSE_RUNTIME, "Composable")
val LaunchedEffect = MemberName(PKG_COMPOSE_RUNTIME, "LaunchedEffect")
val LocalContext = MemberName(PKG_COMPOSE_PLATFORM, "LocalContext")
val composable = MemberName(PKG_COMPOSE_NAVIGATION, "composable")

val LocalComposeRouter = MemberName(PKG_MY_ROUTER, "LocalComposeRouter")
val showToast = MemberName(PKG_MY_ROUTER, "showToast")
val IScreen = ClassName(PKG_MY_ROUTER, "IScreen")
val ParamsCollector = ClassName(PKG_MY_ROUTER, "ParamsCollector")
val RouterManager = ClassName(PKG_MY_ROUTER, "RouterManager")

class ComposeRouterProcessor(
    private val codeGenerator: CodeGenerator,
    private val logger: KSPLogger,
    private val options: Map<String, String>,
) : SymbolProcessor 

    /**
     * 需要收集的信息是一个Composable函数列表
     */
    data class RouterData(
        val composableList: List<ComposableFun>
    )

    /**
     * 每个被注解标注的Composable函数需要收集的信息
     */
    data class ComposableFun(
        var packageName: String = "", // 包名
        var funName: String = "",     // 函数名
        var routerPath: String = "",  // 路由路径
        var isStart: Boolean = false, // 是否为启动页
        var keys: MutableList<Pair<String, TypeName>> = mutableListOf() // 参数名和参数类型
    )

    override fun process(resolver: Resolver): List<KSAnnotated> 
        val symbols = resolver.getSymbolsWithAnnotation(Router::class.qualifiedName!!)
        以上是关于Kotlin 元编程之 KSP 实战:通过自定义注解配置Compose导航路由的主要内容,如果未能解决你的问题,请参考以下文章

Kotlin 元编程之 KSP 全面突破

Kotlin 元编程之 KotlinPoet

KSP - 元编程编译提速的小助手

拥抱Kotlin Symbol Processing(KSP),手把手带你实现Kotlin的专有注解处理

登录时未通过凭据提供程序加载 KSP(密钥存储提供程序)

高效 告别996,开启java高效编程之门 2-10实战:自定义函数式接口