草庐IT

Jetpack Compose中的导航路由

川峰 2023-04-13 原文

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) { id ->
                    navController.navigate("Detail/$id")
                }
            }
             if (user == null) {
                with(LocalContext.current) { showToast("user == null") }
            }
        }
    }
}

运行效果:

可以看到这时接受到的User对象是null,因为这种方案是将User对象保存到当前回退栈中的SavedStateHandle对象中,如果将回退栈清空了,自然就获取不到了。

使用开源库compose-destinations进行路由导航

compose-destinations库支持对象类型的参数传递。

该库使用kotlin强大的KSP在编译期进行注解符号处理和生成代码,它的内部只是基于官方Compose的Navigation进行的封装,需要注意的是,compose-destinations是针对路由导航的通用方案,而并不仅仅是针对传递对象类型的参数,对于任意参数类型传参、以及无参路由跳转都是可以使用的。

集成步骤:
1.在app/build.gradle中添加ksp插件

plugins {
    // ...
    id 'com.google.devtools.ksp' version '1.7.20-1.0.8' 
}

ksp插件版本参考:https://github.com/google/ksp/releases,注意它的版本号,是跟你使用的kotlin版本挂钩的。

2.添加compose-destinations的依赖库

    implementation 'io.github.raamcosta.compose-destinations:core:1.7.27-beta'
    ksp 'io.github.raamcosta.compose-destinations:ksp:1.7.27-beta'

3.设置ksp中间代码保存目录

android {
	...
	// replace applicationVariants with libraryVariants if the module uses 'com.android.library' plugin!
    applicationVariants.all { variant ->
        kotlin.sourceSets {
            getByName(variant.name) {
                kotlin.srcDir("build/generated/ksp/${variant.name}/kotlin")
            }
        }
    }
}

接着就可以在代码中使用了,使用非常简单,首先在需要导航的页面级的Composable上面添加@Destination注解:

@RootNavGraph(start = true) // 该注解表示根路由页面
@Destination
@Composable
fun FirstScreen(navigator: DestinationsNavigator) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("FirstScreen", fontSize = 20.sp)
        Button(onClick = {
             // TODO
        }) {
            Text(text = "Go to SecondScreen")
        }
    }
}

@Destination
@Composable
fun SecondScreen(
    navigator: DestinationsNavigator,
    id: Int,
    name: String?,
    isOwnUser: Boolean = false
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("SecondScreen", fontSize = 20.sp)
        Text("$id $name $isOwnUser", fontSize = 20.sp)
        Button(onClick = {
            // TODO
        }) {
            Text(text = "Go to ThirdScreen")
        }
    }
}

@Destination
@Composable
fun ThirdScreen(
    navigator: DestinationsNavigator,
    person: Person
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("ThirdScreen", fontSize = 20.sp)
        Text("$person ", fontSize = 20.sp)
    }
}

这里注意到每个函数上面都有一个 DestinationsNavigator参数,后面生成代码后会使用该参数进行导航,这里暂时不用管只需要添加上即可,然后其他的参数,不管是需要什么类型的,都可以直接按需添加写在函数参数即可。当然如果Composable内部不需要再跳转其他页面,那么函数上就不用添加navigator参数了。

然后build一下项目,就会生成对应的中间代码,添加了@Destination注解的Composable函数就会产生同名且以Destination结尾的类,形如[ComposableName]Destination

然后就可以使用参数navigator.navigate()方法进行跳转,例如这里跳转到SecondScreen,就可以这样写:

navigator.navigate(SecondScreenDestination(id = 789, "王小明", true)) // 传递基本数据类型参数

类似的,再如跳转到ThirdScreen,注意到ThirdScreen需要接受一个Person对象类型参数,直接传即可:

val person = Person(1234567, "Android")
navigator.navigate(ThirdScreenDestination(person))  // 传递对象类型参数

是不是超级简单,简直比官方的好用一万倍。

完整示例代码:

@Parcelize
data class Person(val userId : Int, val name : String): Parcelable
@Serializable
data class People(val userId : Int, val name : String)

data class Man(val userId : Int, val name : String): java.io.Serializable

@Composable
fun NavigationWithParamsByDestinationsLib() {
    DestinationsNavHost(navGraph = NavGraphs.root)
}

