做 Android 开发的人应该都有过这种经历:项目里的页面跳转一开始用 startActivity,后来换成 Fragment 的 add/replace,再后来用上官方 Navigation 组件,等 Jetpack Compose 全面普及之后,导航库也跟着重写了一遍,变成了大家口中的 Compose Navigation。我第一次被这个库“折磨”是在一次单 Activity + Compose 的重构里,页面一多,返回栈行为、ViewModel 生命周期、进程重建后的状态恢复这些问题远比想象中复杂。后来我花了一个下午,把 Compose Navigation 的关键调用链画成一张时序图,很多想不明白的东西瞬间通了。这篇博文就围绕这张时序图展开,聊聊从点击到页面切换,导航背后到底发生了什么。
先给跑错片场的同学提个醒。标题里的“Compose”这几年有点歧义:Docker Compose 在部署圈火得一塌糊涂,你要是搜部署方案搜到这篇文章,大概率走错门了。这里说的是 Android 生态的 Jetpack Compose,用 Kotlin 写 UI 的声明式框架,Compose Navigation 是它的官方导航解决方案。俩 Compose 除了名字一样,血缘上没有任何关系,别搞混。
这张时序图能帮你解决三个实际问题:第一,搞清楚 navigate() 之后新页面是怎么“变”出来的,而不是只会调接口;第二,搞清楚按返回键之后,你的 ViewModel 和界面状态为什么有的还在、有的没了;第三,看懂官方文档里那句“NavBackStackEntry 有自己的生命周期”到底落实到了哪一步。适合刚从 Fragment 迁到 Compose 的工程团队、被返回栈问题折磨的初级工程师,也适合准备做内部技术分享的人。下面我按画图时的思路一步步拆给你看。
1. 画时序图前,先把角色和机制盘清楚
时序图最关键的不是时间线,而是“演员表”。图里每条生命线都是一个参与者,参与者没列清楚,后面画消息线全是空中楼阁。所以我画图前先花了半小时把 Compose Navigation 涉及的核心对象盘了一遍,挨个确认它们在图中扮演什么位置。
1.1 五个核心角色,先给时序图凑齐“演员表”
- 调用方(Caller)。多数情况下是一个 Composable 函数,比如列表页某个 Button 的点击回调,或者 ViewModel 里对导航事件的封装。它只负责发起动作,不关心导航内部怎么执行。
- NavController。整个导航的中枢。它维护返回栈、创建和销毁 NavBackStackEntry、处理导航请求、派发生命周期事件。可以理解成 ActivityManager 和 FragmentManager 的迷你合体版。
- NavBackStackEntry。返回栈里的一个条目,代表一个导航目标的“实例”,这是后面所有状态逻辑的落点。它同时实现 LifecycleOwner、ViewModelStoreOwner、SavedStateRegistryOwner 三个接口。
- NavHost。一个 Composable 组件,内部订阅 NavController 的返回栈状态,负责把当前应该显示的条目对应的内容渲染出来。它是导航逻辑和 UI 渲染之间的桥梁。
- NavDestination。描述导航“去哪”,包括路由、参数类型、对应的 composable 内容。它本身不带状态,状态都在 NavBackStackEntry 上。
五个角色加起来,正好覆盖一条导航链路的上下游:调用方发起,NavController 调度,NavBackStackEntry 承载,NavHost 渲染,NavDestination 定义目标。时序图上至少要有这五条生命线,如果要画进程恢复,就再加一条“系统(System)”的线。我第一次画的时候偷懒只画了三个角色,结果返回栈相关的问题根本讲不清楚,后来补齐到五个才顺了。
1.2 NavBackStackEntry 的三个身份,是所有时序的地基
刚才特别提到 NavBackStackEntry 有三个身份,这不是随便设计的,它直接决定了导航时序里“状态从哪来、到哪去”。
先看 LifecycleOwner。Compose Navigation 没有 Fragment 那套生命周期回调方法,它把生命周期对象直接挂在返回栈条目上。每个条目自己维护一套生命周期状态机,从创建到销毁,状态切换有清晰的轨道。后面讲的状态分发,本质上就是往这个轨道上推拉。
再看 ViewModelStoreOwner。ViewModel 的作用域按 NavBackStackEntry 划分。你在一个页面里拿到的 ViewModel,绑定的是这个页面的条目;条目销毁,作用域跟着销毁。这就解释了为什么 A 跳到 B,A 的 ViewModel 还在;A 被弹出返回栈之后,A 的 ViewModel 才真正清除。很多人困惑“我的 ViewModel 什么时候 onCleared”,答案就在返回栈条目什么时候销毁。
最后是 SavedStateRegistryOwner。进程被杀后导航状态和页面数据怎么恢复?靠的就是 SavedStateRegistry。每个 NavBackStackEntry 维护独立的保存状态注册表,导航参数、rememberSaveable 数据都走这条线。
这三个身份就是后面所有时序分支的“地基”。我见过不少同学把这里的生命周期直接等同于 Activity 生命周期,排查问题方向完全跑偏。比如在 Activity 的 onDestroy 里以为页面销毁了,但 Compose Navigation 的页面条目可能还活在返回栈里,这个坑在常见问题章节再细说。
2. 生命周期状态机:时序图的“时间轴刻度”
画时序图之前,有一个前置知识点绕不开:NavBackStackEntry 的生命周期状态怎么流转。因为时序图里大量画的是“某个状态切换发生时,谁通知了谁”,不理解状态机,图上的消息线就没有意义,就像拿到了 I2C 时序图却不认识 SCL 和 SDA 一样。
2.1 四个生命周期状态,对应页面可见性
NavBackStackEntry 的生命周期状态有四个,从小到大依次是:
| 状态 | 含义 | 页面表现 |
|---|---|---|
| INITIALIZED | 条目刚创建,尚未准备显示 | 界面完全不可见 |
| CREATED | 条目创建完成,但不在前台 | 占位,可能已初始化资源 |
| STARTED | 进入用户可见范围 | 可能可见但无焦点 |
| RESUMED | 完全前台、可交互 | 真正意义上的“当前页面” |
基于这四档状态,有一条铁律需要记住:在普通全屏页面的导航场景里,任何时刻返回栈最多只有一个条目处于 RESUMED。这条铁律是理解全部时序的钥匙。正向导航时,新条目一路冲上 RESUMED,旧条目让出位置降到 STARTED 甚至 CREATED;返回时反过来,旧条目重新回升到 RESUMED。如果栈顶是一个对话框类型的 destination,情况会特殊一些,底下的条目可能保持 RESUMED,但这种场景先不展开。
2.2 状态分发是“一次计算、统一调整”
Compose Navigation 里状态切换的驱动者是 NavController。每次返回栈内容变化,它就遍历整个返回栈,算出每个条目该处于什么状态,然后统一调用各条目 LifecycleRegistry 的 currentState 做调整。这个“先算后调”的机制,让多个条目之间的状态变化保持同步,不会出现新页面没进场、旧页面已经退场的竞态。
跟 Fragment 时代的 add/hide/show 对比一下更直观。Fragment 做 hide/show 时,被隐藏的 Fragment 仍停留在 Created 状态;Compose Navigation 则会真正驱动 STARTED/RESUMED 下沉和回升。所以如果你用 LifecycleObserver 或 DisposableEffect 监听页面可见性,在 Compose Navigation 下会更敏感,也更符合直觉。
这段时序的文字版大致是:
- NavController 内部的返回栈 StateFlow 发生变化
- NavController 触发状态分发,逐项计算每个条目的目标状态
- 对每个条目调用 LifecycleRegistry.currentState 设置新状态
- 各条目的 LifecycleObserver 被回调
- NavHost 感知到当前条目变化,触发重组
- 目标条目对应的 composable() 内容被组合并显示
注意第 2 步和第 5 步的先后:状态分发在先,重组在后。也就是说,页面的“可见性逻辑”必定先于 UI 渲染触发。如果你用 DisposableEffect 监听 RESUMED 来埋点,理论上这个回调发生在新页面真正绘制之前。这个细节在排查埋点时机问题时非常有价值,我后面会给出一个具体的排查案例。
3. 正向导航完整时序:一次 navigate() 的旅程
演员表和状态机都齐了,开始画最关键的正向导航线。场景就用最经典的“列表页 -> 详情页”,假设列表页路由是 "list",详情页路由是 "detail/{id}",用户点击某个 item 后调用 navController.navigate("detail/123")。
3.1 从点击到页面出现,完整 8 步
先把常用的脚手架代码摆出来,后面每一步都可以对照着看:
val navController = rememberNavController() NavHost( navController = navController, startDestination = "list" ) { composable("list") { ListScreen( onItemClick = { id -> navController.navigate("detail/$id") } ) } composable("detail/{id}") { backStackEntry -> val id = backStackEntry.arguments?.getString("id") DetailScreen(id = id) } }基于这段代码,把正向导航的时序线完整列出来:
- 调用方(列表页 Composable)执行 navController.navigate("detail/123")。
- NavController 解析路由字符串,与 NavHost 里声明过的 composable("detail/{id}") 匹配,找到 NavDestination。
- NavController 新建一个 NavBackStackEntry,把路由、参数(id=123)、NavDestination 三者关联在一起。
- NavController 把新条目 push 进返回栈,同时更新内部返回栈 StateFlow。
- NavController 触发状态分发:新条目从 INITIALIZED 一路升到 RESUMED;原有的列表页条目从 RESUMED 降为 STARTED。
- NavHost 的订阅者收到返回栈变化,触发重组。
- 重组中 NavHost 找到当前处于 RESUMED 的条目,调用开发者声明的 composable("detail/{id}") 内容,并把 NavBackStackEntry 传给该内容。
- 详情页 Composable 完成首次组合,页面可见可交互。
第 2 步有个细节值得展开。navigate 里传的是字符串模板,"detail/{id}" 中的 {id} 是占位符,NavController 内部把 "123" 填进参数表,放入条目的 arguments。如果你用的是官方推荐的类型安全导航(kotlinx.serialization 路由),这一步变成序列化路由对象的解码,本质相同,只是不再手写易错的字符串。项目里我建议直接上类型安全方案,字符串路由在目标多了以后迟早出拼错事故,到时候排查成本比迁移成本高得多。
3.2 三个容易被画错、理解错的细节
第一,Composable 的重组不是“创建新页面”的瞬间。NavHost 本质上是监听返回栈 StateFlow 的 Composable,返回栈变化后它触发重组,而重组本身由 Compose 运行时调度,是异步的。所以 navigate() 返回那一刻,新页面未必已经绘制。你如果紧接着读取导航目标,拿到的是更新后的数据,但 UI 可能还在路上。写自动化测试时最容易掉进这个时间差,要么用 waitUntil 轮询目标状态,要么直接用官方 testing 库的等待机制。
第二,旧页面状态“降”到哪里去。详情页完全盖住列表页时,列表页从 RESUMED 降到 STARTED,不是销毁。它的界面仍然在组合树里,只是没有焦点、不触发可见性逻辑。为了省内存手动移除它是错误做法,会破坏恢复逻辑。正确做法是交给 NavHost 管理,只在条目真正弹出返回栈时才销毁。这里经常有人误解成“返回后列表页重新加载了”,其实没有,它一直在那儿待命。
第三,NavHost 和 NavController 是观察者关系,不是调用关系。NavController 不直接命令 NavHost 显示新页面,而是通过 StateFlow 发布新状态,NavHost 订阅后自行决定何时重组。所以在时序图上,消息线应该是 NavController -> StateFlow -> NavHost,中间隔着状态对象。网上很多图画成 NavController 直接指向 NavHost,严格来说是错的。理解了观察者模式,你就能解释为什么 navigate() 之后 UI 不立刻刷新。另外,正因如此,凡是“等过渡动画结束再做事”的需求,都不能依赖生命周期回调为准,RESUMED 在动画开始前就已经到达了。
4. 返回与恢复时序:popBackStack 和进程重建
正向时序清楚后,返回时序是另一个高频需求。这里不只是“按返回键回上一页”,还涉及 ViewModel 销毁时机、状态保存时机、系统手势返回等话题。这块线条比正向导航少,但牵扯的状态细节更多。
4.1 返回操作的 6 步时序
继续用上面的场景:详情页在栈顶,按返回键回到列表页。
- 调用方执行 navController.popBackStack(),或者系统返回键通过 BackHandler 回调进入。
- NavController 定位返回栈顶部的详情页条目。
- NavController 弹出该条目,更新返回栈 StateFlow。
- NavController 触发状态分发:被弹出的条目从 RESUMED 一路降到 DESTROYED;列表页条目从 STARTED 回升到 RESUMED。
- NavHost 重组,组合列表页内容。
- 详情页条目销毁后,它的 ViewModelStore 被清理,SavedStateRegistry 里的状态被保存,供未来再次进入时恢复。
第 4 步的顺序值得记一下:先让新栈顶(列表页)回到 RESUMED,再销毁旧条目。这个顺序保证旧页面销毁那一刻,屏幕上已经有一个活跃页面顶替,不会出现空白。用户看到的效果就是秒切,没有中间态。
销毁不等于立刻释放。条目进入 DESTROYED 后,ViewModel 的 onCleared() 才触发。如果你在 onCleared() 里做落库、埋点,要明确一个事实:它触发时新页面已经可见,一切涉及 UI 的收尾工作都不应该放在里面。我见过有人在 onCleared() 里尝试更新 Compose 状态,运气好被忽略,运气不好直接崩,排查起来还挺费劲。
popBackStack() 的返回值也容易踩坑。如果返回栈只剩一个条目,pop 失败返回 false。系统返回键默认会向上冒泡退出 Activity,如果你希望“首页返回弹提示”,必须自己处理:
val navController = rememberNavController() BackHandler { val popped = navController.popBackStack() if (!popped) { // 到根页面了,自己决定退出还是弹提示 activity?.finish() } }4.2 进程被杀后的状态保存与恢复时序
时序图里容易被忽略的是“暗线”:进程重建。Activity 被系统回收内存杀掉后重建,为什么返回栈还在?为什么滚动位置能恢复?因为有一整套保存恢复时序在起作用。
保存阶段大致是:
- 系统触发 Activity 的 onSaveInstanceState。
- NavController 取出注册在 SavedStateRegistry 上的保存回调。
- NavController 遍历返回栈,把每个条目的路由、参数、生命周期状态、rememberSaveable 数据序列化成 Bundle。
- 整个 Bundle 合并进 Activity 的保存状态,交给系统。
恢复阶段反过来:
- Activity 重建时拿到 savedInstanceSate。
- NavController 从 Bundle 反序列化出全部返回栈条目。
- NavController 按原顺序重建返回栈,各条目状态恢复到保存时的值。
- NavHost 重组,显示栈顶条目。
- rememberSaveable 数据恢复;ViewModel 因进程已死只能重建,但 SavedStateHandle 中的数据可以恢复。
这里有一个最常见的误区:以为 ViewModel 进程重建后还能活。进程都死了,ViewModel 对象必然没了。真正活下来的是 SavedStateHandle,那是 ViewModel 构造函数里的一个类似于 Bundle 的容器,恢复时由系统注入新数据。打个比方:ViewModel 是工位上的纸箱,进程杀死瞬间纸箱被收走;但纸箱里重要文件提前复印了一份放在保险柜(SavedStateHandle),重建后系统换了个新纸箱,把复印件放回去。数据看着一样,纸箱却是全新的。
代码层面,如果你的 ViewModel 需要进程重建后恢复参数,就写成:
class DetailViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { private val id: String = savedStateHandle["id"] ?: "" }NavBackStackEntry 创建 ViewModel 时会把 SavedStateHandle 注入进去,而内容来自刚才恢复的返回栈条目。所以哪怕进程死透了,详情页的 id 参数照样在。排查“进程被杀后数据丢失”问题时思路就清晰了:先在时序图里确认数据放在 rememberSaveable 或 SavedStateHandle 里没有,再查恢复时机。如果数据只是普通 ViewModel 的属性且没有走 SavedStateHandle,进程重建后必丢,这不算 Navigation 的 bug。
顺带提一句预测性返回。Android 13 起系统支持预测性返回手势,Compose Navigation 也做了适配。如果只是默认行为,返回时序跟上面描述一致。但如果接入 predictive back,时序图会多一条“系统”参与者的消息线:系统提前询问应用“用户可能要从当前页面返回”,应用在这个窗口里准备过渡动画,之后才真正执行 pop。这段过程和我在嵌入式里看 I2C 时序图的感觉很像:SCL 时钟沿来了,从设备要拉低 SDA 确认,双方按协议握手,不是单向强制。Compose Navigation 的返回同样是协商式的——系统让应用决定是否消费返回手势,不消费就冒泡给 Activity。类比虽说跨界,但熟悉 SPI、I2C 时序图的人应该秒懂:时序图不只是 Android 的专属工具,嵌入式工程师天天画这种握手过程,只是把消息换成电平信号、把参与者换成主从设备而已。
5. 常见问题与排查实录
图画完后,很多曾经的玄学问题变成了可推理的路径。把工作中遇到的和同事踩过的坑整理成速查表,每个问题都能在时序图里找到对应环节,排查效率能翻好几倍。
5.1 导航问题速查表
| 现象 | 时序图环节 | 根因 | 解决思路 |
|---|---|---|---|
| 返回后列表位置丢失 | 正向导航旧条目降级 | LazyListState 没走 rememberSaveable | 用 rememberLazyListState + rememberSaveable 配合保存 |
| ViewModel 页面间共享失败 | 条目销毁清理 | ViewModel 作用域绑定 NavBackStackEntry | 需要跨页共享时升级到 activity ViewModel 或父级作用域 |
| 二次进入同一页面数据残留 | 弹出时状态保存 | rememberSaveable 自动保留旧数据 | 显式清理或用 SavedStateHandle 定向管理 |
| 进程重建后返回栈丢失 | 状态恢复阶段 | NavController 未收到保存回调 | 检查 NavHost 是否与 parentSavedStateRegistry 正确配对 |
| 首页按返回直接退出 | popBackStack 返回 false | 返回栈只剩一条,pop 未处理 | 按 4.1 的代码处理返回值,弹提示或做二次确认 |
| onCleared() 里操作 UI 崩溃 | 销毁时序晚于切换 | onCleared 触发时新页面已可见 | 把 UI 操作迁出 onCleared,改为状态驱动 |
表格里第 5 行再展开说一下。返回栈只剩一条时 popBackStack() 返回 false,不处理的话按返回键没反应,系统向上冒泡后 Activity 就退了。大多数应用期望的“首页返回弹 Toast 提示退出”是自定义行为,必须自己接 BackHandler 来做。很多项目从 Fragment 迁移过来后这里会漏掉,因为 Fragment 时代的返回行为往往被 FragmentManager 掩盖了。
第 4 行的情形我也遇到过。有人为了“灵活”手动包了一层 NavHost 之外的容器,保存逻辑没对上,进程杀后台再回来返回栈直接清空。排查时先在时序图上定位恢复数据源头,再检查 NavController 是否真的把自己注册进了 Activity 的 SavedStateRegistry,基本就能找到问题。
5.2 通用排查套路:先定位时序段,再动手改
分享一个我自己的经验,画完时序图后排查导航问题的效率提升了一个量级。核心方法就一句话:永远先定位当前现象对应时序图的哪一段,再决定改哪里。
举例。同事报了一个 bug:从详情页返回列表页后,列表的滚动位置丢了。按图定位,这个现象对应正向导航里的“旧条目状态降级”阶段。滚动位置是 rememberSaveable 保存的状态,如果列表条目在离开时只是降级没有销毁,那保存的数据应该还在;如果丢失,多半是列表状态压根没走 rememberSaveable。顺这个思路,很快定位到 LazyColumn 的 rememberLazyListState 没有配 rememberSaveable,改一行就好了,根本不用去翻 Navigation 源码。
排查时还可以在关键环节打日志,校验自己的时序图。比如给 NavBackStackEntry 的生命周期挂监听,打印页面进入 RESUMED 的时刻:
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { Log.d("NavTrace", "页面 ${ navBackStackEntry.destination.route } 进入RESUMED") }拿到日志后跟时序图对照,如果顺序对不上,说明有自定义逻辑干扰了状态流,比如手动移除了返回栈里的条目、或者在不该拦截的地方消耗了返回事件。这个方法看着朴素,但帮我抓住了两个“玄学 bug”,最后都在代码审查里定位成了明确问题。
一点补充:排查 Compose Navigation 问题的时候,别急着怀疑库本身。这个库经过多年迭代已经非常稳定,九成的情况都是使用者对返回栈和生命周期理解偏差导致的。时序图不是让你去读源码,而是让你建立一套正确的调用链直觉。有了直觉,错误代码一眼就能扫出来。
最后再分享一个体会。画完 Compose Navigation 时序图,我最大的收获不是“我懂了它内部怎么工作”,而是团队沟通成本下降了。以前讨论返回栈、ViewModel 作用域的时候总是各说各话,现在直接把图搬出来指着讲,三分钟就能对齐。如果你也在做 Compose 项目,我建议花一个下午做同样的事:不管用纸笔还是手绘工具,把 navigate()、popBackStack()、进程恢复这三条主线画出来,放进团队技术文档里。这张图以后会帮你省下无数个排查问题的夜晚。我自己画完图那天晚上,顺手把项目里几个“能跑但说不清原理”的导航写法改成了规范写法,之后三个月,导航相关的问题几乎绝迹。