
从 Fragment 时代转过来的老伙计上手 Compose 之后第一道坎十有八九是路由跳转。以前写页面切换要么startActivity一把梭要么beginTransaction().replace().commit()一套带走到了 Compose 这里突然发现没有 Activity 可以启动也没有 Fragment 可以 add页面变了栈在哪参数怎么带返回键还能不能管用这篇文章就是围绕Jetpack Compose 路由跳转这个最核心的问题从原理讲到实战从最基本的字符串路由讲到带参数跳转、返回栈清理、嵌套导航和深层链接最后把我这两年踩过的坑一并交代清楚。适合刚转 Compose 的 Android 开发也适合已经在项目里用了 Compose 但导航逻辑还靠野路子硬撑的同学。1. 先从根上讲清楚Compose 为什么非要一套新路由1.1 传统跳转方式在 Compose 里为什么拧巴传统 Android 的页面切换本质上是系统维护一个页面栈我往栈里推一层界面就切一次。Activity 之间用 Intent startActivityFragment 之间用 FragmentManager 的事务操作。这套模型在很多年里都很好用因为它和一个页面 一个可回收资源的直觉是对应的。但 Compose 的世界不一样了。Compose 里界面不再是一个页面一个 Activity/Fragment而是一棵状态树。整棵 UI 树根据当前状态实时渲染页面切换的本质是状态切换——从当前是列表页变成当前是详情页。在这个模型下硬拿 startActivity 的思路去跳转会非常别扭每个 Activity 本身就承载一棵完整的 Compose 树为了看个详情再开一个新 Activity等于把一棵树拆成好多棵树状态共享、返回手势、转场动画全都会变得很碎。更麻烦的是Fragment 那一套现在是兼容层。你用FragmentActivity做壳、在里面塞 Compose虽然也能跑但add/remove/replace事务和 Compose 的声明式重组本质上不在一个频道上做多了你会在命令式换页面和状态驱动渲染之间反复横跳代码越写越精分。1.2 Navigation Compose 到底解决了什么Navigation Compose 做的事情很简单在 Compose 的单 Activity 结构里重新实现了一套能感知 Compose 生命周期的页面栈。它不依赖 Activity也不依赖 Fragment而是把每一个页面注册到导航图里成为一个目的地destination跳转就是在一个导航图里切换目的地。它提供的核心能力可以分成四块页面栈调度谁在栈顶、谁能回退、回退到哪一层都由调度中心统一管参数绑定路由里可以带路径参数、查询参数甚至类型安全的参数对象生命周期集成每个目的地对应一个NavBackStackEntry进出栈都有明确的生命周期ViewModel 作用域也挂在它上面深层链接支持应用内外部 URI 都能映射到路由点通知跳页面的需求天然支持。我自己用了大半年之后的体会是Navigation Compose 真正解决的其实不是能不能跳过去而是跳完之后状态怎么办、返回栈怎么管、参数怎么带。这几个问题理顺了页面之间怎么换反而是最次要的。下面这张表是我转 Compose 早期做的对比省得你在两套心智模型之间来回切换维度传统 startActivity / Fragment 事务Navigation Compose页面载体每个页面独立 Activity 或 FragmentNavHost 里的一个 composable 目的地参数传递Intent extras / Fragment arguments路由路径参数、查询参数或类型安全参数返回栈管理Task / FragmentManagerNavController 返回栈可精确 pop 到任意层生命周期Activity / Fragment 生命周期NavBackStackEntry 驱动ViewModel 作用域Activity / Fragment 作用域以目的地返回栈条目为作用域页面重建系统或事务触发重建重组 状态恢复机制2. 四个核心角色认全路由就算入门一半2.1 NavController所有跳转动作的总调度NavController是整个路由系统的调度中心你可以把它理解成旧世界的 FragmentManager Task 的结合体。它维护了当前的返回栈提供navigate()、popBackStack()、navigateUp()这些核心操作同时也知道当前栈里每一层页面是什么状态、带了什么参数。在 Compose 里获取它的方式非常统一val navController rememberNavController()这里有两点值得注意。第一rememberNavController()是绑定在当前 Composable 生命周期上的如果这个 Composable 因为某些原因退出了 compositioncontroller 也会被回收。所以官方建议把NavController放在尽量靠近根节点的位置比如MainActivity的setContent里或者导航图的最外层。第二它内部用rememberSaveable保存了返回栈信息因此配置变更转屏、字体缩放时返回栈不会丢——这点和 Fragment 的系统帮你恢复类似但实现上轻了不少。2.2 NavHost页面容器和导航图的宿主NavHost是一个 Composable专门用来把NavController和导航图绑定在一起。说白了你希望有多少页面、每个页面对应哪个 Composable、从哪个页面开始这些东西全都写在NavHost里。NavHost( navController navController, startDestination home ) { // 在这里注册一个个目的地 }startDestination就是 App 启动后默认展示的那个页面。这个参数是必填的而且它必须在导航图里真实存在否则运行时会直接抛异常。特别提醒一下NavHost在 Composable 树里的位置要稳定别把它塞进某个会被条件判断隐藏的子树里否则页面栈会跟着NavHost一起消失这问题排查起来相当隐蔽。2.3 composable()注册目的地Navigation Compose 里注册一个页面的方式就是在NavHost作用域内调用composable()composable(home) { HomeScreen() } composable(profile/{userId}) { ProfileScreen() }第一个参数是路由字符串第二个大括号里拿到的是这个页面应该长什么样的 Composable 内容。一旦用户跳转到profile/123NavHost就会在合适的位置重组出ProfileScreen()同时把profile/123压入返回栈。这里要建立的一个核心认知是路由字符串只是地址不是Activity 对象也不是 Fragment 对象。跳转时系统做的事情是给返回栈增加一个条目真正的页面内容是在栈条目变成栈顶之后由composable()的 lambda 渲染出来的。所以 navigation 和 Compose 的状态驱动并不矛盾反而配合得很好。2.4 NavBackStackEntry每个目的地的户口本当你跳转之后composable()的回调里会拿到一个NavBackStackEntry对象composable( route profile/{userId}, arguments listOf(navArgument(userId) { type NavType.StringType }) ) { backStackEntry - val userId backStackEntry.arguments?.getString(userId).orEmpty() ProfileScreen(userId userId) }NavBackStackEntry可以理解成页面在返回栈里的户口本。它记录了这页面的路由信息、解析好的参数、生命周期状态甚至给你挂了一个SavedStateHandle用来存页面级的数据。跳转页面的参数就是通过backStackEntry.arguments这个 Bundle 读取的参数类型在注册目的地时用navArgument声明。这个设计决定了后面所有参数问题的排查方向只要进了composable()回调先看backStackEntry.arguments数据不对一定是路由参数定义或拼 route 字符串时出了问题跟 Composable 本身没什么关系。3. 第一个能跑的项目骨架从列表页跳详情页开始3.1 依赖和环境准备新建项目或往现有项目里加 Navigation Compose只需要在 module 的build.gradle.kts里引入dependencies { implementation(androidx.navigation:navigation-compose:2.8.9) }如果你后面要用类型安全路由Type-safe Navigation还需要加序列化插件和依赖plugins { kotlin(plugin.serialization) version 2.0.21 } dependencies { implementation(org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3) }这个插件是给Serializable注解用的等讲到类型安全路由时你会理解为什么需要它。项目本身不需要额外权限单 Activity 架构下所有页面都在MainActivity里的NavHost中切换。3.2 搭出最朴素的导航骨架我把一个最基础的列表页 → 详情页骨架完整写出来这是后面所有讨论的底子class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { AppTheme { AppNavGraph() } } } } Composable fun AppNavGraph() { val navController rememberNavController() NavHost( navController navController, startDestination home ) { composable(home) { HomeScreen( onUserClick { userId - navController.navigate(profile/$userId) } ) } composable( route profile/{userId}, arguments listOf( navArgument(userId) { type NavType.StringType } ) ) { backStackEntry - val userId backStackEntry.arguments?.getString(userId).orEmpty() ProfileScreen(userId userId) } } }HomeScreen和ProfileScreen就是你自己的页面 Composable。点击列表项时通过navController.navigate(profile/$userId)带着 ID 跳过去非常简单。第一次跑通这个流程之后你就能直观地感受到 Compose 导航和传统方式的区别没有跳转动画的书写没有onActivityResult页面切换只是往返回栈里压了一个条目。3.3 字符串路由 路径参数的第一次实战上面代码里的profile/{userId}就是路径参数的写法。大括号里的userId是占位符真正跳转时用具体的值替换它。注意几个细节占位符名字必须和navArgument(userId)里的名字完全一致哪怕差一个字母运行时拿到的参数也是 null跳转时拼字符串要用profile/$userId别随手写成profile/ userId如果 userId 是Long转成字符串没问题但要注意 null 的情况navArgument如果不写类型默认按StringType处理所以上面代码里其实可以不写type但写出来更明确团队里其他人一眼能看懂。参数拿到手之后我强烈建议不要直接塞给ProfileScreen(userId userId)就算完更好的做法是把这个 userId 传给ProfileScreen内部创建的 ViewModel让页面自己根据 ID 去仓库层加载数据。这样页面只拿一个 ID数据加载逻辑完全收敛在 ViewModel 里返回的时候也不需要重新传参。3.4 传参数只传 ID是我反反复复强调的一条铁律新手最常见的冲动是把一个对象整个塞进路由里跳转比如navController.navigate(profile/${user})。千万别这么干。原因有三层第一路由参数最终会落到NavBackStackEntry的 Bundle 里这个 Bundle 是要参与进程被杀后的状态恢复的。你把一个普通对象塞进去进程恢复时反序列化一定会出问题。即使你用Parcelable或序列化能硬转过关也会让路由层承担本不该承担的数据持久化职责。第二跨页面传整个对象会让数据从哪来变得模糊。A 页面持有User对象B 页面也想用User如果每次都从 A 传过去B 页面一旦被系统重建就得依赖传过来的对象而仓库和 ViewModel 才是数据唯一可靠的来源。第三可维护性差。对象字段一变所有传参点都得跟着改你要是把这个对象用在了五六个页面的路由上那就是五六个炸弹。正确姿势永远是路由只传稳定且可恢复的最小标识符比如userId、orderId、articleId。目标页面拿到 ID 之后自己通过仓库或者共享 ViewModel 去取完整数据。4. 参数跳转的进阶姿势默认值、可空参数与类型安全路由4.1 navArgument 的完整配置navArgument不止能声明类型还能配置默认值和可空性composable( route profile?source{source}, arguments listOf( navArgument(userId) { type NavType.StringType }, navArgument(source) { type NavType.StringType defaultValue unknown nullable false } ) ) { backStackEntry - val userId backStackEntry.arguments?.getString(userId) ?: val source backStackEntry.arguments?.getString(source) ?: unknown ProfileScreen(userId userId, source source) }这里把source做成了查询参数用?连接在路由后面。查询参数的好处是可以缺省——跳转时你可以只写profile?userId100source就会自动使用默认值不用每次都拼上。但路径参数大括号里的不一样它在路由里占了位置跳转时必须有值否则这层路由根本匹配不上。实际项目中我见过不少因为忘记配 defaultValue导致的崩溃典型场景是上游数据源某个字段可能为空跳转代码直接拼出了profile?userId参数是空字符串到了目标页面解析出来的就是一个空 ID然后在网络请求里炸了。所以我的习惯是凡是可能被缺省传值的参数一律给defaultValue并且在目标页面读取时再做一次兜底判断。4.2 Serializable 类型安全路由怎么用Navigation 2.8 开始官方支持了类型安全路由也就是你在热点搜索里看到的android compose 注解标签那一套。核心是用Serializable标记一个路由类或 object用它来代替手写字符串Serializable data object HomeRoute Serializable data class ProfileRoute(val userId: String) Composable fun AppNavGraph() { val navController rememberNavController() NavHost( navController navController, startDestination HomeRoute ) { composableHomeRoute { HomeScreen( onUserClick { userId - navController.navigate(ProfileRoute(userId)) } ) } composableProfileRoute { backStackEntry - val profileRoute backStackEntry.toRouteProfileRoute() ProfileScreen(userId profileRoute.userId) } } }路由不在是字符串而是实实在在的类型。HomeRoute用data object因为它是无参路由ProfileRoute用data class因为要带userId参数。跳转时直接navigate(ProfileRoute(userId))目标页面里用backStackEntry.toRouteProfileRoute()解析参数完全没有手拼字符串的环节。这套方案最大的优势是编译期类型检查。字符串路由最大的痛点是写错一个字母、少写一个斜杠运行时才炸类型安全路由直接把这类问题挡在编译之前。而且 IDE 对数据类的参数有补全提示重构时也不怕漏改。4.3 两种方案怎么选我的判断标准我的项目在这两种方案之间试过不少时间结论比较务实已有项目、路由较少、团队对字符串路由已经很熟练没必要为了追新全量重写保持字符串路由完全没问题你只需管好参数命名规范新项目、路由数量会快速增长、团队愿意引入 kotlinx.serialization直接用类型安全路由长期收益明显需要大量深层链接映射的项目类型安全路由的深层链接依然需要写 URI 字符串所以你不妨继续用字符串路由避免两种寻址方式混着用。我自己的新项目现在是类型安全路由为主。一个数据类定义好路由全项目所有跳转点都引用它再也没发生过路由拼错了这种低级事故。5. 返回值、返回栈与常见的翻车现场5.1 路由也能模拟startActivityForResultSavedStateHandle 回传结果Compose 导航里没有onActivityResult但B 页面选完东西把结果告诉 A 页面这个需求很常见。官方推荐的做法是借助previousBackStackEntry的SavedStateHandle// B 页面用户点了某个选项后 navController.previousBackStackEntry ?.savedStateHandle ?.set(selectedItem, itemId) navController.popBackStack()// A 页面在进入时监听 val savedStateHandle navController.currentBackStackEntry?.savedStateHandle LaunchedEffect(savedStateHandle) { savedStateHandle?.getStateFlow(selectedItem, 0L)?.collect { selectedId - // 在这里更新 A 页面的 UI 状态 } }这个模式的本质是A 和 B 在返回栈里是相邻条目B 在出栈前把自己的结果写进 A 的SavedStateHandleA 再通过getStateFlow收到变化。注意几点previousBackStackEntry在 B 页面刚入栈时可能不稳定所以写入操作最好放在 B 确定要返回之前的那个时机A 的监听用LaunchedEffect包住key 绑定到currentBackStackEntry防止监听错对象。5.2 launchSingleTop、popUpTo 与登录页销毁路由跳转并不是每次都要往栈里加一层。两个高频场景必须用返回栈操作配合。场景一用户快速点击同一个入口跳了两次。导航到同一个目的地时如果栈顶已经是它我们想忽略这次跳转。一个基础防线是launchSingleTopnavController.navigate(profile/$userId) { launchSingleTop true }但说实话launchSingleTop在 Compose 里的防重复能力有限因为navigate()是同步执行的栈顶更新发生在重组之前连续两次点击时第一次导航产生的栈顶还没落定第二次照样可能触发。所以我在正式项目里还会加一道保险用一个简单的navigating标志位或协程防抖确保同一时间只有一个跳转在处理。场景二登录成功后不想让用户按返回键回到登录页。做法是popUpTonavController.navigate(main) { popUpTo(login) { inclusive true } }popUpTo(login)表示把 login 之上的页面全部弹出inclusive true表示把 login 自己也算进去。整个业务流的语义就变成登录页完成使命后直接从栈里消失主页面成为新的栈底。类似的需求还有下单完成页、注册成功页凡是到达新页面后旧流程不应该再回去的都要考虑这一手。5.3 返回栈失控的几个典型症状与修复我整理了几个自己在项目里真实遇到、也被同事反复问过的问题症状一页面跳过去了目标页面拿到的参数却是 null。排查链路就两条——第一看路由占位符名字和navArgument名字是否一致第二看跳转拼的字符串格式和注册的 route 格式是否匹配比如少写了?或{}。90% 的参数 null都是这两类低级错误。症状二从 B 返回 AA 的滚动位置、输入框内容全没了。这是因为当 B 压栈后A 已经不在栈顶Compose 会把 A 的 composition 销毁以释放资源A 里的remember状态随之丢失。解决办法有二界面状态用rememberSaveable保存或者把状态提升到页面级的 ViewModel 里。一个页面如果状态多我更推荐后者毕竟rememberSaveable只解决值还在不在解决不了谁该持有这些状态的架构问题。症状三转屏之后回到某个深层页面直接崩溃。最常见的原因就是在路由参数里传了不可序列化的对象。转屏时系统会尝试保存并恢复整个返回栈Bundle 塞不进去的东西就成了不稳定因素。这个问题的根治办法就是回到 3.4 那条铁律——路由只传稳定标识符。症状四栈越堆越深返回键要按七八下。原因是跳转时没有做栈清理。电商类 App 里列表→详情→下单→支付→结果这条链路不写popUpTo的话每一层都留在栈里用户退出时体验极差。处理方式就是在关键的流程终点跳转时把中间页面的栈清掉只留主界面或流程出口。6. 再往前走一步嵌套导航、深层链接与状态保真6.1 嵌套导航图与底部 Tab 的标准写法大型 App 里的首页/消息/我的这类底部 Tab首页内部还有自己的子页面直接平铺维护会很乱。Navigation Compose 支持嵌套导航图用navigation()包一层NavHost( navController navController, startDestination root ) { navigation( route root, startDestination home ) { composable(home) { HomeScreen() } composable(home/detail) { HomeDetailScreen() } } composable(message) { MessageScreen() } composable(mine) { MineScreen() } }切底部 Tab 时标准做法是配合popUpTosaveStaterestoreState让每个 Tab 的页面状态都能保留切换时才不会反复重建navController.navigate(tabRoute) { popUpTo(navController.graph.findStartDestination().id) { saveState true } launchSingleTop true restoreState true }这段代码的意思是跳到目标 Tab 时把返回栈清到根节点但保留当前 Tab 的状态下次切回来时恢复之前保存的状态。这样 Tab 切换的体验才接近原生 App 该有的样子不会每次切 Tab 都把之前的页面状态丢掉。嵌套导航图的价值主要有两个一是把一组相关页面聚合在一个命名空间下路由管理更清晰二是可以针对整个子图做统一的返回栈操作。6.2 深层链接从外部拉起应用的跳转深层链接Deep Link是路由系统里经常被忽略但含金量很高的能力。它允许你用一条 URI 直接定位到某个页面比如myapp://profile/123。在 Compose 导航里给目的地声明深层链接有两种场景。场景一是应用内跳转注册时直接在composable()里加deepLinkscomposable( route profile/{userId}, deepLinks listOf( navDeepLink { uriPattern myapp://profile/{userId} } ) ) { backStackEntry - // ... }场景二是外部拉起比如从推送通知点进来。除了上面的注册还必须在AndroidManifest.xml里给承载NavHost的 Activity 加 intent-filterintent-filter action android:nameandroid.intent.action.VIEW / category android:nameandroid.intent.category.DEFAULT / category android:nameandroid.intent.category.BROWSABLE / data android:schememyapp / /intent-filter实际匹配工作由 NavController 内部完成Manifest 里的 filter 只负责告诉系统这个 Activity 接收这类链接所以 scheme 写对即可。深层链接真正的好处是通知、短信、网页里点一条链接App 可以直接落到对应页面而不是每次都要打开首页再折腾跳转。做运营活动或者客服反馈流程时这个能力几乎是必选项。6.3 配置变更下路由状态不丢的秘密最后聊一个很多人没意识到的点为什么转屏之后Compose 导航的返回栈还在这背后是rememberNavController()的功劳它的返回栈信息被rememberSaveable持久化了。但你要明白这只能保存栈里还有谁页面内部的滚动位置、表单内容、列表数据并不归它管。我推荐的组合是页面级的界面状态用rememberSaveable做瞬态保存页面级的业务数据放在以NavBackStackEntry为作用域的 ViewModel 里全局的状态放在 Activity 作用域的 ViewModel 里。这样转屏、进程被杀重建路由栈能恢复页面数据能恢复界面状态也不会回到原点。这里也有个容易踩的坑NavBackStackEntry一旦被 pop以它为作用域的 ViewModel 会立刻被清掉这是系统的设计不是 bug。如果你的页面 A 被 B 压栈了A 的 ViewModel 不会清因为 A 还在栈里但 A 被popUpTo弹出去之后数据就没了。所以流程终点清栈的时候要考虑清晰栈之后是否还需要旧页面的残留数据。用类型安全路由之后rememberNavController()这套组合拳在我项目里已经稳定跑了一年多。最后分享一个调试技巧排查路由问题时别光靠Log你可以把navController.currentBackStackEntryAsState()的值临时显示在一个调试面板上看看当前栈顶到底是哪个 destination——返回栈到底长什么样立刻一目了然。我自己现在的选择是新页面全部走Serializable类型安全路由流程终点无脑popUpTo清栈防重复跳转统一封装一个带时间锁的导航函数。这一套下来团队里再也没有人因为路由字符串拼错来找我了。路由这事儿看着简单但只有把返回栈和参数这两件事真正理顺了一个多页面应用才不会在用户手里变成返回键灾难现场。