@RootNavGraph(start = true)
@Destination
@Composable
fun FirstScreen(navigator: DestinationsNavigator) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("FirstScreen", fontSize = 20.sp)
        Button(onClick = {
             navigator.navigate(SecondScreenDestination(id = 789, "王小明", true))
        }) {
            Text(text = "Go to SecondScreen")
        }
    }
}

@Destination
@Composable
fun SecondScreen(
    navigator: DestinationsNavigator,
    id: Int,
    name: String?,
    isOwnUser: Boolean = false
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("SecondScreen", fontSize = 20.sp)
        Text("$id $name $isOwnUser", fontSize = 20.sp)
        Button(onClick = {
            val person = Person(1234567, "Android")
            navigator.navigate(ThirdScreenDestination(person))
        }) {
            Text(text = "Go to ThirdScreen")
        }
    }
}

@Destination
@Composable
fun ThirdScreen(
    navigator: DestinationsNavigator,
    person: Person
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("ThirdScreen", fontSize = 20.sp)
        Text("$person ", fontSize = 20.sp)
        Button(onClick = {
            val people = People(7654321, "Kotlin")
            navigator.navigate(FourthScreenDestination(people))
        }) {
            Text(text = "Go to FourthScreen")
        }
    }
}

@Destination
@Composable
fun FourthScreen(
    navigator: DestinationsNavigator,
    people: People
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("FourthScreen", fontSize = 20.sp)
        Text("$people", fontSize = 20.sp)
        Button(onClick = {
            val man = Man(8866999, "Compose")
            navigator.navigate(FifthScreenDestination(man))
        }) {
            Text(text = "Go to FifthScreen")
        }
    }
}

@Destination
@Composable
fun FifthScreen(
    navigator: DestinationsNavigator,
    man: Man
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("FifthScreen", fontSize = 20.sp)
        Text("$man", fontSize = 20.sp)
        Button(onClick = {
            navigator.popBackStack(FirstScreenDestination, inclusive = false)
        }) {
            Text(text = "Back To Home")
        }
    }
}

导航的首页也不需要NavHost那么麻烦的配置了,只需DestinationsNavHost(navGraph = NavGraphs.root)这一句就OK了。

运行效果:


可以看到不管是普通数据类型还是对象类型都可以传递,而且使用方式及其简单,此时如果再回过头去看官方的配置方法,简直又臭又长。

注意:上面示例代码中People数据类使用了@Serializable注解,使用该注解需要参考官网进行配置

Navigation搭配底部导航栏使用

sealed class Screen(val route: String, val title: String) {
    object Home : Screen("home", "Home")
    object Favorite : Screen("favorite", "Favorite")
    object Profile : Screen("profile", "Profile")
    object Cart : Screen("cart", "Cart")
}

val items = listOf(
    Screen.Home,
    Screen.Favorite,
    Screen.Profile,
    Screen.Cart
)

@Composable
fun WorkWithBottomNavigationExample() {
    val navController = rememberNavController()
    Scaffold(
        bottomBar = {
            BottomNavigation {
                // 从 NavHost 函数中获取 navController 状态,并与 BottomNavigation 组件共享此状态。
                // 这意味着 BottomNavigation 会自动拥有最新状态。
                val navBackStackEntry by navController.currentBackStackEntryAsState()
                val currentDestination = navBackStackEntry?.destination // 这个目的是为了下面比较获得当前的选中状态
                items.forEach { screen ->
                    BottomNavigationItem(
                        icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
                        label = { Text(screen.title) },
                        selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
                        onClick = {
                            // 加这个可解决问题:按back键会返回2次,第一次先返回home, 第二次才会退出
                            navController.popBackStack()
                            navController.navigate(screen.route) {
                                // 点击item时,清空栈内 popUpTo ID到栈顶之间的所有节点,避免站内节点持续增加
                                popUpTo(navController.graph.findStartDestination().id) {
                                    saveState = true // 用于页面状态的恢复
                                }
                                // 避免多次重复点击按钮时产生多个实例
                                launchSingleTop = true
                                // 再次点击之前选中的Item时,恢复之前的状态
                                restoreState = true
                                // 通过使用 saveState 和 restoreState 标志,当您在底部导航项之间切换时,
                                // 系统会正确保存并恢复该项的状态和返回堆栈。
                            }
                        }
                    )
                }
            }
        }
    ) { innerPadding ->
        NavHost(navController, startDestination = Screen.Home.route, Modifier.padding(innerPadding)) {
            composable(Screen.Home.route) { HomeScreen2(navController) }
            composable(Screen.Favorite.route) { FavoriteScreen(navController) }
            composable(Screen.Profile.route) { ProfileScreen(navController) }
            composable(Screen.Cart.route) { CartScreen2(navController) }
        }
    }
}

