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代码中进行控制,更加方便灵活了。

导航路由配置

NavControllerNavigation 的核心,它是有状态的,可以跟踪返回堆栈以及每个界面的状态。可以通过 rememberNavController 来创建一个NavController的实例。

NavHost 是导航容器,NavHostNavController 与导航图相关联,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 中的导航?

Jetpack Compose 滚动条

底部导航栏与 Jetpack Compose 中的屏幕内容重叠

在 Scaffold Jetpack Compose 内的特定屏幕上隐藏顶部和底部导航器

Jetpack Compose - 导航 - 脚手架 + NavHost 不工作