Jetpack Compose中的导航路由
Posted 川峰
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Jetpack Compose中的导航路由相关的知识,希望对你有一定的参考价值。
Jetpack Compose中的导航库是由Jetpack库中的Navigation组件库的基础上添加的对Compose的扩展支持,使用需要单独添加依赖:
implementation "androidx.navigation:navigation-compose:$nav_version"
Jetpack库中的Navigation使用起来还是比较麻烦的,首先需要在xml中进行导航图的配置,然后在代码中使用NavController.navigate(id)
进行跳转到指定的id
的fragment页面,个人感觉这种方式还是不够灵活,需要预先定义,假如某个fragment没有在xml中定义就无法使用NavController进行跳转,另外还需要在xml和java/kotlin文件来回折腾修改。
Jetpack Compose中的Navigation在功能上跟jetpack组件库中对Fragment的导航使用方式很类似,但是使用Compose的好处是,它是纯kotlin的代码控制,不需要在xml再去配置,一切都是在kotlin代码中进行控制,更加方便灵活了。
导航路由配置
NavController 是 Navigation 的核心,它是有状态的,可以跟踪返回堆栈以及每个界面的状态。可以通过 rememberNavController
来创建一个NavController
的实例。
NavHost 是导航容器,NavHost
将 NavController
与导航图相关联,NavController
能够在所有页面之间进行跳转。当在进行页面跳转时,NavHost
的内容会自动进行重组。导航图中的目的地就是一个路由。路由名称通常是一个字符串。
@Composable
fun NavigationExample()
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "Welcome")
composable("Welcome") WelcomeScreen(navController)
composable("Login") LoginScreen(navController)
composable("Home") HomeScreen(navController)
composable("Cart") CartScreen(navController)
NavHost
中通过composable(routeName)...
进行路由地址和对应的页面进行配置,startDestination
指定的路由地址将作为首页进行展示。
导航路由跳转
路由跳转就是通过navController.navigate(id)
的方式进行跳转,id
参数就是前面配置的目标页面的路由地址。
@Composable
fun WelcomeScreen(navController : NavController)
Column()
Text("WelcomeScreen", fontSize = 20.sp)
Button(onClick = navController.navigate("Login") )
Text(text = "Go to LoginScreen")
注意: 实际业务中,路由名称的字符串应当全部改成密封类的实现方式。
这种方式是将 navController
作为参数传入到了Composable组件中进行调用,更加优雅的方式应当是通过函数回调的方式,来进行跳转,不用每个都传一个navController
参数:
@Composable
fun NavigationExample2()
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "Welcome")
composable("Welcome")
WelcomeScreen
navController.navigate("Login")
...
@Composable
fun WelcomeScreen(onGotoLoginClick: () -> Unit = )
Column()
Text("WelcomeScreen", fontSize = 20.sp)
Button(onClick = onGotoLoginClick)
Text(text = "Go to LoginScreen")
这种方式的好处是,更加易于复用和测试。
默认navigate
是在回退栈中压入一个新的Compasable的Destination作为栈顶节点进行展示,可以选择在调用navigate
方法时,在后面紧跟一个block lambda,在其中添加对NavOptions的操作。
// 在跳转到 Home 之前 ,清空回退栈中Welcome之上到栈顶的所有页面(不包含Welcome)
navController.navigate("Home")
popUpTo("Welcome")
// 同上,包含Welcome
navController.navigate("Home")
popUpTo("Welcome") inclusive = true
// 当前栈顶已经是Home时,不再入栈新的Home节点,相当于Activity的SingleTop启动模式
navController.navigate("Home")
launchSingleTop = true
可以根据需求场景进行选择,例如从欢迎页面到登录页面,登录成功之后,跳转到首页,此时回退栈中首页之前的页面就不再需要了,按返回键可以直接返回桌面,这时就适合用下面代码进行跳转:
navController.navigate("Home")
popUpTo("Welcome") inclusive = true
另外,需要注意的一点是,如果跳转的目标路由地址不存在时,NavController
会直接抛出IllegalArgumentException
异常,导致应用崩溃,因此在执行navigate
方法时我们应该进行异常捕获,并给出用户提示:
@Composable
fun NavigationExample2()
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "Welcome")
composable("Login")
val context = LocalContext.current
LoginScreen
try
navController.navigate("Home")
popUpTo("Welcome") inclusive = true
catch (e : IllegalArgumentException)
// 路由不存在时会抛异常
Log.e("TAG", "NavigationExample2: $e")
with(context) showToast("Home路由不存在!")
...
最好是封装一下定义一个扩展函数来使用,例如
fun NavHostController.navigateWithCall(
route: String,
onNavigateFailed: ((IllegalArgumentException)->Unit)?,
builder: NavOptionsBuilder.() -> Unit
)
try
this.navigate(route, builder)
catch (e : IllegalArgumentException)
onNavigateFailed?.invoke(e)
// 使用:
LoginScreen
navController.navigateWithCall(
route = "Home",
onNavigateFailed = with(context) showToast("Home路由不存在!")
)
popUpTo("Welcome") inclusive = true
导航路由传参
基本数据类型的传参
基本数据类型的参数传递是通过List/userId
这种字符串模板占位符的方式来提供:
@Composable
fun NavigationWithParamsExample()
val navController = rememberNavController()
NavHost(navController, startDestination = "Home")
composable("Home")
HomeScreen1 userId, isFromHome ->
navController.navigate("List/$userId/$isFromHome")
composable(
"List/userId/isFromHome",
arguments = listOf(
navArgument("userId") type = NavType.IntType , // 设置参数类型
navArgument("isFromHome")
type = NavType.BoolType
defaultValue = false // 设置默认值
)
) backStackEntry ->
val userId = backStackEntry.arguments?.getInt("userId") ?: -1
val isFromHome = backStackEntry.arguments?.getBoolean("isFromHome") ?: false
ListScreen(userId, isFromHome) id ->
navController.navigate("Detail/$id")
composable("Detail/detailId") backStackEntry ->
val detailId = backStackEntry.arguments?.getString("detailId")
DetailScreen(detailId)
navController.popBackStack()
如上,在接受页面的路由配置中可以通过 arguments
参数接受一个 navArgument
的 List 集合, 通过navArgument
可以配置路由参数的类型和默认值等。但是如果参数过多,还要指定类型的话,明显就比较麻烦了,还不如传统的Intent
传参方便。目前官方的api也没有提供其他的方式可以解决,所以最好的方式是将参数全部按照String
类型进行传递,不指定具体的参数类型,在目标页面接受之后再进行转换。
可选参数
通过路由名称中以斜杠方式提供的参数,如果启动方不传会导致崩溃,可以通过路由名称后面跟 ?
的方式提供可选参数,可选参数可以不传,不会导致崩溃。跟浏览器地址栏的可选参数一样。
例如:
navController.navigate("List2/$userId?fromHome=$isFromHome")
navController.navigate("List2/$userId") // 可以不传$isFromHome
接受方:
composable(
"List2/userId?fromHome=isFromHome", // 设置可选参数时,必须提供默认值
arguments = listOf(
navArgument("userId") type = NavType.IntType ,
navArgument("isFromHome")
type = NavType.BoolType
defaultValue = false
)
) backStackEntry ->
val userId = backStackEntry.arguments?.getInt("userId") ?: -1
val isFromHome = backStackEntry.arguments?.getBoolean("isFromHome") ?: false
ListScreen(userId, isFromHome) id ->
navController.navigate("Detail/$id")
设置可选参数时,接受方必须提供默认值参数配置。
对象类型的传参
对于数据类或普通class对象类型的参数传递,首先想到的是传递序列化对象,但是很遗憾,官方目前还不支持对象类型的参数传递,虽然如此,但是很奇怪的是,你可以通过代码写出序列化的传参方式,例如以下通过Parcelable
序列化的方式传参:
@Parcelize
data class User(val userId : Int, val name : String): Parcelable
@Composable
fun NavigationWithParamsExample()
val navController = rememberNavController()
NavHost(navController, startDestination = "Home")
composable("Home")
HomeScreen1 userId, isFromHome ->
// 传递序列化参数
val user = User(56789, "小明")
navController.navigate("List3/$user") // NOT SUPPORTED!!!
// NOT SUPPORTED!!! navigation-compose暂不支持直接传Parcelable
composable(
"List3/user", // 传递Parcelable数据类
arguments = listOf(
navArgument("user") type = NavType.ParcelableType(User::class.java) ,
)
) backStackEntry ->
val user : User? = backStackEntry.arguments?.getParcelable("user")
user?.run
ListScreen(userId, true) id ->
navController.navigate("Detail/$id")
以上代码虽然编译完全没有问题,但如果尝试运行以上代码,则会直接崩溃:
因为Compose的导航是基于Navigation的Deeplinks方式实现的,而Deeplinks参数目前不支持对象类型,只能传String字符串。
同样,以下通过Serializable
序列化方式的传参也会崩溃,会报同样的错误
data class User2(val userId : Int, val name : String): java.io.Serializable
@Composable
fun NavigationWithParamsExample()
val navController = rememberNavController()
NavHost(navController, startDestination = "Home")
composable("Home")
HomeScreen1 userId, isFromHome ->
// 传递序列化参数
val user2 = User2(987654321, "小明")
navController.navigate("List5/$user2") // NOT SUPPORTED!!!
// NOT SUPPORTED!!! navigation-compose暂不支持直接传Serializable
composable(
"List5/user", // 传递Serializable数据类
arguments = listOf(
navArgument("user") type = NavType.SerializableType(User2::class.java) ,
)
) backStackEntry ->
val user : User2? = backStackEntry.arguments?.getSerializable("user") as User2?
user?.run
ListScreen(userId, true) id ->
navController.navigate("Detail/$id")
这一点算是目前Compose的短板和缺陷,由于开发者无法在Compose中找到使用传统android传参的方式如Intent/Bundle
形式的平替方案,这会使得旧xml项目迁移Compose的成本增大很多,还是希望谷歌能尽快更新给出解决方案吧,不然影响还是很大的。
对象类型传参的其他方案
虽然官方目前没有给出解决方案,但是我们可以采用曲线救国的其他方式,依然可以做到对象方式的传参,这里我大概总结了有以下几种可选的参考方案:
- 1.使用
Gson
将数据类序列化成gson
字符串传递,然后解析的时候再从字符串反序列化成数据类 - 2.使用共享的
ViewModel
实例保存数据类对象(mutableStateOf
), 发起方向共享的ViewModel
实例中赋值新的数据类对象,接受方从共享的ViewModel
实例中读取数据类对象。 - 3.通过
navController.previousBackStackEntry?.savedStateHandle?.set(key, value)/get(key)
解决,但是这种有缺点就是跳转之前先弹了回退栈就获取不到了。(所以这种方案只能是在一定条件下可行) - 4.使用开源库compose-destinations,这个库非常棒,使用非常简化(后面会介绍如果使用)
- 5.使用共享的
StateFlow
实例,StateFlow
是kotlin协程中的Api,基于观察者模式以单向数据管道流的思想编程 (如果不了解的可看我之前的文章 Flow1 Flow2),我们页面传参无非就是要在其他页面使用该数据,因此不妨换一种思路,我们进行发送参数,而不是传递参数。
以下是上面第3种方案的实现代码:
@Parcelize
data class User(val userId : Int, val name : String): Parcelable
@Composable
fun NavigationWithParamsExample()
val navController = rememberNavController()
NavHost(navController, startDestination = "Home")
composable("Home")
HomeScreen1 userId, isFromHome ->
val user = User(56789, "小明")
navController.currentBackStackEntry?.savedStateHandle?.set("user", user)
navController.navigate("List4")
composable(
"List4",
) backStackEntry ->
val user = navController.previousBackStackEntry?.savedStateHandle?.get<User>("user")
user?.run
ListScreen(userId, true) id ->
navController.navigate("Detail/$id")
println("user == null is $user == null")
运行效果:
可以看到传递序列化对象完全没有问题,但是这个方案有一个缺点就是如果在navigate
的时候弹了回退栈就不行了,例如:
@Parcelize
data class User(val userId : Int, val name : String): Parcelable
@Composable
fun NavigationWithParamsExample()
val navController = rememberNavController()
NavHost(navController, startDestination = "Home")
composable("Home")
HomeScreen1 userId, isFromHome ->
val user = User(56789, "小明")
navController.currentBackStackEntry?.savedStateHandle?.set("user", user)
navController.navigate("List4")
popUpTo("Home") inclusive = true
composable(
"List4",
) backStackEntry ->
val user = navController.previousBackStackEntry?.savedStateHandle?.get<User>("user")
user?.run
ListScreen(userId, true以上是关于Jetpack Compose中的导航路由的主要内容,如果未能解决你的问题,请参考以下文章
Jetpack Compose中的startActivityForResult的正确姿势
底部导航栏与 Jetpack Compose 中的屏幕内容重叠