以上代码有一个需要注意的地方,使用Scaffold中的BottomNavigation 搭配NavHost使用导航时有个问题,如果当前不是在首页(home)Tab页面,而是切换到其他tab页面,那么此时按back键它会先返回到首页(home)Tab页面, 再按一次back键才会退出。

但是一般国内的app效果都是在首页按back键直接回到桌面,不管当前是在哪个tab页,所以上面代码中在onClick方法里调用 navController.navigate方法之前调用了一次navController.popBackStack(),即先弹一次回退栈,否则栈内会保存上次的tab页面。这样就正常了。

多模块下的导航路由配置

当项目采用多模块(Module)组件化开发方式时,应当在app module中配置Root Graph(因为app依赖编译其他业务模块),将 app module 依赖的其他业务模块的导航配置作为 子Graph,嵌套配置到 NavHost 中。

@Composable
fun WorkWithModulesExample() {
    val navController = rememberNavController()
    NavHost(navController, startDestination = "home") {
        //...
        // 当调用 navigate('home') 时,会自动将home模块的MessageList作为页面显示
        navigation(startDestination = "MessageList", route = "home") {
            composable("MessageList") { MessageListScreen(navController) }
            composable("FriendList") { FriendListScreen(navController) }
            composable("Setting") { SettingScreen(navController) }
        }
        //...其他模块的设置,每个模块对应一个navigation子项
    }
}

可以将每个模块的路由配置定义为NavGraphBuilder扩展函数

fun NavGraphBuilder.homeGraph(navController: NavController) {
    navigation(startDestination = "MessageList", route = "home") {
        composable("MessageList") { MessageListScreen(navController) }
        composable("FriendList") { FriendListScreen(navController) }
        composable("Setting") { SettingScreen(navController) }
    }
}

然后在App module中NavHost里依次调用这些扩展函数

@Composable
fun WorkWithModulesExample2() {
    val navController = rememberNavController()
    NavHost(navController, startDestination = "home") {
        homeGraph(navController)
        //...其他模块
    }
}

其实多模块下更加适合使用前面提到的开源库compose-destinations进行路由导航,因为不需要进行大量的配置,app模块会自动依赖其他模块生成的代码。

DeepLink 深度链接

DeepLink 适合的场景:

  • 当前模块跳转到某个业务模块的某个子页面中,而不只是该模块的首页面(不管是否多Module还是单Module都存在这种需求)
  • 隐式跳转

DeepLink 是一个标准的URI格式 符合schema://host/path?query 应当在path或之后的部分指定参数。

const val URI = "my-app://my.example.app"

@Composable
fun WorkWithDeepLinkExample() {
    val navController = rememberNavController()
    NavHost(navController, startDestination = "SomeModule") {
        composable(
            route = "newsDetail?id={id}",
            deepLinks = listOf(
                navDeepLink {
                    uriPattern = "$URI/news/{id}"  // 对应上面route的深度链接
                    action = Intent.ACTION_VIEW // 可选
                }
            )
        ) { backStackEntry ->
            NewsDetailScreen(navController, backStackEntry.arguments?.getString("id"))
        }
        composable("SomeModule") {
            SomeModuleScreen {
                // 在其他地方调用
                val request = NavDeepLinkRequest.Builder
                    .fromUri("$URI/news/1234".toUri())
                    .build()
                navController.navigate(request)
            }
        }
        // ...
    }
}

@Composable
fun NewsDetailScreen(navController : NavController, newsId : String?) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("NewsDetailScreen $newsId", fontSize = 20.sp)
    }
}

@Composable
fun SomeModuleScreen(onNavigate : () -> Unit) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Button(onClick = onNavigate) {
            Text(text = "跳转到NewsDetailScreen")
        }
    }
}

