幕 II · 拆除与新建CH 03进度 03/06

返回栈还给你

这是你错过的这一年半里,单点变化最大的一件事。Navigation 3 在 2025 年 11 月转正(当前 androidx.navigation3 1.1.3)。它不是一次 API 升级 —— 它把「返回栈归谁管」这个问题的答案彻底掉了个个儿。以前返回栈是框架的私产,你只能隔着一层 NavController 求它办事;现在返回栈就是你手里的一个 List,你想怎么改就怎么改。这一章讲清它为什么必须推倒重来,以及新的心智模型长什么样。

NavBackStackNavDisplaySceneStrategy三个 decorator自适应多窗格

先说清楚: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 一样的东西」,不是「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,类型是真的
            }
        },
    )
}
▣ 对着上一个 Demo 读这段代码
  • 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 早就是这个思路了

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