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() 来返回到上一个屏幕。

路由跳转

跳转Push

1
navController.navigate("screenMain")

跳转防重复

1
2
3
4
5
6
7
8
9
10
11
fun NavController.navigateSingle(route: String, popUpToRoute: String? = null) {
val currentRoute = currentBackStackEntry?.destination?.route
if (currentRoute != route) {
navigate(route) {
launchSingleTop = true
popUpToRoute?.let {
popUpTo(it) { inclusive = false }
}
}
}
}

使用

1
2
3
4
// 使用方式
navController.navigateSafe("detail")
// 或者带弹出操作
navController.navigateSafe("detail", "home")

替换路由Replace

直接调用

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
7
8
9
10
11
12
13
14
15
16
import androidx.navigation.NavController

// 定义扩展函数
fun NavController.replace(route: String) {
// 获取当前栈顶的路由
val currentRoute = currentBackStackEntry?.destination?.route
// 执行导航操作
navigate(route) {
if (currentRoute != null) {
// 弹出到当前路由,并将当前路由也从栈中移除
popUpTo(currentRoute) {
inclusive = true
}
}
}
}

调用

1
navController.replace("screenReport")

清空历史跳转登录

1
2
3
4
5
6
7
8
navController.navigate(MyScreen.ScreenLogin.route) {
// 关键1:清空所有历史页面(从栈底到当前页全部移除)
popUpTo(navController.graph.startDestinationId) {
inclusive = true
}
// 关键2:避免重复创建登录页实例(若已存在则复用)
launchSingleTop = true
}

关键在于 navigate 中的配置参数

通过以下两点实现 “清空历史”:

launchSingleTop

launchSingleTop = true
避免重复创建登录页实例(若登录页已在栈顶,则直接复用,防止栈内出现多个登录页)。

popUpToRoute

在 Navigation Compose 中,popUpToRoute(通常通过 popUpTo() 方法配置)的核心作用是管理导航栈中的页面回退行为,允许你在导航到新目的地时,将栈中某些已存在的页面弹出,从而控制导航历史的结构。

具体来说,它的主要用途包括:

清理导航栈
当你导航到新页面时,通过 popUpToRoute 可以指定一个目标路由,系统会将导航栈中该路由之上的所有页面全部弹出。
例如:从 A → B → C 导航到 D 时,如果设置 popUpTo("A"),栈会变成 A → D,中间的 B、C 被移除。

配合 inclusive 参数使用

  • inclusive = false(默认):仅弹出指定路由之上的页面,保留指定路由本身。
  • inclusive = true:弹出指定路由及其之上的所有页面(包括指定路由本身)。

路由传参

参数空字符串

传参不能是空字符串

传参不能是空字符串

传参不能是空字符串

空的话路由会报错。

所以一定要判断参数。

如果要传空字符串,我们可以传类似于”0”,组件中再判断。

例如

1
2
3
4
5
6
7
8
9
10
11
12
composable(
MyScreen.ScreenAicpMain.route,
arguments = listOf(
navArgument("subjectId") { type = NavType.StringType },
)
) {
var subjectId = it.arguments?.getString("subjectId") ?: ""
if (subjectId == "0") {
subjectId = ""
}
ScreenAicpMain(navController, subjectId)
}

单个参数

定义带参数的路径

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

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))

参数中带/

如果参数中带有/,路由跳转会报错。

处理方案:

  1. 传递参数时进行编码:使用 URLEncoder 对包含特殊字符的参数进行编码
  2. 接收参数时进行解码:使用 URLDecoder 对参数进行解码还原

编码

1
val encStr = URLEncoder.encode(imgUrl, "UTF-8")

解码

1
val decStr = URLDecoder.decode(imgUrl, "UTF-8")

参数编码

1
2
3
4
5
6
sealed class MyScreen(val route: String) {
data object ScreenMistakeEntry : MyScreen("screenMistakeEntry/{imgUrl}") {
fun createRoute(imgUrl: String) =
"screenMistakeEntry/${URLEncoder.encode(imgUrl, "UTF-8")}"
}
}

