引言
单 Activity 架构成为主流之后,Compose 应用里的「页面跳转」不再是一句 startActivity,而是 NavHost 内部目的地(destination)之间的切换。Navigation Compose 把返回栈、参数传递、深层链接与状态恢复统一收拢到 NavController 这一个入口,代价是路由从「一行代码」升级成需要设计的架构组件。
真正难的不是把页面接起来,而是三件事:第一,返回栈在嵌套图、底部导航与系统返回手势三者叠加时仍然符合直觉;第二,参数在类型层面可校验,而不是靠字符串拼接后在运行时抛 IllegalArgumentException;第三,外部 URL 打开应用时能落到正确的页面,且用户按返回键能回到入口而不是直接退出。
Navigation 2.8 引入的类型安全路由把第二件事解决得相当彻底:路由从字符串变成 @Serializable 数据类,参数通过构造函数传递,拼写与类型错误在编译期就能发现。返回栈的语义则仍然需要开发者自己理解,框架只提供原语。本文按「结构 → 路由 → 参数 → 深链 → 返回栈 → 集成」的顺序展开,每节都给出可直接落地的写法,最后用一张取舍表说明什么场景该选哪种路由方案。文中涉及的 UI 组织方式可对照 Jetpack Compose 声明式 UI 开发
一文。
目录
- 依赖与整体结构
- NavHost 与 NavController
- 类型安全路由
- 参数传递与结果回传
- 嵌套图与模块化
- 底部导航与多返回栈
- Deep Link 与隐式 Intent 映射
- 返回栈管理与状态恢复
- 与 ViewModel 的作用域绑定
- 转场动画与预测式返回
- 测试与调试
1. 依赖与整体结构
类型安全路由依赖 kotlinx.serialization 的编译器插件,缺了它 @Serializable 不会生成序列化器,路由会退化成 Any 类型的反射查找并在运行时失败。
// app/build.gradle.kts
plugins {
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21"
}
dependencies {
implementation("androidx.navigation:navigation-compose:2.8.4")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
}
整体结构可以拆成三层,三层的改动频率完全不同:
| 层 | 产物 | 位置 | 改动频率 |
|---|---|---|---|
| 路由定义 | @Serializable 数据类 | 各 feature 模块 | 低,等于 API 契约 |
| 图定义 | NavGraphBuilder 扩展函数 | 各 feature 模块 | 中,页面增删时改 |
| 导航调用 | navigate() / popBackStack() | Composable 内部 | 高,交互驱动 |
把「路由定义」单独抽成一层的好处是:深链、通知点击、Widget 跳转都只需要引用同一份类型,不用到处复制字符串常量——字符串路由时代那种 "profile/$id" 拼接散落十几处的局面,正是类型安全路由要消灭的对象。
2. NavHost 与 NavController
NavHost 负责声明图结构,NavController 是运行时句柄。两者在 Compose 中的标准写法如下。
@Composable
fun AppNavHost(navController: NavHostController = rememberNavController()) {
NavHost(
navController = navController,
startDestination = Home,
) {
composable<Home> {
HomeScreen(onOpenProfile = { id -> navController.navigate(Profile(id)) })
}
}
}
三条必须记住的规则:
rememberNavController()内部用rememberSaveable保存返回栈,配置变更(旋转屏幕、深色模式切换)后会自动恢复,不需要自己处理。NavHost必须在任何navigate()调用之前完成一次组合,否则抛Navigation graph has not been set。在LaunchedEffect里导航是安全的,在Application.onCreate里导航不是。- 观察当前目的地只能用
currentBackStackEntryAsState(),它会把NavBackStackEntry转成 Compose 的State,栈变化时触发重组。
val backStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = backStackEntry?.destination
不要自己持有 NavHostController:它持有 Context 与 Lifecycle,存进 ViewModel 或单例会跨配置变更指向已销毁的宿主。正确做法是把 navController 作为参数往下传,或用 (Profile) -> Unit 这类回调把导航意图上抛给 NavHost 所在层。
3. 类型安全路由
Navigation 2.8 的核心变化是「路由即类型」。目的地用一个 @Serializable 类或对象表示,参数就是它的构造属性。
@Serializable data object Home
@Serializable data object Settings
@Serializable data class Profile(val id: Long, val tab: String = "posts")
@Serializable data class Search(val query: String, val filters: List<String> = emptyList())
注册与取值:
composable<Profile> { entry ->
val profile = entry.toRoute<Profile>() // 自动反序列化,类型由泛型决定
ProfileScreen(id = profile.id, tab = profile.tab)
}
导航时直接传对象,不再拼字符串:
navController.navigate(Profile(id = 42, tab = "replies"))
参数类型与 URI 的映射规则值得单独记一下,它决定了哪些类型能直接当路由参数:
| Kotlin 类型 | URI 表现 | 说明 |
|---|---|---|
Int / Long | /profile/42 | 路径段,走内置 NavType |
String | /search?query=compose | 查询参数,需 URL 编码 |
Boolean | ?enabled=true | 内置 NavType |
| 枚举 | 名称字符串 | 需 @Serializable,2.8 内置支持 |
List<String> | ?filters=a&filters=b | 走 CollectionNavType |
嵌套 @Serializable 类 | 自定义编码 | 需要自定义 NavType |
有两点容易踩:
- 默认值即可选参数。
val tab: String = "posts"意味着该字段可以不出现在 URI 里,toRoute<Profile>()会补上默认值。把默认值去掉就变成必填。 equals决定目的地身份。数据类的结构相等意味着Profile(42)与Profile(42, "posts")若默认值相同则相等;launchSingleTop会据此判断是否重复入栈。
遇到 Instant、UUID 这类没有内置支持的字段,需要实现 NavType 并通过 typeMap 挂到路由上。实践建议是能不用就不用:把 UUID 在路由里存成 String,进页面后再解析,代码量少一半,出错面也小一半。
4. 参数传递与结果回传
路由参数的定位是「标识 + 少量控制项」,不是数据传输通道。它们会被序列化进返回栈、写进 SavedState,并在进程重建时再次解析。传一个完整的列表或大对象进路由(navigate(SearchResult(items = bigList))),等于把这些数据塞进系统托管的 Bundle,随时可能撞上 TransactionTooLargeException;正确做法是只传标识(SearchResult(queryId = "q-8842")),数据由数据层按 id 提供。
结果回传有两种方式。传统做法是借用 savedStateHandle:
// 结果页:写回上一个返回栈条目
navController.previousBackStackEntry
?.savedStateHandle
?.set("picked_id", 42L)
navController.popBackStack()
// 发起页:以 StateFlow 形式观察,避免重复消费
val handle = navController.currentBackStackEntry?.savedStateHandle
val pickedId by handle
?.getStateFlow("picked_id", -1L)
?.collectAsStateWithLifecycle()
?: remember { mutableStateOf(-1L) }
getStateFlow 相比 getLiveData 的优势是它能被 Compose 直接消费,且值会被保存,页面重建后仍能读到。但字符串 key 没有类型校验,建议统一收敛成 const val 或用扩展函数封装。
更现代的做法是把「结果」提升为状态:写入共享的 ViewModel(同一子图作用域)或持久层(DataStore、Room),发起页观察状态而不是等待一次性事件。
| 方式 | 类型安全 | 生命周期 | 适用场景 |
|---|---|---|---|
savedStateHandle | 否 | 单个返回栈条目 | 一次性选择结果 |
| 共享 ViewModel | 是 | 图(graph)作用域 | 同图内多页共享 |
| Repository / DataStore | 是 | 应用级 | 需要持久化的结果 |
| 导航返回路由 + 参数 | 是 | 返回栈条目 | 简单确认类回传 |
5. 嵌套图与模块化
navigation<T> 用来声明子图,它既是一层返回栈边界,也是一层作用域边界。
NavHost(navController, startDestination = Home) {
composable<Home> { HomeScreen() }
navigation<CheckoutGraph>(startDestination = Cart) {
composable<Cart> { CartScreen() }
composable<Payment> { PaymentScreen() }
composable<OrderDone> { DoneScreen() }
}
}
子图的价值在返回键行为上最直观:用户从 Cart 走到 Payment 再按返回,会先在子图内部回退;只有走到子图起点再返回才会离开子图。对「结算」这类多步骤流程,这正好是期望的语义。
模块化项目里,每个 feature 模块导出一个 fun NavGraphBuilder.checkoutGraph(navController: NavController) 扩展函数,app 模块只负责拼装。这样模块之间不互相依赖,只依赖各自的路由定义(放在 core:navigation 这类公共模块里),与 Android 多模块架构与 Gradle 优化
中的依赖分层思路一致:路由类型是跨模块的公共契约,图构建函数是各模块的私有实现。
6. 底部导航与多返回栈
底部导航的核心诉求是「每个 tab 保留自己的返回栈」。官方推荐写法依赖三个选项配合:
fun NavHostController.navigateToTab(route: Any) {
navigate(route) {
popUpTo(graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
三个选项各解决一个问题,缺一不可:
popUpTo(startDestination) { saveState = true }:把当前 tab 的栈存起来,而不是丢弃,避免栈无限增长。restoreState = true:切回某个 tab 时恢复它之前保存的栈,用户回到的是离开时的位置。launchSingleTop = true:重复点击同一个 tab 不会重复入栈。
选中态判断不能用字符串比较,要用 hasRoute:
val selected = currentDestination?.hierarchy?.any { it.hasRoute(Profile::class) } == true
hierarchy 会沿嵌套图向上遍历,因此子图内的页面也能正确高亮它所属的 tab。这一点在字符串路由时代需要手动维护「路由前缀 → tab」的映射表,是类型安全路由顺带解决的另一个问题。
7. Deep Link 与隐式 Intent 映射
深链需要两处声明:AndroidManifest.xml 里的 intent-filter,以及路由上的 deepLinks。
<activity android:name=".MainActivity" android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="example.com" android:pathPrefix="/profile" />
</intent-filter>
</activity>
composable<Profile>(
deepLinks = listOf(
navDeepLink<Profile>(basePath = "https://example.com/profile"),
navDeepLink<Profile>(basePath = "myapp://profile"),
),
) { entry ->
ProfileScreen(entry.toRoute<Profile>().id)
}
navDeepLink<T>(basePath = ...) 会自动把类型安全路由的字段映射成路径段或查询参数,规则与前面的类型映射表一致。autoVerify="true" 配合域名下的 /.well-known/assetlinks.json 才能让 App Links 免选择器直接进入应用;缺少验证文件时系统会弹出选择器,体验差一截。
本地验证深链有两条命令:
adb shell am start -W -a android.intent.action.VIEW -d "https://example.com/profile/42"
adb shell pm verify-app-links --re-verify com.example.app # 重新拉取 assetlinks 校验
7.1 隐式 Intent 与合成返回栈
在 MainActivity 里把外部 Intent 交给 NavController:
class MainActivity : ComponentActivity() {
private val navController by lazy { NavHostController(this) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
navController.handleDeepLink(intent) // 深链入口
setContent { AppNavHost(navController) }
}
}
深链进入时,Navigation 会依据 <deepLink> 的层级关系合成一个返回栈:如果深链目标是 CheckoutGraph 里的 Payment,栈会被补成 Home → Cart → Payment,用户按返回键回到 Cart 而不是直接退出应用。合成规则来自图的嵌套结构,所以把页面挂进正确的子图,深链体验就自动正确。需要程序化构造返回栈(如通知点击)时用 NavDeepLinkBuilder 配 createPendingIntent(),它同样基于图结构生成栈。
8. 返回栈管理与状态恢复
返回栈的操作原语不多,但语义容易混淆:
| 操作 | 语义 | 注意 |
|---|---|---|
navigate(route) | 入栈并跳转 | 目标已在栈中时会再入一次 |
popBackStack() | 弹出当前条目 | 返回 Boolean,栈空时返回 false |
popUpTo<T> { inclusive } | 弹出到某目的地 | inclusive = true 连它一起弹 |
navigate(route) { launchSingleTop = true } | 栈顶同目的地则复用 | 配合 tab 切换 |
状态恢复分两个层次。UI 层的临时状态用 rememberSaveable,它只能保存基本类型与 Parcelable,复杂对象需要自定义 Saver。页面级的业务状态放 SavedStateHandle:
class ProfileViewModel(handle: SavedStateHandle) : ViewModel() {
private val profile: Profile = handle.toRoute<Profile>() // 直接取路由参数
val tab = handle.getStateFlow("tab", profile.tab)
}
SavedStateHandle.toRoute<T>() 是 2.8 新增的能力:ViewModel 不必再从 Fragment 的 arguments 里手动取参数,映射由框架完成。进程被杀后系统会重建返回栈结构,但只重建「栈」本身,页面数据需要靠 SavedStateHandle 或持久层补回——这正是「路由参数只放标识」的原因:标识可以重建数据,大对象不能。
9. 与 ViewModel 的作用域绑定
每个 NavBackStackEntry 都实现了 ViewModelStoreOwner 与 SavedStateRegistryOwner,因此每个目的地天然拥有独立的 ViewModel 作用域。
composable<Profile> { entry ->
// 默认绑定最近的 NavBackStackEntry
val vm: ProfileViewModel = hiltViewModel()
ProfileScreen(vm)
}
hiltViewModel() 取到的 ViewModelStoreOwner 就是当前的返回栈条目,目的地出栈时 onCleared() 被调用,协程与资源一并释放。这解决了 Fragment 时代最头疼的「页面级状态泄漏」问题。
同一子图内共享数据时,显式传入父条目:
composable<Cart> { entry ->
val parentEntry = remember(entry) { navController.getBackStackEntry<CheckoutGraph>() }
val checkoutVm: CheckoutViewModel = hiltViewModel(parentEntry) // 图作用域
CartScreen(checkoutVm)
}
作用域层级与 Hilt 的组件层级是同一套心智模型,可对照 Android 依赖注入与 Hilt
中的 Component 表理解:NavBackStackEntry 约等于 ViewModelComponent 的一次实例化。
10. 转场动画与预测式返回
转场动画既可以在 NavHost 上全局声明,也可以逐目的地覆盖。
NavHost(
navController = navController,
startDestination = Home,
enterTransition = { slideInHorizontally { it / 4 } + fadeIn() },
exitTransition = { fadeOut() },
popEnterTransition = { fadeIn() },
popExitTransition = { slideOutHorizontally { it / 4 } + fadeOut() },
) { /* ... */ }
enter/exit 是入栈方向,popEnter/popExit 是出栈方向,四者成对出现才不会出现「进去有动画、返回没有」的不对称。逐目的地覆盖时在 composable<T>(enterTransition = ...) 里传,适合底部导航这种不需要横移动画的场景。
Android 14 的预测式返回手势(Predictive Back)需要在 manifest 的 <application> 上开启 android:enableOnBackInvokedCallback="true"。开启后 Navigation 会自动接入系统返回手势,并提供 progress 供动画跟随手指位移;未开启时返回是「一次性」的,用户看不到预览,体验明显落后于系统应用。这项能力与 Compose 的重组机制配合时要注意:动画读取的是 Transition 的 State,读取位置同样影响重组范围,相关原则见 Compose 状态管理与重组优化
。
11. 测试与调试
TestNavHostController 可以在不启动 Activity 的情况下注入图并断言栈状态:
@get:Rule val composeRule = createComposeRule()
@Test
fun clickCard_navigatesToProfile() {
lateinit var navController: TestNavHostController
composeRule.setContent {
navController = TestNavHostController(LocalContext.current).apply {
navigatorProvider.addNavigator(ComposeNavigator())
}
AppNavHost(navController)
}
composeRule.onNodeWithText("查看详情").performClick()
composeRule.runOnIdle {
assertEquals(Profile::class, navController.currentBackStackEntry?.destination?.route)
}
}
断言目的地类型用 hasRoute 而不是比较字符串,这样重构路由类名时测试不会误报。深链测试可以走 TestNavHostController.handleDeepLink(intent),验证合成返回栈是否符合预期。线上调试则用 NavController.addOnDestinationChangedListener 记录页面曝光,注意把它注册在 DisposableEffect 里并在 onDispose 中移除,否则会重复注册并泄漏。
权衡取舍
| 方案 | 类型安全 | 深链支持 | 返回栈控制力 | 适用场景 |
|---|---|---|---|---|
| 字符串路由(Navigation 2.7 及更早) | 否 | 手写 uriPattern | 完整 | 存量项目,迁移成本高 |
| 类型安全路由(2.8+) | 是 | navDeepLink<T> 自动映射 | 完整 | 新项目首选 |
| 单 Activity + 自研栈 | 自行保证 | 自行实现 | 完全自定义 | 极端定制(如编辑器多文档) |
| 多 Activity | 无 | manifest 原生支持 | 交给系统 | 与外部应用耦合深的场景 |
选择顺序建议是:新项目直接用类型安全路由;存量项目在新增页面时逐页迁移,两套 API 可以共存于同一张图;只有「返回栈语义与标准模型根本不同」的场景才值得自研。
常见坑清单
| 坑 | 现象 | 规避方式 |
|---|---|---|
| 忘记 serialization 插件 | 运行时找不到序列化器 | 加 kotlin.plugin.serialization |
| 把大对象塞进路由参数 | TransactionTooLargeException | 只传标识,数据由数据层按 id 取 |
tab 切换漏了 restoreState | 切回 tab 丢失滚动位置 | 三个选项一起加 |
| 用字符串比较判断选中态 | 嵌套图内高亮失效 | 用 destination.hierarchy.any { it.hasRoute(T::class) } |
在 Application 里导航 | Navigation graph has not been set | 导航只在组合完成后触发 |
| 深链页面未挂进子图 | 返回键直接退出应用 | 挂进正确的 navigation<T> 子图 |
popBackStack() 不判返回值 | 栈空时误判为成功 | 检查布尔返回值 |
忘记 autoVerify | App Links 弹选择器 | 配置 assetlinks.json 并 pm verify-app-links |
只声明 enterTransition | 返回无动画,体验不对称 | 四类转场成对声明 |
| ViewModel 绑错作用域 | 页面出栈后状态未清理 | hiltViewModel() 用当前条目,共享时显式传父条目 |
小结
Navigation 的心智模型可以压缩成四句话:
- 路由是类型,不是字符串:
@Serializable数据类承担契约职责,编译期校验替换运行时崩溃。 - 返回栈靠选项控制:
popUpTo + saveState + restoreState + launchSingleTop是 tab 场景的标准组合,四个选项各有明确职责。 - 深链依赖图结构:页面挂进正确的子图,合成返回栈就自动正确,不需要手写栈重建逻辑。
- 作用域跟着返回栈走:
NavBackStackEntry是ViewModelStoreOwner,页面级状态随出栈释放。
落到工程上,把路由定义集中到公共模块、把导航调用下沉为回调、把结果回传收敛到状态层,这三件事做完,路由代码就不会随着页面数量增长而失控。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。