Jetpack Compose中使用路由导航Navigation Compose

前言

Navigation Compose 是 Jetpack Compose 中的一个导航库,它可以帮助你在基于 Compose 的 Android 应用中实现屏幕之间的导航。

这个就类似Vue中的vue-router,可以使用也可以不使用。

以下是使用 Navigation Compose 的详细步骤:

添加依赖

在你的项目的 build.gradle 文件中添加 Navigation Compose 的依赖:

1
2
3
4
5
dependencies {
implementation("androidx.navigation:navigation-compose:2.7.7")
// 动画扩展
implementation("com.google.accompanist:accompanist-navigation-animation:0.32.0")
}

路由导航

AppNavigation.kt

定义导航图

导航图是一个描述应用中所有可导航目的地的图。

可以使用 NavGraphBuilder 来定义导航图。

基本示例

以下是一个简单的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation

fun NavGraphBuilder.rootNavGraph(navController: NavHostController) {
navigation(
route = "root",
startDestination = "screenLogin"
) {
composable("screenLogin") {
ScreenLogin(navController)
}
composable("screenMain") {
ScreenMain(navController)
}
}
}

在这个示例中,我们定义了一个名为 root 的导航图,它的起始目的地是 screenLogin

导航图中有两个可导航的目的地:screenLoginscreenMain

使用路由常量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 定义路由常量(推荐使用密封类)
sealed class MyScreen(val route: String) {
data object ScreenLogin : MyScreen("screenLogin")
data object ScreenMain : MyScreen("screenMain")
}


fun NavGraphBuilder.rootNavGraph(navController: NavHostController) {
navigation(
route = "root",
startDestination = "screenLogin"
) {
composable(MyScreen.ScreenLogin.route) {
ScreenLogin(navController)
}

composable(MyScreen.ScreenMain.route) {
ScreenMain(navController)
}
}
}

创建导航宿主

导航宿主是一个 Composable,它负责显示当前的目的地。你可以使用 NavHost 来创建导航宿主。

基本示例

以下是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import androidx.compose.runtime.Composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.compose.NavHost

@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "root"
) {
rootNavGraph(navController)
}
}

在这个示例中,我们使用 rememberNavController 来创建一个 NavController,它负责管理导航状态。

然后,我们使用 NavHost 来创建导航宿主,并将 rootNavGraph 作为导航图传递给它。

跳转动画

1
2
3
4
5
6
7
8
9
10
11
12
13
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "root",
// 添加共享元素动画
enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left) },
exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right) }
) {
rootNavGraph(navController)
}
}

主Composable使用导航

最后,我们需要在主 Composable 中使用 AppNavigation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.xhkjedu.zxs_android.route.AppNavigation
import com.xhkjedu.zxs_android.ui.theme.AppAndroidTheme

@Composable
fun MyApp() {
AppAndroidTheme {
Surface(modifier = Modifier.fillMaxSize()) {
AppNavigation()
}
}
}

Activity设置主Composable

这样项目就需要一个Activity了。

在你的 Activity 中,使用 setContent 方法来设置 MyApp 作为内容:

1
2
3
4
5
6
7
8
9
10
11
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApp()
}
}
}

通过以上步骤,你就可以在你的 Jetpack Compose 应用中使用 Navigation Compose 来实现屏幕之间的导航了。

创建屏幕Composable

现在,我们需要创建每个屏幕的 Composable。

以下是 ScreenLoginScreenMain 的示例:

ScreenLogin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.navigation.NavController

@Composable
fun ScreenLogin(navController: NavController) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Screen 1")
Button(onClick = { navController.navigate("screenMain") }) {
Text(text = "Go to Screen 2")
}
}
}

ScreenMain

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.navigation.NavController

@Composable
fun ScreenMain(navController: NavController) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Screen 2")
Button(onClick = { navController.popBackStack() }) {
Text(text = "Go back to Screen 1")
}
}
}

ScreenLogin 中,我们使用 navController.navigate("screenMain") 来导航到 ScreenMain

ScreenMain 中,我们使用 navController.popBackStack() 来返回到上一个屏幕。

路由操作

跳转

1
navController.navigate("screenMain")

替换路由

1
2
3
4
5
6
7
8
navController.navigate("screenMain") {
val currentRoute = navController.currentBackStackEntry?.destination?.route
if (currentRoute != null) {
popUpTo(currentRoute) {
inclusive = true
}
}
}

关键原理说明

  • popUpTo(currentRoute):指定要弹出到哪个路由
  • inclusive = true:表示将 currentRoute 本身也从返回栈中移除
  • 这样新导航的页面会替代被弹出的页面,用户返回时会直接回到更早的页面

返回

1
navController.popBackStack()

返回到指定路由

1
navController.popBackStack(route = MyScreen.ScreenMain.route, inclusive = false)

带参数的导航

单个参数

定义带参数的路径

创建一个生成实际路由的方法,替换占位符