路由组件中解码

1
2
3
4
5
6
7
8
9
10
11
composable(
MyScreen.ScreenMistakeEntry.route, arguments = listOf(
navArgument("imgUrl") { type = NavType.StringType },
)
) { entry ->
var imgUrl = entry.arguments?.getString("imgUrl") ?: ""
if (imgUrl.isNotEmpty()) {
imgUrl = URLDecoder.decode(imgUrl, "UTF-8")
}
ScreenMistakeEntry(navController, imgUrl)
}

起始路由传参

使用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")
) {

}

页面返回传值

方式1

返回前页面传值

1
2
navController.previousBackStackEntry?.savedStateHandle?.set("picPath", it)
navController.popBackStack()

返回后的页面接收值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 获取当前页面的状态句柄
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
// 监听返回值变化
val picPath = savedStateHandle?.getStateFlow("picPath", "")?.collectAsState()

// 处理接收到的数据
LaunchedEffect(picPath) {
picPath?.let {
if (it.value.isNotEmpty()) {
// 处理返回的数据
vm.uploadPic(
filePath = it.value
) {
navController.navigate(MyScreen.ScreenMistakeEntry.createRoute(it))
}
// 清除已处理的返回值,避免页面重建时重复处理
savedStateHandle.set("picPath", "")
}
}
}

方式2

跳转前的页面

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
12
13
14
15
16
17
18
19
20
21
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
val picPathLiveData = savedStateHandle?.getLiveData<String>("picPath")
val picPathObserver = Observer<String>
{ value ->
if (value.isNotEmpty()) {
vm.uploadPic(
filePath = value
) {
navController.navigate(MyScreen.ScreenMistakeEntry.createRoute(it))
}

// 清除已处理的返回值,避免页面重建时重复处理
savedStateHandle?.set("picPath", "")
}
}
DisposableEffect(Unit) {
picPathLiveData?.observe(navController.currentBackStackEntry!!, picPathObserver)
onDispose {
picPathLiveData?.removeObserver(picPathObserver)
}
}

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

页面返回重建

A页面跳转到B页面,再返回到A页面,A页面会触发重建。

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中,即使重建也不影响页面。

页面可见状态监听

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
DisposableEffect(navController.currentBackStackEntry?.lifecycle) {
val lifecycle = navController.currentBackStackEntry?.lifecycle
val observer = LifecycleEventObserver
{ _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
// 页面可见
}

Lifecycle.Event.ON_PAUSE -> {
// 页面不可见
}

else -> {}
}
}

lifecycle?.addObserver(observer)

onDispose {
lifecycle?.removeObserver(observer)
}
}

返回确认

1
2
3
4
5
6
7
8
9
10
11
// 控制弹窗显示状态
var showQuitDialog by remember { mutableStateOf(false) }

// 1. 监听系统返回键:仅当页面可见时生效
BackHandler(
enabled = !showQuitDialog, // 弹窗显示时,暂时关闭监听(避免冲突)
onBack = {
// 2. 自定义返回逻辑:弹出确认弹窗
showQuitDialog = true
}
)

显示退出弹窗

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 3. 确认弹窗:点击「退出」则返回上一页,「取消」则关闭弹窗
if (showQuitDialog) {
AlertDialog(
onDismissRequest = { showQuitDialog = false },
title = { Text("提示") },
text = { Text("确定要返回上一页吗?") },
confirmButton = {
Button(onClick = {
showQuitDialog = false
// 手动触发返回上一页(若不调用,返回事件会被消费,页面不会回退)
navController.popBackStack()
}) {
Text("退出")
}
},
dismissButton = {
Button(onClick = { showQuitDialog = false }) {
Text("取消")
}
}
)
}

原来按钮事件中的

1
navController.popBackStack()

要改成

1
showQuitDialog = true