A
第 50 章KOTLIN40 分钟

Compose Navigation 导航与传参

Jetpack Navigation for Compose 实战:NavHost 与 composable 目标、NavController 管理、必选/可选参数传递、嵌套 NavGraph、底部导航栏集成与深度链接 deepLink 接入。

学习目标

  • 搭建 NavHost 与 composable 目标,掌握路由与目的地概念
  • 熟练使用 NavController 管理转场与回退栈
  • 传递必选参数与可选参数(含类型转换与默认值)
  • 使用嵌套 NavGraph 组织多级导航
  • 集成底部导航栏并接入深度链接 deepLink

学习目标

  • 搭建 NavHost 与 composable 目标,掌握路由与目的地概念;
  • 熟练使用 NavController 管理转场与回退栈;
  • 传递必选参数与可选参数(含类型转换与默认值);
  • 使用嵌套 NavGraph 组织多级导航;
  • 集成底部导航栏并接入深度链接 deepLink。

Compose Navigation 导航与传参

androidx.navigation:navigation-compose 把 Navigation 库移植到 Compose:用 NavHost 描述“哪些 composable 是路由目的地”,用 NavController 在它们之间跳转。它仍是基于回退栈的导航,思路与第 13 章的 Intent 导航类似,但更声明式、更适配 Compose 的“状态驱动”思想。

依赖配置

dependencies {
    val composeBom = platform("androidx.compose:compose-bom:2024.09.03")
    implementation(composeBom)
    implementation("androidx.compose.material3:material3")
    implementation("androidx.navigation:navigation-compose:2.8.2")
    // 用于在 ViewModel 中通过 SavedStateHandle 读取参数
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
}

NavHost 是导航容器,它内部渲染“当前目的地对应的 Composable”。每个目的地用 composable("route") { ... } 注册:

import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController

@Composable
fun AppNavHost() {
    val navController = rememberNavController()
    NavHost(
        navController = navController,
        startDestination = "home"
    ) {
        composable("home") {
            HomeScreen(onOpenDetail = { id ->
                navController.navigate("detail/$id")
            })
        }
        composable("detail/{id}") { backStackEntry ->
            val id = backStackEntry.arguments?.getString("id")
            DetailScreen(id)
        }
    }
}

要点:

  • rememberNavController() 在 Composable 中获取并缓存 NavController,配置变更后仍指向同一实例;
  • startDestination 是初始路由字符串;
  • composable("route") 声明路由对应的可组合函数,块内能拿到 NavBackStackEntry,从中读取参数、SavedStateHandle、ViewModel 等。

NavController 是导航的操作入口:

// 普通跳转
navController.navigate("detail/42")

// 跳转并配置 popUpTo,避免回退栈无限增长
navController.navigate("home") {
    popUpTo("home") { inclusive = true }   // 清空到 home(含)
    launchSingleTop = true                 // 栈顶是 home 时复用
}

// 返回
navController.popBackStack()

// 判断能否返回(如根栈底)
if (navController.previousBackStackEntry != null) {
    navController.popBackStack()
}

navController.navigate(route) { ... } 的 lambda 里可以配置:

选项 作用
popUpTo(route) { inclusive } 跳转前弹出栈直到 route
launchSingleTop = true 栈顶已是目标则不重复入栈
restoreState = true 恢复上次弹出的目的地的 SavedState
enterTransition / exitTransition 自定义动画

经典“底部 Tab 切换”模式:

navController.navigate(tab.route) {
    popUpTo(graph.findStartDestination().id) { saveState = true }
    launchSingleTop = true
    restoreState = true
}

saveState=true/restoreState=true 组合使每个 Tab 的状态独立保留,切换 Tab 时各自的滚动位置/输入内容不会丢。

NavController 必须在 Composable 中获取并放在 NavHost 外层,不要在事件回调里临时 rememberNavController()——那样得到的实例不一致。

参数传递

必选参数

在路由模板里用 {} 占位,目的地用 arguments 声明类型:

composable(
    route = "detail/{itemId}",
    arguments = listOf(navArgument("itemId") { type = NavType.StringType })
) { entry ->
    val itemId = entry.arguments?.getString("itemId")
    DetailScreen(itemId!!)
}

跳转时把值拼进字符串:

navController.navigate("detail/${item.id}")

可选参数与默认值

可选参数用 ?{name}={value} 形式,并在 navArgument 中给默认值:

composable(
    route = "list?filter={filter}&sort={sort}",
    arguments = listOf(
        navArgument("filter") {
            type = NavType.StringType
            defaultValue = "all"           // 必须有默认值才算可选
            nullable = true
        },
        navArgument("sort") {
            type = NavType.IntType
            defaultValue = 0
        }
    )
) { entry ->
    val filter = entry.arguments?.getString("filter")
    val sort = entry.arguments?.getInt("sort") ?: 0
    ListScreen(filter, sort)
}

