ARTICLE DETAIL

资讯详情

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

Compose Navigation 实战指南:路由设计、嵌套导航与状态恢复

Compose Navigation 实战指南:路由设计、嵌套导航与状态恢复 从 Jetpack Compose 全面普及开始页面导航就成了很多团队绕不开的坎。早期用 Fragment 那套 Navigation 组件时路由跳转、参数传递、返回栈管理都还算顺手但切到 Compose 后如果你还抱着 Fragment 的思路不放一边写setContent一边又维护一堆 Fragment 嵌套那开发效率会瞬间被打回原形。说实话Compose Navigation官方库androidx.navigation:navigation-compose才是 Compose 应用里真正该用的导航方案。这篇文章我不打算做官方文档的翻译而是结合我自己在项目里的实际经验把 Compose Navigation 从入门到进阶的完整路径拆给你看包括路由设计、参数传递、嵌套导航、底部栏联动、状态恢复这些日常必踩的点以及我踩过坑之后的排查实录。不管你是刚转 Compose 的新手还是已经在生产环境里跑过一段时间的老手这篇应该都能给你点参考。1. 先理解 Compose Navigation 的核心设计思路1.1 从 Fragment Navigation 说起为什么不能直接照搬老 Android 开发者对FragmentNavigator应该都不陌生。它本质上是一个基于 Fragment 的导航器通过 FragmentManager 来管理页面堆栈。这套方案在 View 体系下非常成熟配上 XML 的 navigation graph写起来确实省心。但到了 Compose 时代页面本身就是普通函数没有生命周期宿主也没有 Transaction 的概念。如果你非要把 Compose 页面裹进 Fragment 里用 FragmentNavigator 去管就会出现一个很尴尬的局面每个页面多了一层无意义的 Fragment 壳子Fragment 的onCreateView里只做了一件事把 ComposeView 返回出去。性能损耗其实还在其次更麻烦的是状态恢复逻辑被割裂了——Fragment 的状态恢复机制和 Compose 的rememberSaveable各管一摊出了问题排查起来非常难受。所以 Google 在 Navigation 2.4.0 之后正式推出了navigation-compose它的导航器不再依赖 FragmentManager而是直接用 Compose 的声明式机制来渲染目标页面同时自己维护一份返回栈Back Stack这份返回栈存储的是导航图中的目的地信息和参数快照跟 Fragment 的返回栈完全独立。1.2 Compose Navigation 的三大件到底在做什么Compose Navigation 的核心其实就三个东西NavController、NavHost和NavGraph。NavController负责管理导航状态。它内部维护了一个返回栈的StateFlow每次navigate()或者popBackStack()都会更新这个返回栈从而触发 Compose 重组。你可以把它理解成整个导航系统的“大脑”。NavHost则是NavController和页面内容之间的桥梁。它读取返回栈中的当前目的地然后找到对应的composable()注册的 Composable 函数渲染到界面上。NavHost本身是一个 Composable所以导航切换本质上就是重组的过程。NavGraph描述的是目的地与目的地之间的关系。你可以把整个 App 的导航结构画成一张有向图节点就是各个页面边就是跳转动作。一个 App 可以只有一个根 NavGraph也可以拆成多个子图Nested Graph来管理不同的功能模块。这三者关系捋顺了之后很多模糊的报错就能看出来原因了。比如最常见的IllegalArgumentException: Navigation destination that matches request ... cannot be found in the navigation graph十有八九是NavHost的 startDestination 和你的路由标识对不上。2. 路由设计的选型字符串路由还是类型安全路由2.1 字符串路由到底差点什么Navigation 2.4.0 刚支持 Compose 那会唯一的写法就是字符串路由。比如这样Serializable object Profile composableProfile { ProfileScreen() }这段代码其实很清晰地表达了我的意图定义了一个Profile目的地并将ProfileScreen与之关联。这种方式的好处是简洁直观不用在NavHost中为每个目的地手动声明字符串。但是如果你把整个项目的路由都定义在一个文件里长此以往就会遇到一些问题。最明显的问题就是路由的可见性与可维护性。字符串路由分散在各处一旦页面名称调整你需要全局搜索profile这个字段去逐个替换容易出现遗漏而且 IDE 的全局重构功能对普通字符串无能为力。其次参数传递非常脆弱。你需要手动拼接 URInavController.navigate(profile/$userId?sourcehome)然后在目标页面这边解析composable( route profile/{userId}?source{source}, arguments listOf( navArgument(userId) { type NavType.StringType }, navArgument(source) { type NavType.StringType, defaultValue unknown } ) ) { backStackEntry - val userId backStackEntry.arguments?.getString(userId) val source backStackEntry.arguments?.getString(source) ProfileScreen(userId userId, source source) }这只是两个参数就已经开始出现样板代码了。如果页面有五个甚至十个参数这个arguments列表会非常冗长。更要命的是如果调用方少传了某个参数或者类型写错了编译期完全不会报错只有运行到那一刻才会 crash而且错误信息往往还很隐晦。2.2 类型安全路由为什么是更好的选择Navigation 2.8.0 开始支持基于kotlinx.serialization的类型安全路由这是我认为 Compose Navigation 到目前为止最重要的一次升级。类型安全路由把“路由”这个概念从字符串变成了可序列化的对象。比如上面的Profile就只是一个Serializable objectnavigate()时直接传对象不用拼字符串定义composable时也直接用类型参数不用写route。参数传递则是通过对象的属性完成Serializable data class ProfileRoute( val userId: String, val source: String unknown ) // 跳转 navController.navigate(ProfileRoute(userId 123, source home)) // 定义目的地 composableProfileRoute { backStackEntry - val route backStackEntry.toRouteProfileRoute() ProfileScreen(userId route.userId, source route.source) }你注意看调用方和接收方的参数结构是同一个数据类所以如果字段改名、类型变化编译期就会直接报错根本不需要运行。这一点对团队协作的帮助非常大路由的参数结构被强制契约化了谁改谁负责不会再出现“调用方传了 int接收方拿 string跑起来崩了”这种低级问题。如果你是新建项目我强烈建议直接上类型安全路由。如果你在维护老项目字符串路由也不是不能用但最好逐步把高频页面迁移到类型安全方案上。2.3 单 Activity 架构下的导航图拆解Compose 项目的标准架构是单 ActivityActivity 里只放一个根NavHost。但整个 App 的导航图如果全部堆在setContent里会变得非常臃肿。我的做法是按业务模块拆分 NavGraph。比如一个电商 App 可以拆成AuthGraph、HomeGraph、OrderGraph、MineGraph这几个子图。每个模块对外暴露一个扩展函数内部注册本模块的页面fun NavGraphBuilder.authGraph( onLoginSuccess: () - Unit, ) { composableLoginRoute { LoginScreen(onLoginSuccess onLoginSuccess) } composableRegisterRoute { RegisterScreen() } } fun NavGraphBuilder.orderGraph( onBack: () - Unit, ) { composableOrderListRoute { OrderListScreen(onBack onBack) } composableOrderDetailRoute { backStackEntry - val route backStackEntry.toRouteOrderDetailRoute() OrderDetailScreen(orderId route.orderId, onBack onBack) } }然后在根NavHost里统一组合NavHost( navController navController, startDestination startRoute ) { authGraph() homeGraph() orderGraph() }这样做的好处很明显模块之间通过回调函数通信不直接依赖彼此的类降低耦合。每个子图的注册逻辑内聚在一个文件里新增页面时很容易找到位置。root NavHost 看起来非常干净只做模块组合不关心内部细节。当然子图之间的跳转需要传递NavController或者回调这个要看团队约定。我习惯是只在根 NavHost 这一层持有 NavController子图内部不直接引用通过回调把动作抛出去。这样每个模块都能独立测试不会因为 NavController 实例变化而写一堆 mock。3. 从零到一完整实现依赖配置与基础导航3.1 依赖版本怎么选避免编译冲突先说依赖版本。navigation-compose目前已经迭代了很多版本不同版本的 API 差别比较大尤其是类型安全路由相关的写法2.7.x 和 2.8.x 就不一样。我用的是 2.8.0 以上的版本所以下面所有代码都基于这个版本。// build.gradle.kts (Module: app) dependencies { implementation(androidx.navigation:navigation-compose:2.8.4) // 如果你用类型安全路由需要加 kotlinx-serialization 插件 // 根目录 build.gradle.kts: // plugins { // kotlin(plugin.serialization) version 2.0.21 apply false // } implementation(org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3) }这里有一个容易踩的坑kotlinx-serialization的版本必须和你的 Kotlin 编译器版本兼容。比如你用 Kotlin 2.0.xserialization 用 1.6.x 可能没问题但用 1.7.x 有时会报一个奇怪的编译器警告其实不影响运行但为了干净起见最好去官方的版本映射表上核对一下。3.2 最小可运行示例列表页跳详情页我直接给你一个可运行的示例。假设我们有两个页面HomeScreen和一个DetailScreen从首页点按钮跳详情详情页显示商品 ID。路由定义Serializable object HomeRoute Serializable data class DetailRoute(val itemId: String)这里的HomeRoute是object因为它不需要参数就是一个单纯的页面而DetailRoute是data class携带一个itemId参数。注意如果 data class 需要有默认值才能做深层链接那种场景但是作为普通跳转参数来说有没有默认值看业务需要。根 ComposableMainActivity 的 setContentComposable fun App() { val navController rememberNavController() NavHost( navController navController, startDestination HomeRoute ) { composableHomeRoute { HomeScreen( onItemClick { itemId - navController.navigate(DetailRoute(itemId)) } ) } composableDetailRoute { backStackEntry - val route backStackEntry.toRouteDetailRoute() DetailScreen( itemId route.itemId, onBack { navController.popBackStack() } ) } } }两个页面的 ComposableComposable fun HomeScreen(onItemClick: (String) - Unit) { Column(modifier Modifier.padding(16.dp)) { Text(商品列表, style MaterialTheme.typography.headlineSmall) repeat(5) { index - Button( onClick { onItemClick(item_$index) }, modifier Modifier.padding(vertical 8.dp) ) { Text(查看商品 $index) } } } } Composable fun DetailScreen(itemId: String, onBack: () - Unit) { Column(modifier Modifier.padding(16.dp)) { Text(这是商品 $itemId 的详情页, style MaterialTheme.typography.titleLarge) Spacer(modifier Modifier.height(16.dp)) Button(onClick onBack) { Text(返回列表) } } }这就完成了一个最基本的导航闭环首页 - 详情页 - 返回。代码量比 Fragment 那套少了大概一半而且所有类型都是强转的编译期就能发现问题。3.3 参数回传的惯用姿势Compose Navigation 没有自带startActivityForResult那样直接的参数回传 API但官方提供了savedStateHandle机制。详情页在返回前把结果写到上一个页面的SavedStateHandle里列表页再从自己的SavedStateHandle里读取。具体的做法是这样的在详情页写结果composableDetailRoute { backStackEntry - DetailScreen( itemId route.itemId, onBackWithResult { result - // 拿到当前页面的 previousBackStackEntry navController.previousBackStackEntry ?.savedStateHandle ?.set(selected_result, result) navController.popBackStack() } ) }在列表页读结果这里有点绕需要在创建HomeScreen的 composable 作用域里拿到currentBackStackEntry的savedStateHandle然后观察其中的数据composableHomeRoute { backStackEntry - // 监听返回结果 val result backStackEntry.savedStateHandle .getStateFlow(selected_result, ) .collectAsState() LaunchedEffect(result.value) { if (result.value.isNotBlank()) { // 拿到结果后在这里处理业务逻辑 } } HomeScreen( onItemClick { itemId - navController.navigate(DetailRoute(itemId)) } ) }注意previousBackStackEntry这个 API 获取的是返回栈里排在当前页面下面的那个条目所以从 Detail 返回到 Home 时正确写法是 Detail 的previousBackStackEntry拿到 Home 的savedStateHandle写入数据。反过来在 Home 里读就要读 Home 自己的backStackEntry.savedStateHandle。这中间只要错一层结果就丢了。这个模式是官方推荐但不太显眼的我在写 Demo 时经常看到有人把它当成全局 EventBus 用这不太合适。它只适合上游页面给下游页面传回件这种场景跨模块、跨层级的通信还是老老实实用 ViewModel 或者单例事件总线。4. 嵌套导航与底部导航栏的联动实现4.1 底部导航栏的经典实现与常见坑底部导航栏加 NavHost 是移动 App 最经典的架构之一。用 Navigation Compose 实现底部栏联动核心思路是底部栏的每一项对应导航图中的一个根目的地点击切换时用navigate()把对应的路由压入返回栈同时通过popUpTo控制返回栈保证只保留一个根目的地。我在项目里的实现大概长这样Composable fun MainScreen() { val navController rememberNavController() // 底部的三个 Tab 目的地 val items listOf( BottomItem(route HomeRoute, label 首页, icon Icons.Filled.Home), BottomItem(route CategoryRoute, label 分类, icon Icons.Filled.GridView), BottomItem(route MineRoute, label 我的, icon Icons.Filled.Person) ) Scaffold( bottomBar { NavigationBar { val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination navBackStackEntry?.destination items.forEach { item - NavigationBarItem( selected currentDestination?.hierarchy?.any { it.route item.route::class.qualifiedName } true, onClick { navController.navigate(item.route) { // 避免重复创建 Tab popUpTo(navController.graph.findStartDestination().id) { saveState true } // 避免多次点击产生多个实例 launchSingleTop true restoreState true } }, icon { Icon(item.icon, contentDescription item.label) }, label { Text(item.label) } ) } } } ) { innerPadding - NavHost( navController navController, startDestination HomeRoute, modifier Modifier.padding(innerPadding) ) { composableHomeRoute { HomeScreen() } composableCategoryRoute { CategoryScreen() } composableMineRoute { MineScreen() } } } } data class BottomItem( val route: Any, val label: String, val icon: ImageVector )这里面有几个关键细节缺一个都会出问题。第一个是popUpTo(graph.findStartDestination().id) { saveState true }。它的意思是在跳转新 Tab 之前把返回栈弹到起始目的地但要保存之前 Tab 的状态。这样用户从首页切到分类再切回首页时首页的滚动位置这些 UI 状态还在。如果你的代码里少了saveState true底部 Tab 一切换整个页面状态就会全部重置体验非常差。第二个是launchSingleTop true。不加这个的话你连续点击同一个 Tab 两次返回栈里会出现两个相同的页面实例按返回键时会出现非常诡异的“跳回自己”的效果。这个错误很隐蔽往往得装机实测才能发现。第三个是选中态的判定。我用了currentDestination?.hierarchy为什么要用hierarchy而不是直接比对route因为如果某个 Tab 页面下面挂了子页面比如在“首页” Tab 下跳到了详情页当前 destination 其实是详情页而不是首页但底部导航的高亮仍然应该显示“首页”。hierarchy会遍历当前目的地的所有父图只要父图里包含这个 Tab 路由就认为这个 Tab 处于激活状态。用route直接比较的话会出现详情页时底部 Tab 全部不亮的情况很难看。4.2 嵌套导航图怎么处理 Tab 内部的二级页面如果你只是做 Tab 之间切换上面的代码已经够了。但实际项目里每个 Tab 内部通常还有自己的二级、三级页面。比如“首页” Tab 下面有搜索页、商品详情页这些页面跳转时底部导航栏通常是需要隐藏的。处理办法是把每个 Tab 的子页面放到一个嵌套图Nested Graph里并让 NavHost 的 startDestination 指向一个内部容器页面。但我更推荐的做法是每个 Tab 的根页面和子页面注册在同一个 NavController 下子页面通过navigate()直接压栈等到子页面进入返回栈后底部栏根据当前 destination 的 hierarchy 自动隐藏。实现上就是你只需要在 NavHost 里把页面注册好NavHost(navController, startDestination HomeRoute) { // 一级 Tab composableHomeRoute { HomeScreen() } composableCategoryRoute { CategoryScreen() } composableMineRoute { MineScreen() } // 二级页面 composableSearchRoute { SearchScreen() } composableProductDetailRoute { backStackEntry - val route backStackEntry.toRouteProductDetailRoute() ProductDetailScreen(route.productId) } }然后在 Scaffold 底部栏的显隐逻辑上动点手脚val showBottomBar currentDestination?.hierarchy?.any { it.route in setOfAny(HomeRoute, CategoryRoute, MineRoute) } ?: false if (showBottomBar) { NavigationBar { ... } } else { // 子页面不显示底部栏可以做一个全屏内容区域而不是仅仅隐藏 }那有的同学会问能不能用navigation()函数定义子图再用navigate(...)跳进子图内部的页面也可以但你要小心一个坑如果你把一级 Tab 定义成navigationHomeTab(startDestination HomeRoute)而 Tab 切换的导航目标是这个HomeTab子图那么HomeTab本身不是一个可渲染的 Composable你必须在子图内部定义 startDestination 对应的页面。用过 Fragment Navigation 的都知道嵌套图是一个很精巧但也很容易混乱的东西。在 Compose Navigation 里我建议还是优先使用扁平化的路由注册把“Tab 页面”和“二级页面”都注册在同一个 NavHost 里通过hierarchy判定底部栏显隐这样代码最简单也最容易排查。4.3 返回栈状态恢复的完整方案Compose Navigation 在进程被系统杀掉后可以根据 NavController 的状态恢复整个返回栈。前提是你得把 NavController 的状态存到可保存的容器里而不是用普通的rememberNavController()。我项目里是这么做的Composable fun rememberAppNavController(): NavHostController { val context LocalContext.current return rememberSaveable( saver NavHostController.Saver ) { NavHostController(context) } }注意这里用了rememberSaveable和NavHostController.Saver。这样在配置变更比如屏幕旋转或者系统回收内存时NavController 的返回栈和状态能够被保存下来。如果你用的是rememberNavController()它内部其实也是做了rememberSaveable但我见过的很多项目里有人手写remember { NavHostController(context) }或remember { navController }键盘一旋转返回栈就丢了用户明明已经跳到了第三级页面旋转后却回到了首页。这就是典型的没处理好rememberSaveable。提示NavHostController.Saver是在 Navigation 2.7.0 之后才加入的如果你用的版本太老想保存 NavController 的状态会很麻烦。所以尽量把 navigation-compose 的版本升到 2.8.x省掉很多不必要的坑。5. 导航动画、Deep Link 与依赖注入5.1 页面切换动画如何优雅实现Compose Navigation 从 2.6.0 开始终于可以在composable()里直接设置进入/退出动画了。用法很直接composableDetailRoute( enterTransition { fadeIn(animationSpec tween(300)) slideInHorizontally(initialOffsetX { it }) }, exitTransition { fadeOut(animationSpec tween(300)) slideOutHorizontally(targetOffsetX { -it }) }, popEnterTransition { fadeIn(animationSpec tween(300)) slideInHorizontally(initialOffsetX { -it }) }, popExitTransition { fadeOut(animationSpec tween(300)) slideOutHorizontally(targetOffsetX { it }) } ) { DetailScreen() }简单解释一下这几个参数enterTransition新页面进入时执行的动画。exitTransition旧页面被覆盖时的退场动画注意不只是返回时会触发。popEnterTransition用户按返回键时被覆盖的下面那个页面重新出现的动画。popExitTransition用户按返回键时当前页面退出时的动画。这里的slideInHorizontally自带initialOffsetX如果写成我上面那套进入的和返回的偏移方向是相反的看起来比较像 iOS 那种侧滑返回的质感。不过要注意tween的animationSpec类型必须匹配我这里用的都是tween()没有问题。动画虽然不难但有一个性能注意点导航动画触发瞬间会同时会有两个页面参与 composition如果两边的 Composable 里有大量网络图片或者重度计算可能掉帧。建议是在动画期间避免在目标页面启动高开销的加载任务或者用AnimatedVisibility配合局部动画来替代全局页面动画。5.2 Deep Link 老项目迁移时怎么接Deep Link 是 Navigation 一个很传统的功能就叫“深链接”实现方式是给某个composable加一个deepLinks列表composableDetailRoute( deepLinks listOf( navDeepLinkDetailRoute( basePath https://example.com/detail/{itemId} ), // 也支持自定义 scheme navDeepLinkDetailRoute( basePath myapp://detail/{itemId} ) ) ) { backStackEntry - val route backStackEntry.toRouteDetailRoute() DetailScreen(itemId route.itemId) }itemId会自动从 URL 中解析出来然后注入到DetailRoute的对应字段上。这里要注意类型安全路由的 Deep Link 要求数据类的字段名和 URL 模板中的占位符完全一致否则解析会失败。深层链接在 Web 页面拉起 App 的场景里非常实用比如你收到一条 H5 促销短信点击后浏览器打开然后又拉起 App 直接进到商品详情页。没有 Deep Link 的话这种链路基本只能做到“拉起 App”停在首页用户还要自己搜一遍转化率差不少。5.3 和 Hilt 等依赖注入框架配合的注意事项项目里如果用了 Hilt页面里的 ViewModel 基本都是靠hiltViewModel()获取的。在 Compose Navigation 环境下这个获取依然可用而且有一个很大的好处ViewModel 的 Scope 默认绑定在 NavBackStackEntry 上也就是说页面 onBackStackEntry 销毁时ViewModel 也会被自动清理不需要你手动释放。实战中的坑在动态路由参数上。如果你的 ViewModel 需要根据路由参数做初始化比如详情页的 ViewModel 要知道productId才能去加载数据这时不要直接在 ViewModel 里写死参数而是通过 SavedStateHandle 获取HiltViewModel class ProductDetailViewModel Inject constructor( savedStateHandle: SavedStateHandle ) : ViewModel() { private val productId: String checkNotNull(savedStateHandle[productId]) // ... }SavedStateHandle 里的 key 名需要和路由参数名保持一致。我见过有人用requireNotNull(savedStateHandle.toRouteProductDetailRoute())这种方式去拿 route 对象也可以但注意toRoute是 NavBackStackEntry 的扩展要在 composable 层调用ViewModel 里只能用 SavedStateHandle。此外还有个常见问题同一个 NavBackStackEntry 下如果页面内部又嵌套了一个NavHost比如首页二级页里又有一个子 Tab那 hiltViewModel 的 Scope 要找对对应层级不然会出现两个页面注册了同一个 key 的 ViewModel导致拿到旧的实例。我建议整个页面树就维护一个顶层 NavController子页面内不要轻易再创建第二个 NavHost除非你能保证它们的返回栈不会产生歧义。6. 导航中的状态管理与副作用处理6.1 remember、rememberSaveable 与 NavBackStackEntry 的关系Compose 初学者最困惑的就是remember、rememberSaveable和导航页面状态之间到底是什么关系。remember保存的值只在当前 Composable 离开组合时自动清理。页面被导航栈压住不可见但仍在返回栈里时它的组合并不会被销毁所以remember的值还在。rememberSaveable在配置变更跟系统回收时也能恢复。它底层用的是 SaveableStateRegistry和导航的 SavedStateHandle 是连通的。实际开发中当你在 Compose Navigation 里发生页面跳转时被覆盖的页面并没有离开组合它只是从界面树中被移除但整个返回栈里的状态都还在。所以如果你在 A 页面用remember保存了某个输入框内容跳到 B 页面再返回输入框内容还在这没问题。但如果系统内存吃紧A 页面所在的 Activity 被杀了回来时remember保存的内容就没了rememberSaveable保存的内容还活着。如果你在做聊天页面这种需要保持大量对话状态的地方建议用 ViewModel 来持有状态不要只靠 remember 系列。因为在极端情况下哪怕返回栈还在Activity 重建过程中的组合状态恢复也可能出问题。6.2 导航动作触发的 LaunchedEffect 陷阱LaunchedEffect是 Compose 里最常用的副作用 API之一在导航场景里也经常出现。典型的场景是composableHomeRoute { backStackEntry - val viewModel: HomeViewModel hiltViewModel() LaunchedEffect(Unit) { viewModel.loadData() } }这段代码的本意是页面首次进入时加载数据。但有个问题如果HomeRoute被从返回栈中弹掉后又重新进入LaunchedEffect(Unit)会再次执行loadData会被重复调用。很多时候这可能正是你想要的重新拉取数据但如果你想要的是只加载一次或者需要避免重复触发就要注意这个行为。还有一个很隐蔽的问题如果在LaunchedEffect里直接用了navController.navigate()但这个导航动作的触发放到了重组之外可能造成多次导航。我建议所有导航动作都放到用户事件回调或者LaunchedEffect里并且加上防抖逻辑var navigationHandled by remember { mutableStateOf(false) } LaunchedEffect(Unit) { if (!navigationHandled) { navigationHandled true navController.navigate(TargetRoute) } }6.3 页面可见性监听onResume / onDispose 在 Compose 里的对应实现传统 Fragment 里有onResume、onPause生命周期回调可以用来做页面曝光埋点或者恢复轮播图动画。Compose 里直接对应用户可见性的工具是LifecycleResumeEffect和LifecycleStartEffect这两个 API 在androidx.lifecycle:lifecycle-runtime-compose里。导航页面组合时的用法Composable fun HomeScreen() { LifecycleResumeEffect(Unit) { // 相当于 onResume页面可见时触发包括从其他页面 popBack 回来 onPauseOrDispose { // 相当于 onPause页面不可见时触发 } } }注意LifecycleResumeEffect触发时机是当 NavBackStackEntry 对应的 Lifecycle 状态到达 RESUMED 时。也就是说A 页面被压栈变成不可见时会执行onPauseOrDispose里的代码当用户从 B 返回 AA 再次变为 RESUMED 时LifecycleResumeEffect主块会再次执行。这个 API 对于页面曝光埋点非常有用。我在做直播间相关页面时就靠它来切换摄像头预览和暂停播放器效果比之前听 Fragment 生命周期回调再手动判断要干净得多。7. 常见问题与排查技巧实录7.1 问题速查表我把日常开发里遇到过的问题整理成了一张表方便你对照。问题现象可能原因解决方案跳转时报错Navigation destination that matches request ... cannot be foundstartDestination 与 NavHost 里注册的某个路由不匹配或者路由对象没注册检查 startDestination 类型是否在 NavHost 中有对应的 composable连续点击按钮会连续跳两个页面缺少launchSingleTop true或者事件回调没有加防抖在 navigate 时设置launchSingleTop trueTab 切换后页面状态全丢popUpTo 配置不对或缺失saveState true使用popUpTo(navController.graph.findStartDestination().id) { saveState true }旋转屏幕后返回栈丢失NavController 没有被 rememberSaveable 保存使用rememberSaveable(saver NavHostController.Saver)Deep Link 接收到的参数为 nullURL 模板占位符与数据类字段名不一致核对 URL 中参数名与 data class 字段名必须一致从页面 A 返回 AA 的 LaunchedEffect 重复执行页面重新进入 LaunchedEffect 重新启动用 ViewModel 缓存加载标记或者调整依赖 key底部导航栏在二级页面仍然显示currentDestination.hierarchy 用错判定逻辑不准确改用 parent 节点或hierarchy.any { it.route in tabRoutes }7.2 从一次线上崩溃说起类型安全路由的版本兼容说个真实事件。有次我们的项目从 Navigation 2.7 升到 2.8其中一个模块的页面突然出现kotlinx.serialization.SerializationException: Serializer for class XxxRoute is not found。排查了半天发现原因是Serializable注解没有加到 route 对象上而 Navigation 2.8 的composableT必须要求 T 是可序列化的。这种问题在升级之前可能不会暴露2.7 及之前可以用字符串路由升级后一改写法就崩。所以升级 Navigation 版本不是一个无脑操作你需要全局搜一遍所有navigate(xxx)和composable(xxx)的调用确认都已经迁移到类型安全写法。另外Serializabledata class 必须要放在独立的文件里不要嵌套在别的类内部否则序列化框架可能无法生成对应的 serializer。7.3 动画导致的崩溃transformOrigin 与 Size 不匹配另一个常见但很少被提及的崩溃是页面使用slideIntoContainer或其他尺寸依赖动画时遇到IllegalStateException: Layout coordinates are not available。这个崩溃通常发生在导航动画过程中目标页面还没完成测量就开始执行动画了。解决办法有两个一是把操作延迟一帧用LaunchedEffect加withFrameNanos等手段等布局稳定后再触发动画二是直接把动画简化尽量用fadeIn/fadeOut这类不依赖坐标系变换的动画或者在动画闭包里对初始值做空安全比如slideInHorizontally(initialOffsetX { it / 2 })这里it是当前 width如果 width 为 0动画反而不会崩溃但如果你用了{ it * 2 }这种乘法很容易产生奇怪的结果。总之动画闭包里的操作要简单、幂等别写太复杂的逻辑。7.4 多模块项目的路由注册顺序如果你的 App 是多模块架构每个模块各有一个NavGraphBuilder扩展函数要考虑注册顺序。Navigation 对相同路由的重复注册会报编译错误吗不会但运行时会有一个很头疼的表现先注册的会生效后注册的被忽略而且不报错日志也几乎没有提示。排查这个问题时我在实际项目中就踩过坑。当时两个模块不小心都注册了同一个UserDetailRoute跳转时有的页面走了模块 A 的注册有的却走到了模块 B 的注册表现不一致找原因花了我半天时间。所以推荐做法是在根 NavHost 里给每个子图做个注释或者 logcat 输出确认注册顺序符合预期再做统一路由表val registerOrder listOf(Auth, Home, Order) Log.d(NavGraph, Register order: $registerOrder)更好的方案是定义全局唯一的路由契约把这些Serializable对象统一放在 common 模块避免撞名。8. 性能调优与后续扩展思路8.1 导航图加载性能优化NavHost 的初始化和 NavGraph 的构建成本在页面数量很多时会体现出来。如果你一个 NavHost 里注册了上百个 composable 目的地第一帧的启动时间会明显变长。优化手段主要是懒加载子图可以按需加载不在一开始全部注册。比如登录模块只在启动路由是登录页时才注册。避免在 composable 注册时做高开销的初始化例如不要在composableUserRoute { }的 lambda 里直接创建复杂对象可以用val viewModel: UserViewModel hiltViewModel()配合懒加载。如果某些页面使用频率很低也可以考虑不放进主 NavGraph而是放到逻辑上的“子页”中靠显式跳转再注册。不过说实话上百个页面在正常项目里已经算很庞大了我实际见过的大项目注册了 40 多个页面启动 NavHost 的开销并不大真正占用时间的是首屏页面的内容组合。所以与其优化 NavHost不如优化首页布局的复杂度。8.2 动态路由与前缀匹配在某些后台配置驱动页面跳转的场景下你可能需要根据服务端返回的路由字符串做跳转。类型安全路由虽然好用但它是编译期的没法直接解析字符串。我的折中方案是维护一张字符串到路由对象的映射表val routeMap: MapString, Serializable () - Any mapOf( home to { HomeRoute }, product_detail to { ProductDetailRoute(template_id) } ) fun handlePushRoute(rawRoute: String) { val route routeMap[rawRoute] if (route ! null) { navController.navigate(route()) } else { // 找不到对应路由走默认落地页 navController.navigate(HomeRoute) } }这种方式既保留了类型安全路由的强类型优点又能兼容运营配置的动态跳转逻辑。不过要注意映射表里的参数都是模板参数实际参数还得靠后续业务层自己处理不是万能药。8.3 从 Compose Navigation 迁移到导航库的未来Compose Navigation 是 Jetpack 生态中比较稳定的组件之一除非未来 Compose 的多平台化如 Compose Multiplatform覆盖到 Navigation否则短期不会有大变动。但如果你以后做 KMP 跨平台可能会考虑 Voyager、Decompose 这类第三方库它们对多平台支持更完整。如果只是做 Android我的建议是坚定使用官方navigation-compose别上来就用第三方。第三方库虽然在某些场景很好用但社区活跃度和 API 稳定性都比不上官方。等你的项目有具体痛点比如需要跨平台状态持久化或平台无关导航抽象再去评估第三方库也不迟。最后分享一点个人经验我在实际项目中已经用 Compose Navigation 完整迭代过两个中大型应用最深刻的体会是导航设计是整个 App 架构的骨架骨架歪了后面填肉的时候每层都会别扭。所以不管项目多急一开始就要把路由契约设计好。尤其是团队合作时建议把Serializable的 route 对象放在专门的包或者模块里命名统一规范跳转时强制走类型安全写法减少沟通成本。另外一个很多人忽视的小技巧NavHost的modifier别乱加padding。如果你在 NavHost 外面包裹 Scaffold正确的做法是给 NavHost 传Modifier.padding(innerPadding)这样键盘弹出、底部栏遮挡这些情况就能交给 Scaffold 统一处理否则容易出现页面内容被底部栏盖住的问题。最后再分享一个排查导航问题的通用思路先确认 NavController 的当前返回栈内容再确认 NavHost 注册的路由图最后再怀疑代码逻辑。你可以用navController.currentBackStackEntryAsState()把返回栈打出来看或者临时写个 Text 显示当前路由比盲改代码高效得多。Compose Navigation 本身不复杂最复杂的其实是基于它搭建起来的页面组织方式和状态管理方案。把思路理顺、把版本统一、把路由契约定好它就能成为你项目里最稳定的那根支柱。
返回列表