借助这些深层链接,可以将特定的网址、操作或 MIME 类型与可组合项关联起来。
默认情况下,这些深层链接不会向外部应用公开。如需向外部提供这些深层链接,必须向应用的 manifest.xml 文件添加相应的 <intent-filter> 元素。在清单的 <activity> 元素中添加以下内容:

 <activity >
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
   <intent-filter>
          <action android:name="android.intent.action.VIEW" />
          <category android:name="android.intent.category.DEFAULT" />
          <category android:name="android.intent.category.BROWSABLE" />
          <data android:scheme="my-app" android:host="my.example.app" /> // 这里要跟定义的URI对应上
   </intent-filter>
 </activity>

对外声明URI以后,就可以跨进程打开页面了,可以通过adb命令进行测试:

 adb shell am start -d "my-app://my.example.app/news/1234" -a android.intent.action.VIEW

还可以通过URI构建PendingIntent, 在通知栏消息通知等场景中点击打开应用中的Compose页面:

 val id = "1234"
 val context = LocalContext.current
 val deepLinkIntent = Intent(
    Intent.ACTION_VIEW,
    "my-app://my.example.app/news/$id".toUri(),
    context,
    MyActivity::class.java
 )

 val deepLinkPendingIntent: PendingIntent? = TaskStackBuilder.create(context).run {
    addNextIntentWithParentStack(deepLinkIntent)
    getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)
 }

另外,前面提到的compose-destinations导航库也支持DeepLink的使方式,具体可以查看:deeplinks

Navigation对ViewModel的支持

viewModel()androidx-lifecycle针对Compose提供的Composable方法,它通过 LocalViewModelStoreOwner.current 获取最近的 ViewModelStoreOwner ,可能是ActivityFragment, 在一个由 Composable 组成的单 Activity 应用中,相当于所有ViewModel都放在一起,所有的Compose页面共享ViewModel实例。

有时我们希望为每一个页面的Composable单独提供一个ViewModel实例,Navigation更容易做到这一点

class ExampleViewModule : ViewModel() {
    var _name = mutableStateOf("")
    val name = _name
}

@Composable
fun WorkWithViewModelExample() {
    val navController = rememberNavController()
    NavHost(navController, startDestination = "example") {
        composable("example") { backStackEntry -> 
            val exampleViewModel = viewModel<ExampleViewModel>()
            SomeScreen(exampleViewModel)
        }
        // ...
    }
}
@Composable
fun SomeScreen(viewModel: ExampleViewModel = viewModel()) {
}

每个 backStackEntry 都是一个 ViewModelStoreOwner,所以当前viewModel()函数创建的ViewModel单例只服务于当前页面,随着页面从回退栈中弹出,ViewModelStore被清空,所辖的ViewModel会执行onClear操作。

从 Compose 导航到其他 Fragment 页面

使用基于 fragmentNavigationCompose 导航,要在 Compose 代码内更改目的地,可以公开传递由层次结构中的任何可组合项触发的事件:

 @Composable
 fun MyScreen(onNavigate: (Int) -> ()) {
     Button(onClick = { onNavigate(R.id.nav_profile) } { /* ... */ }
 }

fragment 中,可以通过找到 NavController 实例并导航到目的地,在 Compose 和基于 fragmentNavigation 组件之间架起桥梁:

 override fun onCreateView( /* ... */ ) {
    setContent {
        MyScreen(onNavigate = { dest -> findNavController().navigate(dest) })
    }
 }

或者,可以将 NavController 传递到 Compose 层次结构下方。不过,公开简单的函数的可重用性和可测试性更高。

如果 Fragment 没有使用 Navigation 组件库,那么只能在Compose公开的回调函数中使用FragmentManager 进行跳转了(Compose属于当前的Fragment 中的View)。

从 Compose 导航到其他 Activity 页面

从 Compose 跳转到其他 Activity 页面就是启动Activity的代码,其实跟导航组件没有多大关系了,我们可以在Composable暴露出的点击事件函数中进行跳转:

@Composable
fun NavigationExample2() {
    val navController = rememberNavController()
    NavHost(navController = navController, startDestination = "Welcome") {
        composable("Welcome") {
            val context = LocalContext.current 
            WelcomeScreen { 
                val intent = Intent(context, OtherActivity::class.java).apply {
                    putExtra("name", "张三") 
                    putExtra("uid", 123)
                }
                context.startActivity(intent) 
            }
        }
    }
}
@Composable
fun WelcomeScreen(onClick: () -> Unit = {}) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("WelcomeScreen", fontSize = 20.sp)
        Button(onClick = onClick) {
            Text(text = "Go to Other")
        }
    }
}