跳转:

navController.navigate("list?filter=hot&sort=1")
// 或省略可选参数,使用默认值
navController.navigate("list")

URL 中拼接字符串参数要做 URLEncoder.encode(...),避免特殊字符破坏路由。

通过 ViewModel 读取参数

NavBackStackEntry 也是 ViewModelStoreOwner,可以创建目的地级作用域的 ViewModel,并在其中用 SavedStateHandle 直接读取参数:

class DetailViewModel(
    savedStateHandle: SavedStateHandle
) : ViewModel() {
    val itemId: String = checkNotNull(savedStateHandle["itemId"])
    // 或动态监听:savedStateHandle.getStateFlow("itemId", "")
}

@Composable
fun DetailScreen(itemId: String) {
    val vm: DetailViewModel = viewModel(
        factory = SavedStateViewModelFactory(null, /* entry */)
    )
    // ...
}

在 composable {} 块内调用 viewModel() 会自动绑定到当前 NavBackStackEntry,因此不同目的地的 ViewModel 是相互独立的。

嵌套 NavGraph:navigation

复杂应用常把多页面按模块分组。navigation(startDestination, route) 可以在 NavHost 内嵌套一个子图:

NavHost(navController, startDestination = "home") {
    composable("home") { HomeScreen() }

    // 订单模块子图
    navigation(startDestination = "order/list", route = "order") {
        composable("order/list") { OrderListScreen() }
        composable("order/detail/{id}") { entry ->
            OrderDetailScreen(entry.arguments?.getString("id"))
        }
        composable("order/tracking/{id}") { entry ->
            TrackingScreen(entry.arguments?.getString("id"))
        }
    }

    // 个人中心子图
    navigation(startDestination = "profile/main", route = "profile") {
        composable("profile/main") { ProfileScreen() }
        composable("profile/settings") { SettingsScreen() }
    }
}

跳转到子图入口或子图内任意目的地都允许:

navController.navigate("order")              // 进入 order/list
navController.navigate("order/detail/O-001") // 直接进入 tracking

子图的好处:

  • 跨模块共享 popUpTo 边界(如 popUpTo("order") { inclusive = true } 一键返回订单列表);
  • 配合 navigation 作为 Tab 根,方便整组切换。

底部导航栏集成

底部导航是导航库最经典的搭配。结构是 Scaffold(bottomBar = { ... }) + NavHost,状态由 Compose 的“当前路由”驱动:

@Composable
fun MainScreen() {
    val navController = rememberNavController()
    val items = listOf(
        BottomItem("home", "首页", Icons.Default.Home),
        BottomItem("order/list", "订单", Icons.Default.List),
        BottomItem("profile/main", "我的", Icons.Default.Person)
    )
    val backStack by navController.currentBackStackEntryAsState()
    val currentRoute = backStack?.destination?.route

    Scaffold(
        bottomBar = {
            NavigationBar {
                items.forEach { item ->
                    NavigationBarItem(
                        selected = currentRoute == item.route ||
                            currentRoute?.startsWith(item.route.split("/").first()) == true,
                        onClick = {
                            if (currentRoute != item.route) {
                                navController.navigate(item.route) {
                                    popUpTo(navController.graph.findStartDestination().id) {
                                        saveState = true
                                    }
                                    launchSingleTop = true
                                    restoreState = true
                                }
                            }
                        },
                        icon = { Icon(item.icon, null) },
                        label = { Text(item.label) }
                    )
                }
            }
        }
    ) { padding ->
        AppNavHost(navController, Modifier.padding(padding))
    }
}

要点:

  • currentBackStackEntryAsState() 把回退栈顶部变成 Compose State,底部栏自动响应路由变化;
  • 用 popUpTo + saveState/restoreState 实现“切换 Tab 时各自保留状态”;
  • selected 计算时要兼容子路由(如 order/list 与 order/detail 都属于“订单” Tab)。

深度链接让外部(通知、其他应用、URL)直接进入指定目的地。Compose Navigation 同时支持 App Links(http/https)和自定义 scheme。

composable(
    route = "detail/{itemId}",
    arguments = listOf(navArgument("itemId") { type = NavType.StringType }),
    deepLinks = listOf(
        navDeepLink { uriPattern = "myapp://item/{itemId}" },
        navDeepLink { uriPattern = "https://example.com/items/{itemId}" }
    )
) { entry ->
    DetailScreen(entry.arguments?.getString("itemId"))
}

uriPattern 中的 {itemId} 会自动绑定到对应 navArgument。

处理 Intent

在 Activity 中接收 Intent 并交给 NavController:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        val navController = rememberNavController()
        AppNavHost(navController)
        LaunchedEffect(Unit) {
            navController.handleDeepLink(intent)
        }
    }
}

