如果你是从系列第一篇追过来的,应该已经知道 Navigation 3 是 Jetpack 家族里新一代的导航库,它把 Navigation 2 的字符串路由和 NavController 那一套整个推倒重来了。如果你直接点开这篇文章也没关系,我会从工程接入开始讲,基础用法这部分完全可以独立阅读。
这篇是系列第二篇,主题很明确:Compose Navigation 3 的基础用法。我会从依赖配置开始,一步步把类型安全路由、导航操作、参数传递、返回栈控制讲清楚,过程中会把我在 alpha 版本上踩过的坑一起交代。适合已经开始用 Compose 做项目、对 Navigation 2 有一定了解、想往 Navigation 3 迁移的开发者;纯新手也能跟着跑通,但最好先掌握 Compose 基本组件和 Kotlin 序列化基础。
1. 从 NavController 到 NavBackStack:先弄懂架构变化再动手
1.1 Navigation 2 时代最磨人的三个问题
Navigation 2 本身并不差,但用久了总有几个地方让人难受。
第一,字符串路由。整个导航图靠 "home"、"profile/{userId}" 这种字符串拼起来,写错一个字母不报编译错误,运行到点击跳转那一步才崩溃。一个大型项目里路由常量散落在各个文件,App 结构复杂以后,找一条路由从哪里定义、在哪里被引用,特别费劲。IDE 的全局搜索能帮上忙,但这不是解决问题,只是缓解症状。
第二,参数声明和解析分裂。定义目的地的时候要写 route pattern,还要写 arguments 列表声明参数类型;跳转的时候要小心翼翼地拼字符串,用 Uri.encode 处理特殊字符;进到目的地之后还得用arguments?.getString("userId")这种写法从 Bundle 里取值。三处代码各写各的,改参数名要改三遍,漏一个就是运行时崩溃,而且只在特定路径上触发,测试很难覆盖全。
第三,对 Fragment 的深度绑定。Navigation 2 的NavHostFragment是基于 Fragment 实现的,哪怕你全程不用 Fragment 的 UI 能力,底层也要维护一个 Fragment 容器。这既是历史包袱,也是多平台路上的障碍。Compose 生态一直在提 Kotlin Multiplatform,导航作为应用架构的核心一环,如果不能脱离 Fragment,那 Compose 的跨平台版图始终缺一块。
1.2 NavBackStack 与 NavDisplay 各管哪摊事
Navigation 3 把原来的NavController拆成了两个角色:NavBackStack和NavDisplay。
NavBackStack是导航状态的实际持有者。它内部维护着一个返回栈,记录了当前有哪些目的地、每个目的地处于什么状态。你可以把它理解成舞台监督手里的出场记录:当前在台上的演员是谁,前一场是谁,下一场该谁上。所有关于导航状态的操作——navigate、popBackStack、观察当前目的地变化——都发生在它身上。
NavDisplay是负责渲染的 Compose 组件。它监听NavBackStack的变化,拿到当前应该展示的NavEntry,然后调用你在导航图里注册的 Composable 内容去渲染界面。它俩的分工很清楚:一个管状态,一个管 UI。
这样的拆分带来一个很直接的好处:导航逻辑和 UI 渲染解耦。测试NavBackStack不需要搭 Compose 环境,可以直接在单元测试里验证navigate之后的返回栈结构是否符合预期;换 UI 层时只需要换掉NavDisplay的声明,导航逻辑原封不动。
1.3 重新理解"导航"这件事
Navigation 3 还有一个有意思的变化——目的地不再是一个字符串常量,而是一个 Kotlin 类型。
如果你用 Navigation 2 写过项目,你心里一定有一张"路由表":哪个字符串对应哪个页面,参数叫什么名字。到了 Navigation 3,这张表消失了,取而代之的是一组@Serializable的普通 Kotlin 类。Home就是一个object,Profile(userId: Long)就是一个data class。
这个变化看起来只是"换了个写法",但它把导航从"两个字符串之间的匹配"变成了"两个类型之间的匹配"。编译器能帮你检查绝大部分错误:跳转时参数类型不对,编译直接失败;改目的地类名,所有引用处同步 IDE 重构。这是体验上的质变。
理解了这几个概念,后面的基础用法就顺理成章了。先把工程配好,跑一个最小导航。
2. 工程接入:依赖配置和第一个最小导航
2.1 版本选择与依赖项避坑
Navigation 3 目前还在 alpha 阶段,我写这篇文章时用的版本是3.0.0-alpha01这一系。在libs.versions.toml里这样配置:
[versions] navigation3 = "3.0.0-alpha01" [libraries] androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation3" }然后在模块的build.gradle.kts中引入:
dependencies { implementation(libs.androidx.navigation.compose) }这里有两个坑要提前说。
坑一:命名空间冲突。androidx.navigation:navigation-compose这个 artifact 在 Navigation 2 时代就在用,你项目里如果之前已经引了 Navigation 2 的版本,升级到 3 之后 Gradle 会直接换上 3.x 的新版本,但代码里androidx.navigation.compose.NavHost等一堆类名和 Navigation 2 完全同名,行为却变了。混合使用新旧 API 时特别容易懵。我的建议是:如果项目已经深度使用 Navigation 2,迁移时留一个独立分支,先在分支里把 Navigation 2 的代码全部移除再引入 3,不要试图长期共存。
坑二:alpha 版本的 API 迭代很快。不同 alpha 之间,函数签名可能直接改名,比如某个早期版本叫composable,后续可能调整成别的名字。看网上的示例代码时,先确认对方用的版本号和你一致,不然抄过来编译不过,还要花时间排查是不是自己写错了。
2.2 开启 kotlinx.serialization 插件
类型安全路由依赖@Serializable注解,而它来自kotlinx.serialization,所以需要两步配置。
第一步,在项目根目录build.gradle.kts里声明插件:
plugins { // ... id("org.jetbrains.kotlin.plugin.serialization") version "2.0.20" apply false }第二步,在模块的build.gradle.kts里应用:
plugins { // ... id("org.jetbrains.kotlin.plugin.serialization") }如果你用的是 Kotlin 2.0 以上的 Compose 编译器插件,还需要确认版本匹配。这一步漏掉的话,编译时会报 "Serializable class is not found" 之类的错误,网上搜解决办法时很容易被引导到旧框架问题上去,浪费时间。
2.3 跑起一个最小可运行的导航
依赖配好、插件开启,下面是最小可运行代码:
@Serializable object Home @Composable fun App() { val backStack = rememberNavBackStack(startEntry = Home) NavDisplay( backStack = backStack, modifier = Modifier.fillMaxSize() ) { composable<Home> { HomeScreen(onNavigateToDetail = { backStack.navigate(Detail(it)) }) } composable<Detail> { entry -> val detail = entry.toRoute<Detail>() DetailScreen(profileId = detail.id, onBack = { backStack.popBackStack() }) } } }这段代码只要跑起来,就能实现从首页跳到详情页、再从详情页返回首页的基本闭环。对比 Navigation 2,你会发现少了NavHost、少了navController,多了NavBackStack和NavDisplay。后面的所有用法,都是在这个骨架上做扩展。
3. 类型安全路由:路由表终于不需要人肉维护
3.1 字符串路由的问题在哪
不是所有字符串方案都该被否定,但导航场景下,字符串路由的代价确实偏高。项目大了之后,路由名称重命名、参数结构变化,都需要全局搜索替换。最痛苦的是参数解析:你要在跳转时把整数拼成字符串,进页面时再从字符串里解析整数,类型在这一来一回中变得不可信。IDE 的重构工具能改常量名,但改不了跨模块传参时隐藏的类型隐患。
3.2 用 @Serializable 声明目的地
Navigation 3 的解决思路很直接:把路由本身定义成可序列化的 Kotlin 类型。无参目的地声明为object,带参目的地声明为data class。
@Serializable object Home @Serializable object Settings @Serializable data class Profile(val userId: Long) @Serializable data class ArticleDetail( val articleId: String, val source: String? = null )细心的读者会发现,这些类就是普通的数据类,加上@Serializable注解后,它们具备了在导航过程中被序列化和反序列化的能力。object因为没有属性,天然是单例,表示无参目的地非常合适;data class天然携带参数,表示带参目的地顺理成章。
在NavDisplay里注册目的地时,泛型直接指向对应类型:
NavDisplay(backStack = backStack) { composable<Home> { ... } composable<Profile> { entry -> ... } }一个容易忽略的点:同一个类型不能重复注册。我一开始在导航图里写了两个composable<Profile>,编译没问题,运行时直接崩溃,提示重复目的地。data class之间如果存在相同类型签名,也会出问题,所以定义路由类型时尽量保持含义单一,不要拿一个通用类型到处用。
3.3 带参路由的定义与取值
跳转时直接构造对象:
backStack.navigate(Profile(userId = 42L))页面里取值用toRoute<T>():
composable<Profile> { entry -> val profile = entry.toRoute<Profile>() ProfileScreen(profile.userId) }toRoute<T>()会把导航过程中序列化好的参数还原成原始类型,调用方不再需要关心参数是怎么编码的。跳转和接收发生在两个地方,但数据类型始终一致,编译器会在跳转时校验构造参数是否齐全、类型是否正确。
这里有个参数默认值的细节。如果data class的属性有默认值,比如ArticleDetail.source = null,跳转时可以不传 source。但要注意,默认值是在序列化时处理的——序列化时没有该字段,反序列化时用默认值补上。这在大多数场景下符合预期,但如果你期望能通过"某个字段是否为空"来判断跳转来源,建议不要过度依赖默认值,显式传参更清晰。
4. 日常导航操作:navigate、popBackStack 与回调组织
4.1 navigate 的基础用法与常见场景
NavBackStack.navigate(entry)是最常用的操作,它把目标目的地压入返回栈。最简单的写法是:
backStack.navigate(Profile(userId = 42L))但实际项目中往往需要携带导航选项,这时后面跟一个navOptions块:
backStack.navigate( Profile(userId = 42L), navOptions { launchSingleTop = true popUpTo(Home) { inclusive = false } restoreState = true } )navOptions里每一项的含义,我后面会重点展开,这里先记住:无脑navigate是最简单的,但一旦涉及返回栈清理、页面唯一性、状态恢复,就必须依赖navOptions来精确控制。
另一个常见的场景是启动页/登录页跳主页面。登录成功后,你不希望用户按下返回键又回到登录页,这个场景就是典型的popUpTo+inclusive:
backStack.navigate( MainScreen, navOptions { popUpTo(LoginScreen) { inclusive = true } } )这段代码的意思是:把LoginScreen从栈里弹出去,再压入MainScreen。弹出后返回栈里不再有登录页,用户按返回键不会回到登录界面。
4.2 popBackStack:返回键、返回逻辑和它的脾气
popBackStack()从栈顶弹出一个目的地:
backStack.popBackStack()在 Compose 里,返回键事件并不直接调用backStack.popBackStack(),而是通过BackHandler拦截。Navigation 3 与 Navigation 2 在这块的处理思路一致:默认情况下,返回键会触发返回;当你在页面里拦截返回事件时,需要自己判断是否需要调用popBackStack()。
这里有个避免误用的建议:不要在popBackStack()前手动判断"栈里还有没有页面"。返回栈为空时调用popBackStack()可能会导致崩溃或者无法退出应用,建议依赖NavBackStack是否还有可弹出的条目来判断,或者直接用系统返回键机制来处理最终退出行为。
还有一种条件返回的场景。比如用户填写了一个表单,按返回键时你要弹窗确认是否丢弃内容。这个逻辑通常放在 UI 层做拦截:
BackHandler(enabled = formDirty) { showDiscardDialog = true }确认后你再调用backStack.popBackStack()。Navigation 3 不会替你决定 UI 交互,它只负责在调用返回时正确地把状态还原到上一个目的地。
4.3 导航事件的代码组织方式
Navigation 3 提供的能力只到"状态管理"这一层,怎么组织导航调用代码,是开发者的自由,也是项目架构的分水岭。
我在项目里推荐一种回调上抛的模式:每个页面只抛出导航意图,具体navigate调用统一放在导航图声明处。这样做的好处是页面不知道 Navigation 的存在,方便解耦和测试。
@Composable fun HomeScreen( onClickProfile: (Long) -> Unit, onClickSettings: () -> Unit ) { // UI 只负责把事件抛出去 } // 导航图里集中处理跳转 composable<Home> { HomeScreen( onClickProfile = { userId -> backStack.navigate(Profile(userId)) }, onClickSettings = { backStack.navigate(Settings) } ) }有读者会问,如果页面层级很深,每个页面都回调上抛,参数传递会不会很啰嗦?确实会有一些模板感,但换来的是可测试性和可读性。导航操作全部集中在一个文件里,后面改导航图、加动画、调整返回栈逻辑时,改动范围可控。
另一种方式是直接向 Composable 传backStack,或者通过CompositionLocal把它暴露给所有子组件。这种方式写起来省事,但页面会隐式依赖导航库,不利于复用和测试。我的经验是:小项目随便用,模块较多的项目用回调上抛,长期维护更舒服。
5. 参数传递:干净的数据流比看起来更重要
5.1 基础类型参数直接传
Int、Long、String、Boolean、Float、Double 这些基础类型,直接放进data class路由里就可以传。跳转时构造数据类:
backStack.navigate(ArticleDetail(articleId = "abc123"))接收时通过toRoute<ArticleDetail>()取回:
composable<ArticleDetail> { entry -> val route = entry.toRoute<ArticleDetail>() ArticleScreen(articleId = route.articleId) }整个过程没有字符串拼接,没有 Uri.encode,没有手动 Bundle 写入和读取。类型安全在这里不只是一句口号,而是实实在在的编译器保障。你想传错类型,编译器都不答应。
5.2 复杂对象的取舍与序列化边界
Navigation 3 的@Serializable基于 kotlinx.serialization,不能直接传一个任意对象。比如你有一个User对象,里面既有普通字段又有运行时状态,直接塞进路由参数里,编译期可能通过,但序列化时会报错。
我的建议是:路由参数只放标识符,不放业务对象。比如跳转用户页时传userId,进入页面后通过 ViewModel 或 Repository 根据 id 加载完整 User 数据。这样有几个好处:
- 数据永远是最新的。如果用户信息在跳转过程中被修改了,按 id 重新拉取能得到最新数据,传对象拿到的反而是旧副本。
- 避免超大参数导致 Binder 事务失败。Android 的 Intent/Bundle 传输有大小限制,路由参数最终也要走序列化,塞一个大 List 或者 Bitmap 会直接撑爆事务缓冲,抛
TransactionTooLargeException。 - 序列化结构稳定。网络数据模型往往带各种自定义类型、继承结构,做路由参数会陷入序列化和反序列化的类型匹配地狱。
如果你确实需要传递一些轻量的结构化数据(比如过滤条件),可以单独定义一个data class作为路由专用类型,里面只放必要的标量字段。
5.3 参数解析的细节和默认值处理
基础参数解析很简单,但有一些边界情况要注意。
可空参数。如果路由类型里有一个可空字段:
@Serializable data class SearchResult( val keyword: String, val category: String? = null )跳转时可以传category也可以不传,接收时category可能是空。这看起来没什么特别,但要注意:如果你的业务逻辑中,"未传参数"和"参数为 null"是两种不同含义,最好用显式字段表达,比如加一个val fromCategory: Boolean。
默认值一致性问题。序列化和反序列化使用同一套默认值规则,所以跳转方构造SearchResult(keyword = "compose")时省略 category,接收方拿到的 keyword 正常、category 为 null,逻辑上没有歧义。但如果你在两个不同的地方维护了两份"同样的默认值",后续某个字段的默认值一改,另一处没跟上,就会出现线上数据和预期不一致的诡异问题。所以默认值尽量只定义在路由类型一处,不要复制到跳转方逻辑里。
6. 返回栈精细调控:NavOptions 使用的几个真实场景
6.1 避免重复堆栈:launchSingleTop
如果返回栈里已经有一个相同类型的目的地实例,正常情况下再navigate一次就会压入两个。有些场景下这不是问题,但像底部导航栏这种频繁切换的入口,重复压栈会让返回路径变得不可控。
launchSingleTop = true的效果是:当目标目的地已经位于栈顶时,不再压入新的实例,而是复用现有的。典型写法:
backStack.navigate( Home, navOptions { launchSingleTop = true } )注意,launchSingleTop只对"栈顶是同一个类型"生效。如果目标类型在栈中间而不是栈顶,它不会帮你把前面的页面弹掉。这种场景需要配合popUpTo使用。
6.2 清理返回栈:popUpTo 与 inclusive 的正确理解
popUpTo的语义在 Navigation 3 里和 Navigation 2 保持一致,但在类型安全路由下,目标参数从字符串变成了类型:
backStack.navigate( CompletePayment, navOptions { popUpTo(Cart) { inclusive = true } } )这段代码的含义是:把Cart及其之上的所有页面弹出,然后压入CompletePayment。inclusive = true表示Cart本身也被弹出;inclusive = false则保留Cart。
实际业务里,最常见的场景是下单流程结束后的清栈:
// 购物车 -> 确认订单 -> 支付成功 backStack.navigate( PaymentSuccess, navOptions { popUpTo(Cart) { inclusive = true } } )这样用户从PaymentSuccess返回时,不会看到"确认订单"和"购物车",而是直接回到上一级入口(比如商品列表)。
一个我踩过的坑:popUpTo的目标类型必须在当前返回栈中存在。如果你在某个分支流程里点到了某个页面,A 页被提前弹掉了,后面在 B 页里调用popUpTo(A),运行时会因为找不到目标而报错。所以做长链路清理之前,先确认目标页面一定还在栈里,或者在设计流程时就保证目标页始终存在。
6.3 状态保存与恢复:saveState 与 restoreState
底部导航栏的场景里,你总希望切到其他 Tab 再切回来时,原来 Tab 里的列表滚动位置和输入状态能保留。Navigation 3 提供saveState和restoreState组合:
backStack.navigate( ProfileTab, navOptions { popUpTo(HomeTab) { saveState = true } launchSingleTop = true restoreState = true } )它的流程是:从 HomeTab 切到 ProfileTab 时,把 HomeTab 的状态保存下来;再从 ProfileTab 切回 HomeTab 时,把保存的状态恢复。这组选项配合使用,能实现比较顺滑的 Tab 切换体验。
状态保存的粒度是目的地级别。它保存的是目的地 Composable 在返回栈中的状态,不是页面里某个输入框的值。如果你的页面里有rememberSaveable保存的状态,会跟随目的地状态一起恢复;如果你用remember而不是rememberSaveable,切走再切回来就会丢失。这和 Navigation 2 的行为一致。
6.4 我踩过的 NavOptions 相关坑
实践下来有几个坑值得单独记一笔。
第一个坑:popUpTo 的目标类型不能是栈外的任意类型。编译期不会报错,甚至运行到某条路径时也不一定报错,但只要那条清理路径真的走到了,就可能因为找不到目标而崩溃。做长流程跳转时,我会在注释里写清楚当前栈的预期结构,方便后面维护的人一眼看出 popUpTo 的目标是否合理。
第二个坑:launchSingleTop 的"栈顶复用"不等于"页面刷新"。复用栈顶实例时,页面不会重新组合。如果你希望切回某个 Tab 时它的内容总是刷新(比如角标数字、消息列表),需要额外用 ViewModel 或事件机制通知页面更新,不能依赖导航本身。
第三个坑:navOptions 的 DSL 写法和 Navigation 2 不完全一致。网上关于 Navigation 2 的popUpTo大括号代码不要直接抄到 Navigation 3,类型、默认值都有变化。遇到编译错误时,先查官方迁移文档,再对当前版本的 API 签名,不要在旧教程里反复打转。
第四个坑:restoreState 和 popUpTo 搭配不当会产生预期外的扩展行为。比如你期望切走时保存状态、切回时恢复,但popUpTo的目标和saveState的位置没对应好,可能出现页面一级一级往外弹而不是直接回到目标页的情况。多写几个用例,把返回路径都点一遍再继续开发。
写在最后:实际用下来的几点体会
Navigation 3 现在还不到生产环境大规模采用的阶段,但基础用法已经能带来实打实的体验提升。
我实际写代码时最直观的感受是:字符串路由那套思维定势要主动改。刚开始我还会不自觉地在代码里找路线常量、担心参数类型对不上,但用了一周类型安全路由后,再切回 Navigation 2 的项目会觉得浑身别扭,总觉得少了一层编译期的保护。
如果你想把 Navigation 3 放进现有项目试试水,我建议先拿一个新的业务模块做试点,路由类型单独建一个包,NavDisplay集中管理,导航回调按页面拆分,跑通后再考虑把老的 Navigation 2 页面逐个迁移过来。alpha 阶段别急着全量重构,先把基础用法摸熟、把返回栈状态变化的规律吃透,后面的进阶功能——动画、深层链接、嵌套导航——才有扎实的地基。
下一篇我会沿着动画和转场效果继续往下挖,那是 Navigation 3 和 Compose 结合得最紧密的地方,也是实际项目里绕不开的一块。