返回栈还给你
这是你错过的这一年半里,单点变化最大的一件事。Navigation 3 在 2025 年 11 月转正(当前 androidx.navigation3 1.1.3)。它不是一次 API 升级 —— 它把「返回栈归谁管」这个问题的答案彻底掉了个个儿。以前返回栈是框架的私产,你只能隔着一层 NavController 求它办事;现在返回栈就是你手里的一个 List,你想怎么改就怎么改。这一章讲清它为什么必须推倒重来,以及新的心智模型长什么样。
先说清楚:Nav2 到底哪儿不行
推倒一套用了六年、无数项目在用的 API,理由必须硬。Nav2 的问题不是「不好用」,是三个结构性的死结。
死结一:一次只能画一个目的地
NavHost 的模型是:返回栈栈顶是谁,就画谁。一次一个。
这在手机上没问题。但上一章说了,Android 16 起大屏不能再锁方向 —— 平板、折叠屏、桌面窗口, 你必须做列表-详情双栏这种布局。而双栏意味着:要同时画两个目的地。
Nav2 做不到。所以过去大家是怎么绕的?
// Nav2 时代做双栏,你得在 NavHost 外面自己搭一套影子状态:
var selectedId by remember { mutableStateOf<String?>(null) } // ← 第二份「返回栈」
val windowSize = calculateWindowSizeClass(activity)
if (windowSize.widthSizeClass == Expanded) {
Row {
ListPane(onSelect = { selectedId = it }) // 不走 navController
DetailPane(selectedId) // 也不走 navController
}
} else {
NavHost(...) { /* 走 navController */ } // 手机上才走导航
}
// 现在请回答:用户在双栏模式下按返回,该退到哪?
// 你得自己写。而且 selectedId 和 navController 的返回栈,是两份会打架的状态。
你被迫维护两套导航状态:一套在 NavController 里(框架的),一套在你自己的
selectedId 里(手写的)。它们必然会不同步 —— 这正是第 1 章说的「同一个事实有两份副本」。
而且这个问题没法在 Nav2 内部修,因为「一次画一个」是它的根基。
死结二:你不能自由操作那个栈
想「跳到登录页,并且清掉之前所有页面」?Nav2 里你得念这段咒:
navController.navigate("login") {
popUpTo(navController.graph.startDestinationId) { inclusive = true }
launchSingleTop = true
restoreState = false
}
// 这四行到底会产生什么样的栈?说实话,你得跑一遍才知道。
为什么这么别扭?因为栈不是你的。你不能直接改它,只能通过一组「导航选项」描述你希望它变成什么样, 然后祈祷框架理解对了你的意思。
死结三:参数靠 Bundle 往返,类型是幻觉
"detail/{id}" 这种字符串路由,参数最终要序列化进 Bundle。Nav 2.8(2024 年 8 月)
用 @Serializable 的路由类型在表面上修好了这个 —— 但底下还是那套 Bundle 往返。
Nav3 的答案:把栈还给你
Navigation 3 的核心决定,一句话就能说完:
不是「像 List 一样的东西」,不是「List 的包装」。它就是一个
SnapshotStateList<NavKey> —— Compose 的可观察列表。
于是:导航 = 往 List 里加一个元素。返回 = 删掉最后一个元素。
你想清栈?List 有什么方法,你就有什么方法。没有咒语了。
下面这个模拟器里,左边那个栈是真的一个数组。点按钮 push / pop,看它怎么变; 然后把窗口宽度拉到 600dp 以上,看那件 Nav2 做不到的事情发生:
代码长什么样
整个 Nav3 的 API 表面小得惊人。这就是全部:
// 1. 路由 key —— 就是普通的 Kotlin 类型,实现 NavKey
@Serializable data object Home : NavKey
@Serializable data class Detail(val id: String) : NavKey
@Composable
fun App() {
// 2. 返回栈 —— 你持有它
val backStack = rememberNavBackStack(Home)
// 3. NavDisplay —— 它只负责「把当前该显示的东西画出来」
NavDisplay(
backStack = backStack,
onBack = { count -> repeat(count) { backStack.removeLastOrNull() } },
// 4. entryProvider —— key 到 UI 的映射表
entryProvider = entryProvider {
entry<Home> {
HomeScreen(onOpen = { id -> backStack.add(Detail(id)) }) // ← 导航
}
entry<Detail> { key ->
DetailScreen(key.id) // ← key 就是那个 data class,类型是真的
}
},
)
}
backStack.add(Detail(id))—— 这就是「导航」的全部。没有navigate("detail/$id"),没有路由字符串。backStack.removeLastOrNull()—— 这就是「返回」的全部。key.id—— 类型是真的。Detail就是那个 data class,不是从 Bundle 里挖出来的String?。- 想清栈到根?
backStack.subList(1, backStack.size).clear()。想一次推三层?backStack.addAll(...)。 它是 List,你早就会了。
Scene:栈没变,只是画法变了
现在回到死结一。Nav3 怎么在不引入第二份状态的前提下做双栏?
答案是 SceneStrategy:它是一个「看一眼当前的返回栈,决定这一帧该怎么摆」的策略对象。
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()
NavDisplay(
backStack = backStack,
sceneStrategy = listDetailStrategy, // ← 加这一行
entryProvider = entryProvider {
entry<Home>(metadata = ListDetailSceneStrategy.listPane()) { ... }
entry<Detail>(metadata = ListDetailSceneStrategy.detailPane()) { ... }
},
)
窗口够宽、并且栈顶恰好是「一个 listPane + 一个 detailPane」时,这个策略就把 两个 entry 一起渲染进一个双栏 Scene。窗口变窄,它自动塌回单栏。
[Home, Detail(3)] 这个栈,在手机上画成「详情页盖住列表页」,在平板上画成「左列表右详情」。
同一个栈,两种画法。
于是「按返回该退到哪」这个问题自动就有答案了 —— removeLastOrNull() 弹掉
Detail,双栏塌成单列表。你不需要维护第二份 selectedId,
因为「选中了哪一项」这个信息,本来就在返回栈里。
这就是 Nav2 修不好、只能重来的那个死结。
三个 decorator:那些「消失了的」框架职责
等等 —— 如果返回栈只是个 List,那么谁来管每个页面的生命周期、状态保存、ViewModel 作用域?
Nav3 的回答很诚实:这些都是可插拔的装饰器,你自己列出来。
NavDisplay(
backStack = backStack,
entryDecorators = listOf(
rememberSceneSetupNavEntryDecorator(), // 生命周期:谁该是 RESUMED
rememberSavedStateNavEntryDecorator(), // 状态保存:进程死了能恢复
rememberViewModelStoreNavEntryDecorator(), // ViewModel 作用域:每个 entry 一个
),
...
)
| Decorator | 它替你干的事 | Nav2 里这活儿是谁干的 |
|---|---|---|
SceneSetup |
决定哪个 entry 处于 RESUMED、哪个只是 STARTED(双栏时两个都得活着) | NavHost 内部,黑盒 |
SavedState |
rememberSaveable 能跨进程死亡恢复 |
NavBackStackEntry 内部,黑盒 |
ViewModelStore |
每个 entry 一个独立的 ViewModelStore;entry 出栈 → onCleared() |
NavBackStackEntry 内部,黑盒 |
这三个 decorator 不是默认加上的 —— 你不写,它们就不存在。
忘了 rememberViewModelStoreNavEntryDecorator(),你的 viewModel()
就会挂到 Activity 上,而不是挂到这个页面上。后果:从详情页返回,ViewModel 不会被清理;
再进详情页,拿到的是上一次那个脏 ViewModel。
在上面的 Demo 里 pop 一下,你会看到那个红色的 VM onCleared() ——
那是因为 decorator 在。没有它,那一行永远不会出现。
为什么它反而更好模块化
entryProvider 看起来像个大而全的映射表,会不会所有 feature 都得挤在 :app 里注册?
不会。因为它就是个普通的 builder,你可以让每个 feature 模块自己贡献自己那几条:
// :feature:news 模块里,自己管自己的 entry
fun EntryProviderBuilder<NavKey>.newsEntries(backStack: NavBackStack) {
entry<NewsList> { NewsListScreen(onOpen = { backStack.add(NewsDetail(it)) }) }
entry<NewsDetail> { NewsDetailScreen(it.id) }
}
// :app 模块里,把各个 feature 的 entry 拼起来
entryProvider {
newsEntries(backStack) // 来自 :feature:news
bookmarkEntries(backStack) // 来自 :feature:bookmarks
settingsEntries(backStack) // 来自 :feature:settings
}
路由 key(那些 @Serializable 的 data class)放在一个薄薄的
:core:navigation 模块里,谁都能依赖。这样 :feature:news
想跳到收藏页,只需要知道那个 key 的类型,不需要依赖 :feature:bookmarks 的实现 ——
第 6 章的模块图会再回到这一点。
那 Mihon 为什么用 Voyager?
Mihon 没用 Nav2,也没用 Nav3,它用的是社区库 Voyager。这不是掉队,恰恰相反:
Voyager 的模型是:Screen 是一个对象,Navigator 持有一个 Screen 的栈,
navigator.push(DetailScreen(id))。
看出来了吗?这和 Nav3 的心智模型几乎一模一样。Mihon 早在几年前就受够了 Nav2 的字符串路由, 转向了「栈里放的就是类型化的对象」这条路。Nav3 某种意义上,是官方追上了社区早就选好的方向。
所以如果你的项目正在用 Voyager,你不用慌 —— 你的心智模型是对的。要不要迁到 Nav3,取决于你需不需要
SceneStrategy 那套自适应能力,以及要不要吃官方的生态红利。
迁移:该怎么办
| 你的处境 | 建议 |
|---|---|
| 新项目 | 直接上 Nav3。没有任何理由从 Nav2 开始 |
| 老项目,还在用字符串路由 | 别先迁到 Nav 2.8 的类型安全路由 —— 那是一条死路,Nav3 的 API 完全不同。直接评估迁 Nav3 |
| 老项目,跑得好好的,也不做平板 | 不急。Nav2 没被废弃,还在维护。但新写的 feature 值得试试 Nav3 |
| 要做平板 / 折叠屏 / 桌面 | 立刻。这正是 Nav3 存在的理由,你手写那套双栏逻辑迟早会崩 |
| 在用 Voyager | 心智模型已经对了。按需迁移,不必着急 |
- 返回栈是你的。
backStack.add(Key)是导航,removeLastOrNull()是返回。咒语没了。 - NavDisplay 只负责画。它不持有状态,它读你的 List。
- SceneStrategy 解开了那个死结:同一个栈,窄屏画一栏,宽屏画两栏。「选中了哪一项」这个信息本来就在栈里,不需要第二份。
- 三个 decorator 必须自己写上,尤其是 ViewModel 那个 —— 忘了它,你的 ViewModel 会活得比页面久。
- Nav3 追上了 Voyager 早就选好的方向:栈里放类型化的对象,而不是字符串。
1. 回上面的 Demo,把宽度拉到 700dp,栈是 [Home, Detail] —— 双栏。现在再 push 一个 Settings。观察:为什么塌回单栏了?(因为栈顶两个不再是 list+detail 那对组合,策略没匹配上。)
2. 在你的项目里找到那句最长的 navigate(...) { popUpTo... },把它翻译成 Nav3 的 List 操作。你会发现翻译完之后你终于看得懂它在干什么了。
地基里最大的那块换完了。接下来两章进入第三幕:三档流派。
先从一个你以为早有定论的问题开始 —— 状态到底该住在哪儿?
官方说 ViewModel,Mihon 说不需要,还有一派说:presenter 本身就该是个 @Composable。