更完整的做法是用 com.google.androidbrowser:helper + android-app-links 验证,但 handleDeepLink 是 Compose 集成的入口。

Pending Intent:通知点击跳转

fun buildNotificationIntent(context: Context, itemId: String): PendingIntent {
    val deepLink = "myapp://item/$itemId".toUri()
    val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
    val bundle = navController.createDeepLink().uri(deepLink).createPendingIntent(flags) as? Bundle
    return TaskStackBuilder.create(context).let { it.addNextIntentWithParentStack(intent); it.getPendingIntent(0, flags)!! }
}

简化版可用 NavDeepLinkBuilder:

val pendingIntent = NavDeepLinkBuilder(context)
    .setGraph(R.navigation.nav_graph)
    .setDestination(R.id.detailFragment)
    .setArguments(bundleOf("itemId" to id))
    .createPendingIntent()

在纯 Compose 项目里,nav_graph.xml 通常不存在。可以直接构造 PendingIntent,让 Intent 带上 data URI,然后在 Activity 里交给 navController.handleDeepLink(intent)。

实战:可传参的多模块导航骨架

@Composable
fun AppNavHost(navController: NavHostController, modifier: Modifier = Modifier) {
    NavHost(navController, startDestination = "home", modifier = modifier) {
        composable("home") {
            HomeScreen(
                onOpenItem = { id -> navController.navigate("detail/$id") },
                onOpenOrders = { navController.navigate("order") }
            )
        }
        composable(
            route = "detail/{itemId}",
            arguments = listOf(navArgument("itemId") { type = NavType.StringType }),
            deepLinks = listOf(navDeepLink { uriPattern = "myapp://item/{itemId}" })
        ) { entry ->
            DetailScreen(
                itemId = entry.arguments?.getString("itemId").orEmpty(),
                onBack = { navController.popBackStack() }
            )
        }
        navigation(startDestination = "order/list", route = "order") {
            composable("order/list") {
                OrderListScreen(onOpenDetail = { id -> navController.navigate("order/detail/$id") })
            }
            composable(
                route = "order/detail/{id}",
                arguments = listOf(navArgument("id") { type = NavType.StringType })
            ) { entry ->
                OrderDetailScreen(
                    id = entry.arguments?.getString("id").orEmpty(),
                    onBack = { navController.popBackStack() }
                )
            }
        }
    }
}

外部发送 myapp://item/abc123 即可直达详情页;订单子图内部互相跳转不影响首页栈。

常见坑与最佳实践

  1. 在事件回调里临时 rememberNavController():得到的实例与 NavHost 不一致,跳转不生效。NavController 必须在 Composable 作用域取得并下传。
  2. 路由字符串里没占位符但传了参数:composable("detail") 后调用 navigate("detail/42") 会路由不匹配崩溃。模板与跳转字符串必须严格对应。
  3. 可选参数没设 defaultValue:被识别为必选,navigate("list") 不带参时崩溃。
  4. popUpTo 滥用导致栈被清空:根路由 popUpTo 加 inclusive = true 会让回退栈空掉,再按返回会直接退出。
  5. 底部 Tab 切换没开 saveState/restoreState:每次切换 Tab 都重建,输入框内容丢失。
  6. deepLink 的 uriPattern 与 navArgument 名称不一致:参数绑不上,运行时拿到 null。
  7. NavController 放到 ViewModel:NavController 是 UI 层路由器,不要交给架构层。状态导航用路由的 StateFlow,UI 层再调用。
  8. 在 Composable 块里直接 navigate:每次重组都触发跳转。应放在事件回调(按钮 onClick)里。
  9. 多个目的地用同一 route:路由冲突,运行时崩溃。路由字符串要全局唯一。
  10. 未配置 Manifest 的 intent-filter:http/https 深度链接在浏览器或其他应用打开时不会被你的 App 拦截。要在 AndroidManifest.xml 中为 Activity 声明 <intent-filter> 与 android:autoVerify="true"(App Links)。

章节小结

本章覆盖了 Compose 版 Navigation 的全部核心:用 NavHost + composable 描述目的地,用 NavController 跳转与回退;必选参数用 {name} 占位、可选参数用 ?name=value 与 defaultValue;navigation {} 组织嵌套子图便于模块化;底部导航通过 currentBackStackEntryAsState 把路由变化驱动 NavigationBar,saveState/restoreState 保证各 Tab 状态独立;深度链接通过 navDeepLink + handleDeepLink 接入外部入口。配合 ViewModel + SavedStateHandle,导航参数可被目的地级 ViewModel 直接消费。

下一章预告

下一章进入动画与手势领域:animate*AsState、Crossfade、AnimatedContent、updateTransition、Modifier.animateContentSize、rememberInfiniteTransition 等声明式动画 API,以及 pointerInput/detectDragGestures/draggable/swipeable 等手势处理。