ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Compose Navigation 时序图详解:从 navigate() 到页面切换的完整链路

Compose Navigation 时序图详解:从 navigate() 到页面切换的完整链路 做 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 跳到 BA 的 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把路由、参数id123、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()、进程恢复这三条主线画出来放进团队技术文档里。这张图以后会帮你省下无数个排查问题的夜晚。我自己画完图那天晚上顺手把项目里几个“能跑但说不清原理”的导航写法改成了规范写法之后三个月导航相关的问题几乎绝迹。
返回列表