在上一篇 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