1
2
3
4
5
6
sealed class MyScreen {
data object Detail:Screen("detail/{id}") {
// 创建一个实际路由的方法,替换占位符
fun createRoute(id: String) = "detail/$id"
}
}

传递的参数不能是对象。

定义接收参数

在 NavHost 中定义可组合的屏幕,包括带参数的屏幕

1
2
3
4
5
6
7
8
9
10
11
12
13
// 详情屏幕(带参数)
composable(
route = MyScreen.Detail.route,
arguments = listOf(
navArgument("id") {
type = NavType.StringType
}
)
) { entry ->
// 从参数中获取id
val id = entry.arguments?.getString("id") ?: "" // 提供默认值防止空
DetailScreen(id, navController)
}

导航到带参数的屏幕

使用 createRoute 方法生成实际的路由字符串。

1
navController.navigate(MyScreen.Detail.createRoute("123"))

多个参数

定义带参数的路径

创建一个生成实际路由的方法,替换占位符

1
2
3
4
5
sealed class MyScreen {
data object ScreenVideoPlay : MyScreen("screenVideoPlay/{videoId}/{vtype}") {
fun createRoute(videoId: String, vtype: Int) = "screenVideoPlay/$videoId/$vtype"
}
}

传递的参数不能是对象。

定义接收参数

在 NavHost 中定义可组合的屏幕,包括带参数的屏幕

1
2
3
4
5
6
7
8
9
10
11
composable(
MyScreen.ScreenVideoPlay.route,
arguments = listOf(
navArgument("videoId") { type = NavType.StringType },
navArgument("vtype") { type = NavType.IntType }
)
) {
val videoId = it.arguments?.getString("videoId") ?: ""
val vtype = it.arguments?.getInt("vtype") ?: 0
ScreenVideoPlay(navController, videoId, vtype)
}

屏幕

1
2
3
4
@Composable
fun ScreenVideoPlay(navController: NavHostController, videoId: String, vtype: Int) {

}

导航到带参数的屏幕

使用 createRoute 方法生成实际的路由字符串。

1
navController.navigate(MyScreen.ScreenVideoPlay.createRoute("123",1))

起始路由传参

使用navController.navigate(MyScreen.ScreenSubjectQues.createRoute("1956278715385204738"))才可以。

正确写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "root",
// 添加共享元素动画
enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left) },
exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right) }
) {
rootNavGraph(navController)
}

navController.navigate(MyScreen.ScreenSubjectQues.createRoute("1956278715385204738"))
}

错误写法

startDestination中设置,虽然能跳转到指定页面,但是获取不到参数。

1
2
3
4
5
6
navigation(
route = "root",
startDestination = MyScreen.ScreenSubjectQues.createRoute("1956278715385204738")
) {

}

页面返回重建

Navigation Compose 在返回时会重新创建上一个页面的 Composable(这是官方设计的默认行为)。

这时候组件内的LaunchedEffect也会重新执行

1
2
3
LaunchedEffect(Unit) {

}

这和我们平时开发逻辑就不太一样。

我们第一想到就是怎样让返回不重建,但是这是错误的。

Navigation Compose 的设计理念就是通过重组更新 UI,强制阻止重建可能会导致状态不一致、内存泄漏等问题。

保留页面实例会增加内存消耗,尤其是在复杂页面或多页面场景下。

可以的方案是

使用 rememberSaveable 或 ViewModel 保存状态(即使页面重建也能恢复)是更合理的选择,既符合 Compose 范式,又能达到类似 “不重建” 的用户体验。

添加初始化的状态

1
2
3
class BaseViewModel : ViewModel() {
var isInit = false
}

页面中

1
2
3
4
5
6
7
LaunchedEffect(Unit) {
delay(200)
if (!mViewModel.isInit) {
mViewModel.getTopMenu()
mViewModel.isInit = true
}
}

这样把状态保留在ViewModel中,即使重建也不影响页面。

页面返回传值

我用的是NavHost定义导航图,竟然找不到怎么接收返回参数,以下记录一下
跳转前的页面

1
2
3
4
5
navController.currentBackStackEntry?.savedStateHandle
?.getLiveData<String>("picPath")
?.observe(navController.currentBackStackEntry!!) { value ->
Log.d("TAG", "ScreenSubjectQues: $value")
}

返回页面、返回回调参数

1
2
3
4
5
6
7
8
9
10
11
val picPathLiveData = navController.currentBackStackEntry?.savedStateHandle?.getLiveData<String>("picPath")
val picPathObserver = Observer<String> { value ->
Log.d("TAG", "ScreenLogin: $value")
}

DisposableEffect(Unit) {
picPathLiveData?.observe(navController.currentBackStackEntry!!, picPathObserver)
onDispose {
picPathLiveData?.removeObserver(picPathObserver)
}
}

注意要在页面销毁的时候移除观察者,否则回调会触发多次。