☰
Compose Navigation 时序图详解:从 navigate() 到页面切换的完整链路
2026/10/1 12:01:19 网站建设 项目流程

做 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 下会更敏感,也更符合直觉。

这段时序的文字版大致是:

  1. NavController 内部的返回栈 StateFlow 发生变化
  2. NavController 触发状态分发,逐项计算每个条目的目标状态
  3. 对每个条目调用 LifecycleRegistry.currentState 设置新状态
  4. 各条目的 LifecycleObserver 被回调
  5. NavHost 感知到当前条目变化,触发重组
  6. 目标条目对应的 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) } }

基于这段代码,把正向导航的时序线完整列出来:

  1. 调用方(列表页 Composable)执行 navController.navigate("detail/123")。
  2. NavController 解析路由字符串,与 NavHost 里声明过的 composable("detail/{id}") 匹配,找到 NavDestination。
  3. NavController 新建一个 NavBackStackEntry,把路由、参数(id=123)、NavDestination 三者关联在一起。
  4. NavController 把新条目 push 进返回栈,同时更新内部返回栈 StateFlow。
  5. NavController 触发状态分发:新条目从 INITIALIZED 一路升到 RESUMED;原有的列表页条目从 RESUMED 降为 STARTED。
  6. NavHost 的订阅者收到返回栈变化,触发重组。
  7. 重组中 NavHost 找到当前处于 RESUMED 的条目,调用开发者声明的 composable("detail/{id}") 内容,并把 NavBackStackEntry 传给该内容。
  8. 详情页 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 步时序

继续用上面的场景:详情页在栈顶,按返回键回到列表页。

  1. 调用方执行 navController.popBackStack(),或者系统返回键通过 BackHandler 回调进入。
  2. NavController 定位返回栈顶部的详情页条目。
  3. NavController 弹出该条目,更新返回栈 StateFlow。
  4. NavController 触发状态分发:被弹出的条目从 RESUMED 一路降到 DESTROYED;列表页条目从 STARTED 回升到 RESUMED。
  5. NavHost 重组,组合列表页内容。
  6. 详情页条目销毁后,它的 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 被系统回收内存杀掉后重建,为什么返回栈还在?为什么滚动位置能恢复?因为有一整套保存恢复时序在起作用。

保存阶段大致是:

  1. 系统触发 Activity 的 onSaveInstanceState。
  2. NavController 取出注册在 SavedStateRegistry 上的保存回调。
  3. NavController 遍历返回栈,把每个条目的路由、参数、生命周期状态、rememberSaveable 数据序列化成 Bundle。
  4. 整个 Bundle 合并进 Activity 的保存状态,交给系统。

恢复阶段反过来:

  1. Activity 重建时拿到 savedInstanceSate。
  2. NavController 从 Bundle 反序列化出全部返回栈条目。
  3. NavController 按原顺序重建返回栈,各条目状态恢复到保存时的值。
  4. NavHost 重组,显示栈顶条目。
  5. 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()、进程恢复这三条主线画出来,放进团队技术文档里。这张图以后会帮你省下无数个排查问题的夜晚。我自己画完图那天晚上,顺手把项目里几个“能跑但说不清原理”的导航写法改成了规范写法,之后三个月,导航相关的问题几乎绝迹。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询