ARTICLE DETAIL

资讯详情

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

Jetpack Compose Navigation 3 基础用法:类型安全路由与返回栈控制

Jetpack Compose Navigation 3 基础用法:类型安全路由与返回栈控制 如果你是从系列第一篇追过来的应该已经知道 Navigation 3 是 Jetpack 家族里新一代的导航库它把 Navigation 2 的字符串路由和 NavController 那一套整个推倒重来了。如果你直接点开这篇文章也没关系我会从工程接入开始讲基础用法这部分完全可以独立阅读。这篇是系列第二篇主题很明确Compose Navigation 3 的基础用法。我会从依赖配置开始一步步把类型安全路由、导航操作、参数传递、返回栈控制讲清楚过程中会把我在 alpha 版本上踩过的坑一起交代。适合已经开始用 Compose 做项目、对 Navigation 2 有一定了解、想往 Navigation 3 迁移的开发者纯新手也能跟着跑通但最好先掌握 Compose 基本组件和 Kotlin 序列化基础。1. 从 NavController 到 NavBackStack先弄懂架构变化再动手1.1 Navigation 2 时代最磨人的三个问题Navigation 2 本身并不差但用久了总有几个地方让人难受。第一字符串路由。整个导航图靠 home、profile/{userId} 这种字符串拼起来写错一个字母不报编译错误运行到点击跳转那一步才崩溃。一个大型项目里路由常量散落在各个文件App 结构复杂以后找一条路由从哪里定义、在哪里被引用特别费劲。IDE 的全局搜索能帮上忙但这不是解决问题只是缓解症状。第二参数声明和解析分裂。定义目的地的时候要写 route pattern还要写 arguments 列表声明参数类型跳转的时候要小心翼翼地拼字符串用 Uri.encode 处理特殊字符进到目的地之后还得用arguments?.getString(userId)这种写法从 Bundle 里取值。三处代码各写各的改参数名要改三遍漏一个就是运行时崩溃而且只在特定路径上触发测试很难覆盖全。第三对 Fragment 的深度绑定。Navigation 2 的NavHostFragment是基于 Fragment 实现的哪怕你全程不用 Fragment 的 UI 能力底层也要维护一个 Fragment 容器。这既是历史包袱也是多平台路上的障碍。Compose 生态一直在提 Kotlin Multiplatform导航作为应用架构的核心一环如果不能脱离 Fragment那 Compose 的跨平台版图始终缺一块。1.2 NavBackStack 与 NavDisplay 各管哪摊事Navigation 3 把原来的NavController拆成了两个角色NavBackStack和NavDisplay。NavBackStack是导航状态的实际持有者。它内部维护着一个返回栈记录了当前有哪些目的地、每个目的地处于什么状态。你可以把它理解成舞台监督手里的出场记录当前在台上的演员是谁前一场是谁下一场该谁上。所有关于导航状态的操作——navigate、popBackStack、观察当前目的地变化——都发生在它身上。NavDisplay是负责渲染的 Compose 组件。它监听NavBackStack的变化拿到当前应该展示的NavEntry然后调用你在导航图里注册的 Composable 内容去渲染界面。它俩的分工很清楚一个管状态一个管 UI。这样的拆分带来一个很直接的好处导航逻辑和 UI 渲染解耦。测试NavBackStack不需要搭 Compose 环境可以直接在单元测试里验证navigate之后的返回栈结构是否符合预期换 UI 层时只需要换掉NavDisplay的声明导航逻辑原封不动。1.3 重新理解导航这件事Navigation 3 还有一个有意思的变化——目的地不再是一个字符串常量而是一个 Kotlin 类型。如果你用 Navigation 2 写过项目你心里一定有一张路由表哪个字符串对应哪个页面参数叫什么名字。到了 Navigation 3这张表消失了取而代之的是一组Serializable的普通 Kotlin 类。Home就是一个objectProfile(userId: Long)就是一个data class。这个变化看起来只是换了个写法但它把导航从两个字符串之间的匹配变成了两个类型之间的匹配。编译器能帮你检查绝大部分错误跳转时参数类型不对编译直接失败改目的地类名所有引用处同步 IDE 重构。这是体验上的质变。理解了这几个概念后面的基础用法就顺理成章了。先把工程配好跑一个最小导航。2. 工程接入依赖配置和第一个最小导航2.1 版本选择与依赖项避坑Navigation 3 目前还在 alpha 阶段我写这篇文章时用的版本是3.0.0-alpha01这一系。在libs.versions.toml里这样配置[versions] navigation3 3.0.0-alpha01 [libraries] androidx-navigation-compose { module androidx.navigation:navigation-compose, version.ref navigation3 }然后在模块的build.gradle.kts中引入dependencies { implementation(libs.androidx.navigation.compose) }这里有两个坑要提前说。坑一命名空间冲突。androidx.navigation:navigation-compose这个 artifact 在 Navigation 2 时代就在用你项目里如果之前已经引了 Navigation 2 的版本升级到 3 之后 Gradle 会直接换上 3.x 的新版本但代码里androidx.navigation.compose.NavHost等一堆类名和 Navigation 2 完全同名行为却变了。混合使用新旧 API 时特别容易懵。我的建议是如果项目已经深度使用 Navigation 2迁移时留一个独立分支先在分支里把 Navigation 2 的代码全部移除再引入 3不要试图长期共存。坑二alpha 版本的 API 迭代很快。不同 alpha 之间函数签名可能直接改名比如某个早期版本叫composable后续可能调整成别的名字。看网上的示例代码时先确认对方用的版本号和你一致不然抄过来编译不过还要花时间排查是不是自己写错了。2.2 开启 kotlinx.serialization 插件类型安全路由依赖Serializable注解而它来自kotlinx.serialization所以需要两步配置。第一步在项目根目录build.gradle.kts里声明插件plugins { // ... id(org.jetbrains.kotlin.plugin.serialization) version 2.0.20 apply false }第二步在模块的build.gradle.kts里应用plugins { // ... id(org.jetbrains.kotlin.plugin.serialization) }如果你用的是 Kotlin 2.0 以上的 Compose 编译器插件还需要确认版本匹配。这一步漏掉的话编译时会报 Serializable class is not found 之类的错误网上搜解决办法时很容易被引导到旧框架问题上去浪费时间。2.3 跑起一个最小可运行的导航依赖配好、插件开启下面是最小可运行代码Serializable object Home Composable fun App() { val backStack rememberNavBackStack(startEntry Home) NavDisplay( backStack backStack, modifier Modifier.fillMaxSize() ) { composableHome { HomeScreen(onNavigateToDetail { backStack.navigate(Detail(it)) }) } composableDetail { entry - val detail entry.toRouteDetail() DetailScreen(profileId detail.id, onBack { backStack.popBackStack() }) } } }这段代码只要跑起来就能实现从首页跳到详情页、再从详情页返回首页的基本闭环。对比 Navigation 2你会发现少了NavHost、少了navController多了NavBackStack和NavDisplay。后面的所有用法都是在这个骨架上做扩展。3. 类型安全路由路由表终于不需要人肉维护3.1 字符串路由的问题在哪不是所有字符串方案都该被否定但导航场景下字符串路由的代价确实偏高。项目大了之后路由名称重命名、参数结构变化都需要全局搜索替换。最痛苦的是参数解析你要在跳转时把整数拼成字符串进页面时再从字符串里解析整数类型在这一来一回中变得不可信。IDE 的重构工具能改常量名但改不了跨模块传参时隐藏的类型隐患。3.2 用 Serializable 声明目的地Navigation 3 的解决思路很直接把路由本身定义成可序列化的 Kotlin 类型。无参目的地声明为object带参目的地声明为data class。Serializable object Home Serializable object Settings Serializable data class Profile(val userId: Long) Serializable data class ArticleDetail( val articleId: String, val source: String? null )细心的读者会发现这些类就是普通的数据类加上Serializable注解后它们具备了在导航过程中被序列化和反序列化的能力。object因为没有属性天然是单例表示无参目的地非常合适data class天然携带参数表示带参目的地顺理成章。在NavDisplay里注册目的地时泛型直接指向对应类型NavDisplay(backStack backStack) { composableHome { ... } composableProfile { entry - ... } }一个容易忽略的点同一个类型不能重复注册。我一开始在导航图里写了两个composableProfile编译没问题运行时直接崩溃提示重复目的地。data class之间如果存在相同类型签名也会出问题所以定义路由类型时尽量保持含义单一不要拿一个通用类型到处用。3.3 带参路由的定义与取值跳转时直接构造对象backStack.navigate(Profile(userId 42L))页面里取值用toRouteT()composableProfile { entry - val profile entry.toRouteProfile() ProfileScreen(profile.userId) }toRouteT()会把导航过程中序列化好的参数还原成原始类型调用方不再需要关心参数是怎么编码的。跳转和接收发生在两个地方但数据类型始终一致编译器会在跳转时校验构造参数是否齐全、类型是否正确。这里有个参数默认值的细节。如果data class的属性有默认值比如ArticleDetail.source null跳转时可以不传 source。但要注意默认值是在序列化时处理的——序列化时没有该字段反序列化时用默认值补上。这在大多数场景下符合预期但如果你期望能通过某个字段是否为空来判断跳转来源建议不要过度依赖默认值显式传参更清晰。4. 日常导航操作navigate、popBackStack 与回调组织4.1 navigate 的基础用法与常见场景NavBackStack.navigate(entry)是最常用的操作它把目标目的地压入返回栈。最简单的写法是backStack.navigate(Profile(userId 42L))但实际项目中往往需要携带导航选项这时后面跟一个navOptions块backStack.navigate( Profile(userId 42L), navOptions { launchSingleTop true popUpTo(Home) { inclusive false } restoreState true } )navOptions里每一项的含义我后面会重点展开这里先记住无脑navigate是最简单的但一旦涉及返回栈清理、页面唯一性、状态恢复就必须依赖navOptions来精确控制。另一个常见的场景是启动页/登录页跳主页面。登录成功后你不希望用户按下返回键又回到登录页这个场景就是典型的popUpToinclusivebackStack.navigate( MainScreen, navOptions { popUpTo(LoginScreen) { inclusive true } } )这段代码的意思是把LoginScreen从栈里弹出去再压入MainScreen。弹出后返回栈里不再有登录页用户按返回键不会回到登录界面。4.2 popBackStack返回键、返回逻辑和它的脾气popBackStack()从栈顶弹出一个目的地backStack.popBackStack()在 Compose 里返回键事件并不直接调用backStack.popBackStack()而是通过BackHandler拦截。Navigation 3 与 Navigation 2 在这块的处理思路一致默认情况下返回键会触发返回当你在页面里拦截返回事件时需要自己判断是否需要调用popBackStack()。这里有个避免误用的建议不要在popBackStack()前手动判断栈里还有没有页面。返回栈为空时调用popBackStack()可能会导致崩溃或者无法退出应用建议依赖NavBackStack是否还有可弹出的条目来判断或者直接用系统返回键机制来处理最终退出行为。还有一种条件返回的场景。比如用户填写了一个表单按返回键时你要弹窗确认是否丢弃内容。这个逻辑通常放在 UI 层做拦截BackHandler(enabled formDirty) { showDiscardDialog true }确认后你再调用backStack.popBackStack()。Navigation 3 不会替你决定 UI 交互它只负责在调用返回时正确地把状态还原到上一个目的地。4.3 导航事件的代码组织方式Navigation 3 提供的能力只到状态管理这一层怎么组织导航调用代码是开发者的自由也是项目架构的分水岭。我在项目里推荐一种回调上抛的模式每个页面只抛出导航意图具体navigate调用统一放在导航图声明处。这样做的好处是页面不知道 Navigation 的存在方便解耦和测试。Composable fun HomeScreen( onClickProfile: (Long) - Unit, onClickSettings: () - Unit ) { // UI 只负责把事件抛出去 } // 导航图里集中处理跳转 composableHome { HomeScreen( onClickProfile { userId - backStack.navigate(Profile(userId)) }, onClickSettings { backStack.navigate(Settings) } ) }有读者会问如果页面层级很深每个页面都回调上抛参数传递会不会很啰嗦确实会有一些模板感但换来的是可测试性和可读性。导航操作全部集中在一个文件里后面改导航图、加动画、调整返回栈逻辑时改动范围可控。另一种方式是直接向 Composable 传backStack或者通过CompositionLocal把它暴露给所有子组件。这种方式写起来省事但页面会隐式依赖导航库不利于复用和测试。我的经验是小项目随便用模块较多的项目用回调上抛长期维护更舒服。5. 参数传递干净的数据流比看起来更重要5.1 基础类型参数直接传Int、Long、String、Boolean、Float、Double 这些基础类型直接放进data class路由里就可以传。跳转时构造数据类backStack.navigate(ArticleDetail(articleId abc123))接收时通过toRouteArticleDetail()取回composableArticleDetail { entry - val route entry.toRouteArticleDetail() ArticleScreen(articleId route.articleId) }整个过程没有字符串拼接没有 Uri.encode没有手动 Bundle 写入和读取。类型安全在这里不只是一句口号而是实实在在的编译器保障。你想传错类型编译器都不答应。5.2 复杂对象的取舍与序列化边界Navigation 3 的Serializable基于 kotlinx.serialization不能直接传一个任意对象。比如你有一个User对象里面既有普通字段又有运行时状态直接塞进路由参数里编译期可能通过但序列化时会报错。我的建议是路由参数只放标识符不放业务对象。比如跳转用户页时传userId进入页面后通过 ViewModel 或 Repository 根据 id 加载完整 User 数据。这样有几个好处数据永远是最新的。如果用户信息在跳转过程中被修改了按 id 重新拉取能得到最新数据传对象拿到的反而是旧副本。避免超大参数导致 Binder 事务失败。Android 的 Intent/Bundle 传输有大小限制路由参数最终也要走序列化塞一个大 List 或者 Bitmap 会直接撑爆事务缓冲抛TransactionTooLargeException。序列化结构稳定。网络数据模型往往带各种自定义类型、继承结构做路由参数会陷入序列化和反序列化的类型匹配地狱。如果你确实需要传递一些轻量的结构化数据比如过滤条件可以单独定义一个data class作为路由专用类型里面只放必要的标量字段。5.3 参数解析的细节和默认值处理基础参数解析很简单但有一些边界情况要注意。可空参数。如果路由类型里有一个可空字段Serializable data class SearchResult( val keyword: String, val category: String? null )跳转时可以传category也可以不传接收时category可能是空。这看起来没什么特别但要注意如果你的业务逻辑中未传参数和参数为 null是两种不同含义最好用显式字段表达比如加一个val fromCategory: Boolean。默认值一致性问题。序列化和反序列化使用同一套默认值规则所以跳转方构造SearchResult(keyword compose)时省略 category接收方拿到的 keyword 正常、category 为 null逻辑上没有歧义。但如果你在两个不同的地方维护了两份同样的默认值后续某个字段的默认值一改另一处没跟上就会出现线上数据和预期不一致的诡异问题。所以默认值尽量只定义在路由类型一处不要复制到跳转方逻辑里。6. 返回栈精细调控NavOptions 使用的几个真实场景6.1 避免重复堆栈launchSingleTop如果返回栈里已经有一个相同类型的目的地实例正常情况下再navigate一次就会压入两个。有些场景下这不是问题但像底部导航栏这种频繁切换的入口重复压栈会让返回路径变得不可控。launchSingleTop true的效果是当目标目的地已经位于栈顶时不再压入新的实例而是复用现有的。典型写法backStack.navigate( Home, navOptions { launchSingleTop true } )注意launchSingleTop只对栈顶是同一个类型生效。如果目标类型在栈中间而不是栈顶它不会帮你把前面的页面弹掉。这种场景需要配合popUpTo使用。6.2 清理返回栈popUpTo 与 inclusive 的正确理解popUpTo的语义在 Navigation 3 里和 Navigation 2 保持一致但在类型安全路由下目标参数从字符串变成了类型backStack.navigate( CompletePayment, navOptions { popUpTo(Cart) { inclusive true } } )这段代码的含义是把Cart及其之上的所有页面弹出然后压入CompletePayment。inclusive true表示Cart本身也被弹出inclusive false则保留Cart。实际业务里最常见的场景是下单流程结束后的清栈// 购物车 - 确认订单 - 支付成功 backStack.navigate( PaymentSuccess, navOptions { popUpTo(Cart) { inclusive true } } )这样用户从PaymentSuccess返回时不会看到确认订单和购物车而是直接回到上一级入口比如商品列表。一个我踩过的坑popUpTo的目标类型必须在当前返回栈中存在。如果你在某个分支流程里点到了某个页面A 页被提前弹掉了后面在 B 页里调用popUpTo(A)运行时会因为找不到目标而报错。所以做长链路清理之前先确认目标页面一定还在栈里或者在设计流程时就保证目标页始终存在。6.3 状态保存与恢复saveState 与 restoreState底部导航栏的场景里你总希望切到其他 Tab 再切回来时原来 Tab 里的列表滚动位置和输入状态能保留。Navigation 3 提供saveState和restoreState组合backStack.navigate( ProfileTab, navOptions { popUpTo(HomeTab) { saveState true } launchSingleTop true restoreState true } )它的流程是从 HomeTab 切到 ProfileTab 时把 HomeTab 的状态保存下来再从 ProfileTab 切回 HomeTab 时把保存的状态恢复。这组选项配合使用能实现比较顺滑的 Tab 切换体验。状态保存的粒度是目的地级别。它保存的是目的地 Composable 在返回栈中的状态不是页面里某个输入框的值。如果你的页面里有rememberSaveable保存的状态会跟随目的地状态一起恢复如果你用remember而不是rememberSaveable切走再切回来就会丢失。这和 Navigation 2 的行为一致。6.4 我踩过的 NavOptions 相关坑实践下来有几个坑值得单独记一笔。第一个坑popUpTo 的目标类型不能是栈外的任意类型。编译期不会报错甚至运行到某条路径时也不一定报错但只要那条清理路径真的走到了就可能因为找不到目标而崩溃。做长流程跳转时我会在注释里写清楚当前栈的预期结构方便后面维护的人一眼看出 popUpTo 的目标是否合理。第二个坑launchSingleTop 的栈顶复用不等于页面刷新。复用栈顶实例时页面不会重新组合。如果你希望切回某个 Tab 时它的内容总是刷新比如角标数字、消息列表需要额外用 ViewModel 或事件机制通知页面更新不能依赖导航本身。第三个坑navOptions 的 DSL 写法和 Navigation 2 不完全一致。网上关于 Navigation 2 的popUpTo大括号代码不要直接抄到 Navigation 3类型、默认值都有变化。遇到编译错误时先查官方迁移文档再对当前版本的 API 签名不要在旧教程里反复打转。第四个坑restoreState 和 popUpTo 搭配不当会产生预期外的扩展行为。比如你期望切走时保存状态、切回时恢复但popUpTo的目标和saveState的位置没对应好可能出现页面一级一级往外弹而不是直接回到目标页的情况。多写几个用例把返回路径都点一遍再继续开发。写在最后实际用下来的几点体会Navigation 3 现在还不到生产环境大规模采用的阶段但基础用法已经能带来实打实的体验提升。我实际写代码时最直观的感受是字符串路由那套思维定势要主动改。刚开始我还会不自觉地在代码里找路线常量、担心参数类型对不上但用了一周类型安全路由后再切回 Navigation 2 的项目会觉得浑身别扭总觉得少了一层编译期的保护。如果你想把 Navigation 3 放进现有项目试试水我建议先拿一个新的业务模块做试点路由类型单独建一个包NavDisplay集中管理导航回调按页面拆分跑通后再考虑把老的 Navigation 2 页面逐个迁移过来。alpha 阶段别急着全量重构先把基础用法摸熟、把返回栈状态变化的规律吃透后面的进阶功能——动画、深层链接、嵌套导航——才有扎实的地基。下一篇我会沿着动画和转场效果继续往下挖那是 Navigation 3 和 Compose 结合得最紧密的地方也是实际项目里绕不开的一块。
返回列表