如果是以startForResult的方式启动,最好是通过带回调接口的方式去启动,这样在回调接口中直接获取返回结果进行展示,否则只有在Composable所属的Activity的onActivityResult中处理再通过顶层组件传入,比较麻烦。

@Composable
fun NavigationExample2() {
    val navController = rememberNavController()
    NavHost(navController = navController, startDestination = "Welcome") {
        composable("Welcome") {
            val context = LocalContext.current
            var resultText by remember { mutableStateOf("") }
            WelcomeScreen(resultText) { 
                val intent = Intent(context, OtherActivity::class.java).apply {
                    putExtra("name", "张三") 
                    putExtra("uid", 123)
                }
                if (context is Activity) {
                	// 以回调方式启动Activity
                    ActivityStarter.startForResult(context, intent, object : ActivityResultListener {
                        override fun onSuccess(result: Result?) {
                            val name = result?.data?.getStringExtra("name")
                            val uid = result?.data?.getIntExtra("uid", -1)
                            resultText = "name: $name uid: $uid"
                        }
                        override fun onFailed(result: Result?) {
                        }
                    })
                }
            }
        }
    }
}
@Composable
fun WelcomeScreen(result: String, onClick: () -> Unit = {}) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("WelcomeScreen result: $result", fontSize = 20.sp)
        Button(onClick = onClick) {
            Text(text = "Go to Other")
        }
    }
}

另一种方式是当前Composable只需要监听ViewModel中的mutableStateOf的状态值或者监听StateFlow,而在onActivityResult中更新ViewModel或者StateFlow中的值,那么使用该值的Composable就会自动重组刷新。

更多关于 startForResult 方式启动Activity的内容请查看Jetpack Compose中的startActivityForResult的正确姿势


参考资料:

