Compose Navigation 与深链跳转

单 Activity 架构下页面跳转变成返回栈设计问题。本文讲透 NavHost 与 NavController 的职责划分、Navigation 2.8 的类型安全路由、参数传递与结果回传、嵌套图与底部导航的多返回栈、深链与隐式 Intent 映射、返回栈管理与状态恢复,以及与 ViewModel 作用域和转场动画的集成。

引言

单 Activity 架构成为主流之后,Compose 应用里的「页面跳转」不再是一句 startActivity,而是 NavHost 内部目的地(destination)之间的切换。Navigation Compose 把返回栈、参数传递、深层链接与状态恢复统一收拢到 NavController 这一个入口,代价是路由从「一行代码」升级成需要设计的架构组件。

真正难的不是把页面接起来,而是三件事:第一,返回栈在嵌套图、底部导航与系统返回手势三者叠加时仍然符合直觉;第二,参数在类型层面可校验,而不是靠字符串拼接后在运行时抛 IllegalArgumentException;第三,外部 URL 打开应用时能落到正确的页面,且用户按返回键能回到入口而不是直接退出。

Navigation 2.8 引入的类型安全路由把第二件事解决得相当彻底:路由从字符串变成 @Serializable 数据类,参数通过构造函数传递,拼写与类型错误在编译期就能发现。返回栈的语义则仍然需要开发者自己理解,框架只提供原语。本文按「结构 → 路由 → 参数 → 深链 → 返回栈 → 集成」的顺序展开,每节都给出可直接落地的写法,最后用一张取舍表说明什么场景该选哪种路由方案。文中涉及的 UI 组织方式可对照 Jetpack Compose 声明式 UI 开发 一文。


目录

  1. 依赖与整体结构
  2. NavHost 与 NavController
  3. 类型安全路由
  4. 参数传递与结果回传
  5. 嵌套图与模块化
  6. 底部导航与多返回栈
  7. Deep Link 与隐式 Intent 映射
  8. 返回栈管理与状态恢复
  9. 与 ViewModel 的作用域绑定
  10. 转场动画与预测式返回
  11. 测试与调试

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」的映射表,是类型安全路由顺带解决的另一个问题。


深链需要两处声明: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() 不判返回值栈空时误判为成功检查布尔返回值
忘记 autoVerifyApp Links 弹选择器配置 assetlinks.json 并 pm verify-app-links
只声明 enterTransition返回无动画,体验不对称四类转场成对声明
ViewModel 绑错作用域页面出栈后状态未清理hiltViewModel() 用当前条目,共享时显式传父条目

小结

Navigation 的心智模型可以压缩成四句话:

  1. 路由是类型,不是字符串:@Serializable 数据类承担契约职责,编译期校验替换运行时崩溃。
  2. 返回栈靠选项控制:popUpTo + saveState + restoreState + launchSingleTop 是 tab 场景的标准组合,四个选项各有明确职责。
  3. 深链依赖图结构:页面挂进正确的子图,合成返回栈就自动正确,不需要手写栈重建逻辑。
  4. 作用域跟着返回栈走:NavBackStackEntry 是 ViewModelStoreOwner,页面级状态随出栈释放。

落到工程上,把路由定义集中到公共模块、把导航调用下沉为回调、把结果回传收敛到状态层,这三件事做完,路由代码就不会随着页面数量增长而失控。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Android 开发」更多文章

  1. Kotlin Multiplatform 跨平台共享
  2. Android R8 混淆与 Baseline Profile
  3. Android 测试体系:单元测试、Espresso 与 Compose 测试