有关Jetpack Compose中的导航路由的更多相关文章

  1. ruby - 如何从 ruby​​ 中的字符串运行任意对象方法? - 2

    总的来说,我对ruby​​还比较陌生,我正在为我正在创建的对象编写一些rspec测试用例。许多测试用例都非常基础,我只是想确保正确填充和返回值。我想知道是否有办法使用循环结构来执行此操作。不必为我要测试的每个方法都设置一个assertEquals。例如:describeitem,"TestingtheItem"doit"willhaveanullvaluetostart"doitem=Item.new#HereIcoulddotheitem.name.shouldbe_nil#thenIcoulddoitem.category.shouldbe_nilendend但我想要一些方法来使用

  2. ruby - 其他文件中的 Rake 任务 - 2

    我试图在一个项目中使用rake,如果我把所有东西都放到Rakefile中,它会很大并且很难读取/找到东西,所以我试着将每个命名空间放在lib/rake中它自己的文件中,我添加了这个到我的rake文件的顶部:Dir['#{File.dirname(__FILE__)}/lib/rake/*.rake'].map{|f|requiref}它加载文件没问题,但没有任务。我现在只有一个.rake文件作为测试,名为“servers.rake”,它看起来像这样:namespace:serverdotask:testdoputs"test"endend所以当我运行rakeserver:testid时

  3. ruby-on-rails - Ruby net/ldap 模块中的内存泄漏 - 2

    作为我的Rails应用程序的一部分,我编写了一个小导入程序,它从我们的LDAP系统中吸取数据并将其塞入一个用户表中。不幸的是,与LDAP相关的代码在遍历我们的32K用户时泄漏了大量内存,我一直无法弄清楚如何解决这个问题。这个问题似乎在某种程度上与LDAP库有关,因为当我删除对LDAP内容的调用时,内存使用情况会很好地稳定下来。此外,不断增加的对象是Net::BER::BerIdentifiedString和Net::BER::BerIdentifiedArray,它们都是LDAP库的一部分。当我运行导入时,内存使用量最终达到超过1GB的峰值。如果问题存在,我需要找到一些方法来更正我的代

  4. ruby-on-rails - Rails 3 中的多个路由文件 - 2

    Rails2.3可以选择随时使用RouteSet#add_configuration_file添加更多路由。是否可以在Rails3项目中做同样的事情? 最佳答案 在config/application.rb中:config.paths.config.routes在Rails3.2(也可能是Rails3.1)中,使用:config.paths["config/routes"] 关于ruby-on-rails-Rails3中的多个路由文件,我们在StackOverflow上找到一个类似的问题

  5. ruby-on-rails - Rails - 一个 View 中的多个模型 - 2

    我需要从一个View访问多个模型。以前,我的links_controller仅用于提供以不同方式排序的链接资源。现在我想包括一个部分(我假设)显示按分数排序的顶级用户(@users=User.all.sort_by(&:score))我知道我可以将此代码插入每个链接操作并从View访问它,但这似乎不是“ruby方式”,我将需要在不久的将来访问更多模型。这可能会变得很脏,是否有针对这种情况的任何技术?注意事项:我认为我的应用程序正朝着单一格式和动态页面内容的方向发展,本质上是一个典型的网络应用程序。我知道before_filter但考虑到我希望应用程序进入的方向,这似乎很麻烦。最终从任何

  6. ruby-on-rails - Rails 3.2.1 中 ActionMailer 中的未定义方法 'default_content_type=' - 2

    我在我的项目中添加了一个系统来重置用户密码并通过电子邮件将密码发送给他,以防他忘记密码。昨天它运行良好(当我实现它时)。当我今天尝试启动服务器时,出现以下错误。=>BootingWEBrick=>Rails3.2.1applicationstartingindevelopmentonhttp://0.0.0.0:3000=>Callwith-dtodetach=>Ctrl-CtoshutdownserverExiting/Users/vinayshenoy/.rvm/gems/ruby-1.9.3-p0/gems/actionmailer-3.2.1/lib/action_mailer

  7. ruby-on-rails - Rails 应用程序中的 Rails : How are you using application_controller. rb 是新手吗? - 2

    刚入门rails,开始慢慢理解。有人可以解释或给我一些关于在application_controller中编码的好处或时间和原因的想法吗?有哪些用例。您如何为Rails应用程序使用应用程序Controller?我不想在那里放太多代码,因为据我了解,每个请求都会调用此Controller。这是真的? 最佳答案 ApplicationController实际上是您应用程序中的每个其他Controller都将从中继承的类(尽管这不是强制性的)。我同意不要用太多代码弄乱它并保持干净整洁的态度,尽管在某些情况下ApplicationContr

  8. ruby-on-rails - form_for 中不在模型中的自定义字段 - 2

    我想向我的Controller传递一个参数,它是一个简单的复选框,但我不知道如何在模型的form_for中引入它,这是我的观点:{:id=>'go_finance'}do|f|%>Transferirde:para:Entrada:"input",:placeholder=>"Quantofoiganho?"%>Saída:"output",:placeholder=>"Quantofoigasto?"%>Nota:我想做一个额外的复选框,但我该怎么做,模型中没有一个对象,而是一个要检查的对象,以便在Controller中创建一个ifelse,如果没有检查,请帮助我,非常感谢,谢谢

  9. ruby - rspec 需要 .rspec 文件中的 spec_helper - 2

    我注意到像bundler这样的项目在每个specfile中执行requirespec_helper我还注意到rspec使用选项--require,它允许您在引导rspec时要求一个文件。您还可以将其添加到.rspec文件中,因此只要您运行不带参数的rspec就会添加它。使用上述方法有什么缺点可以解释为什么像bundler这样的项目选择在每个规范文件中都需要spec_helper吗? 最佳答案 我不在Bundler上工作,所以我不能直接谈论他们的做法。并非所有项目都checkin.rspec文件。原因是这个文件,通常按照当前的惯例,只

  10. ruby-on-rails - active_admin 目录中的常量警告重新声明 - 2

    我正在使用active_admin,我在Rails3应用程序的应用程序中有一个目录管理,其中包含模型和页面的声明。时不时地我也有一个类,当那个类有一个常量时,就像这样:classFooBAR="bar"end然后,我在每个必须在我的Rails应用程序中重新加载一些代码的请求中收到此警告:/Users/pupeno/helloworld/app/admin/billing.rb:12:warning:alreadyinitializedconstantBAR知道发生了什么以及如何避免这些警告吗? 最佳答案 在纯Ruby中:classA

随机推荐