ARTICLE DETAIL

资讯详情

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

【Compose Multiplatform 跨端开发学与练】第4课 导航与路由

【Compose Multiplatform 跨端开发学与练】第4课 导航与路由 本课目标理解导航图的三个核心概念路由、目的地、NavHost掌握类型安全路由的定义与参数传递方式学会用 NavController 管理返回栈理解 Web 端浏览器导航的集成方式为构建多页面应用打下基础。系列整体规划课次主题核心内容难度第1课从零开始技术概览、环境搭建、第一个应用、代码解读⭐第2课Compose 基础语法Composable、状态管理、重组机制、Modifier 体系⭐⭐第3课布局与组件Column/Row/Box、LazyColumn、Material3 组件库⭐⭐第4课导航与路由Navigation Compose、类型安全路由、深层链接⭐⭐⭐第5课网络与数据层Ktor 客户端、序列化、Repository 模式⭐⭐⭐第6课状态管理与架构ViewModel、单向数据流、依赖注入⭐⭐⭐⭐第7课平台适配与互操作expect/actual、SwiftUI 互操作、平台特定 API⭐⭐⭐⭐第8课资源管理与主题多平台资源、图片加载、深浅色主题⭐⭐⭐第9课测试与调试Compose UI 测试、单元测试、性能分析⭐⭐⭐⭐第10课发布与部署Android/iOS/桌面/Web 打包发布、CI/CD⭐⭐⭐⭐⭐第4课 导航与路由一、为什么需要导航库1.1 从“单页应用”到“多页应用”前几课的应用都只有一个屏幕。但在真实应用中用户需要在不同页面之间移动从列表点击进入详情从详情返回列表从设置跳转到编辑页。最原始的做法是用一个var currentScreen by remember { mutableStateOf(home) }来手动控制显示哪个页面。但这种方式有几个无法回避的问题返回栈管理。用户从 A 到 B 到 C按返回键应该回到 B 再到 A。手动实现这个“栈”需要维护一个列表每次导航 push 新页面返回时 pop 最后一个。状态传递与恢复。A 页面传递一个参数给 B 页面B 页面在重组时需要拿到这个参数。手动实现需要把参数存到某个全局状态中并处理参数丢失的情况。深度链接。用户从外部链接直接打开应用的某个页面。手动实现需要解析 URL 并映射到对应的页面。生命周期感知。当一个页面被覆盖时它应该进入“暂停”状态当它重新回到前台时应该恢复。手动实现需要自己管理这些状态转换。浏览器集成。在 Web 端地址栏应该反映当前页面浏览器的返回/前进按钮应该与应用的导航同步。手动实现几乎不可行。Navigation 库正是为了解决这些问题而存在的。它把“页面之间怎么走”这件事从业务代码中抽离出来用一个声明式的导航图来描述让开发者专注于每个页面本身的内容。1.2 三个核心概念理解导航库只需要掌握三个概念导航图NavGraph描述了应用中所有可能的目的地以及它们之间的连接关系。你可以把它想象成一张地图上面标注了所有可以去的地方和可以去的方式。目的地Destination是导航图中的一个节点代表用户可以到达的一个位置。在 Compose 中目的地通常对应一个Composable函数。当用户导航到这个目的地时应用会显示它的内容。路由Route是目的地的标识符。在类型安全导航中路由是一个Serializable的类或对象它同时也定义了导航所需的参数。这三个概念的关系可以这样理解导航图是一本书的目录目的地是每一章的标题路由是每一章的页码和索引。目录告诉你有哪些章节、按什么顺序排列标题标识每一章的内容页码和索引则精确定位到具体位置。1.3 返回栈Back Stack返回栈是导航库的核心机制。每当用户导航到一个新目的地该目的地被压入返回栈的顶部。当用户按返回键或执行返回操作时栈顶的目的地被弹出用户回到前一个目的地。理解返回栈的关键是它是一个栈结构遵循后进先出LIFO原则。从 A 到 B 到 C栈的内容是[A, B, C]按返回C 被弹出栈变为[A, B]用户回到 B。导航库还提供了popUpTo、launchSingleTop等参数来精细控制返回栈的行为。例如从登录页导航到主页时你可能希望把登录页从栈中移除这样用户按返回键不会回到登录页。这就是popUpTo的用途。一个关键的认知转变返回栈不是“页面历史”而是“导航意图的记录”。它的目的是让用户能够沿着原路返回而不是简单地记录访问过的所有页面。理解这一点才能正确使用popUpTo和launchSingleTop。二、类型安全路由2.1 从字符串路由到类型安全路由Navigation 2.8.0 之前路由是用字符串定义的composable(profile/{userId}){backStackEntry-valuserIdbackStackEntry.arguments?.getString(userId)ProfileScreen(userId)}这种方式的问题很明显字符串拼写错误在编译期无法发现参数类型需要手动解析路由和参数之间的对应关系没有编译器保障。当项目规模变大时路由定义散落在各处重构几乎不可能。Navigation 2.8.0 引入了类型安全路由用 Kotlin 序列化来定义路由。路由不再是字符串而是一个Serializable的类或对象。编译器可以在编译期检查路由类型的存在性、参数的匹配性IDE 也能提供自动补全和跳转。2.2 定义路由根据是否有参数路由有两种定义方式无参数路由用objectSerializableobjectHome有参数路由用data classSerializabledataclassProfile(valid:String)参数类型由路由类的属性定义编译器会检查类型安全。你不需要NavArgument也不需要手动解析字符串。一个实用的规则参数类型必须是可序列化的。基本类型String、Int、Long 等天然支持。自定义类型需要标记Serializable。枚举类型在较新版本中也支持。路由参数的边界路由参数适合传递标识符ID、路径、key不适合传递复杂对象。如果需要传递复杂对象正确做法是传递对象的 ID在目标页面根据 ID 重新加载数据。这不仅是技术限制也是架构原则——路由参数应该足够小能够被序列化到 URL 中Web 端会把它编码到地址栏。2.3 构建导航图有了路由定义就可以构建导航图了。NavHost是承载导航图的 ComposablevalnavControllerrememberNavController()NavHost(navControllernavController,startDestinationHome,){composableHome{HomeScreen(onNavigateToProfile{id-navController.navigate(Profile(id))})}composableProfile{backStackEntry-valprofile:ProfilebackStackEntry.toRoute()ProfileScreen(profile.id)}}关键细节NavHost需要两个参数navController和startDestination。startDestination是应用启动时显示的页面。composableT使用泛型参数指定路由类型。composableProfile比composable(profile)更安全——编译器会检查Profile是否是一个有效的可序列化类型。在composableProfile的 lambda 中backStackEntry.toRouteProfile()从返回栈条目中重建路由对象。这个对象包含了导航时传递的参数。2.4 导航到目的地导航通过navController.navigate()完成navController.navigate(Profile(id123))传入的是路由对象的实例而不是字符串或 URL。导航库会根据路由类型找到对应的目的地并把参数编码到返回栈中。一个容易忽略的细节navigate()默认是异步的。它会把导航请求加入队列在当前帧结束后处理。这意味着连续调用两次navigate()时第二次调用可能不会立即生效。如果需要同步导航使用navigate()的navOptions参数或者在回调中处理。2.5 从 ViewModel 中访问参数如果使用 ViewModel 管理页面状态可以通过SavedStateHandle获取路由参数classProfileViewModel(savedStateHandle:SavedStateHandle,):ViewModel(){privatevalprofilesavedStateHandle.toRouteProfile()privatevaluserId:Stringprofile.id}这样 ViewModel 就不需要依赖 Composable 的backStackEntry可以独立测试。这也是第6课引入 ViewModel 后路由参数传递的标准方式。三、返回栈操作3.1 popBackStack 与 popUpTonavController.popBackStack()弹出栈顶条目回到前一个目的地。这是返回操作的底层实现。navigate()的popUpTo参数更灵活。它允许你在导航到新目的地的同时从返回栈中移除一些条目navController.navigate(Home){popUpTo(Login){inclusivetrue}}这个导航会压入Home同时移除Login及其之上的所有条目。用户按返回键不会回到登录页。inclusive true表示连同Login本身一起移除。为什么需要 popUpTo考虑登录场景。用户从登录页导航到主页如果不清理栈返回栈是[Login, Home]。用户按返回键会回到登录页但此时用户已经登录回到登录页是逻辑错误。popUpTo(Login) { inclusive true }确保登录页从栈中移除返回栈变为[Home]。3.2 launchSingleToplaunchSingleTop true确保如果目标目的已经在栈顶不会重复压入navController.navigate(Home){launchSingleToptrue}这个参数在底部导航栏场景中特别有用用户在多个 tab 之间切换时不应该为每次点击都创建一个新的返回栈条目。如果用户连续点击“首页” tab 三次返回栈中只有一个 Home而不是三个。3.3 saveState 与 restoreState底部导航场景中用户切换 tab 时希望保留每个 tab 的状态滚动位置、输入内容。saveState和restoreState配合实现这个需求navController.navigate(route){popUpToHome{saveStatetrue}launchSingleToptruerestoreStatetrue}saveState true保存当前 tab 的状态restoreState true恢复目标 tab 上次保存的状态。这对用户体验至关重要——用户从“消息” tab 切到“我的”再切回来“消息”的滚动位置应该保持不变。四、导航动画与转场NavHost 支持为页面切换添加动画。enterTransition和exitTransition控制进入和退出动画NavHost(navControllernavController,startDestinationHome,enterTransition{slideInHorizontally()},exitTransition{slideOutHorizontally()},){// ...}每个composable也可以单独设置动画覆盖 NavHost 的默认值。跨平台注意事项在 iOS 上默认的返回手势会触发原生风格的滑动动画。如果你自定义了enterTransition或exitTransition这个默认动画会被禁用。如果你希望保留 iOS 的原生返回手势体验不要覆盖默认动画。这是 CMP 跨平台开发中“平台一致性 vs 平台原生体验”的典型取舍。一个实用的折中方案只在 Android 和桌面端自定义动画iOS 上保留默认。可以通过expect/actual机制第7课内容实现平台差异化。五、Web 端浏览器导航5.1 bindToBrowserNavigationCompose Multiplatform 的 Web 端完全支持导航库的 API并且可以让浏览器地址栏和返回/前进按钮与导航图同步。核心方法是在main函数中调用bindToBrowserNavigation()ComposablefunApp(onNavHostReady:suspend(NavController)-Unit{}){valnavControllerrememberNavController()LaunchedEffect(navController){onNavHostReady(navController)}// NavHost ...}// wasmJsMainOptIn(ExperimentalBrowserHistoryApi::class)funmain(){valbodydocument.body?:returnComposeViewport(body){App(onNavHostReady{it.bindToBrowserNavigation()})}}调用后浏览器 URL 会反映当前路由在#后的片段中地址栏手动输入的 URL 也会被解析为对应的目的地。为什么要在 onNavHostReady 中绑定bindToBrowserNavigation()需要 NavController 初始化完成、NavHost 准备好之后才能绑定。如果提前调用NavController 还没有注册任何目的地浏览器导航无法正确映射。5.2 URL 的可读性默认情况下类型安全路由会被编码为应用包名.序列化类名/参数1/参数2的形式。例如example.org#org.example.app.StartScreen/123。如果希望 URL 更简洁可以用SerialName注解指定序列化名称SerializableSerialName(start)dataobjectStartScreen这样路由会变成#start。URL 的可读性不仅影响用户体验也影响 SEO如果应用需要被搜索引擎索引。建议为所有公开可访问的路由设置简洁的SerialName。5.3 深层链接Web 端的bindToBrowserNavigation()本质上实现了深层链接——用户从外部 URL 直接打开应用的某个页面。在 Android 和 iOS 上深层链接需要额外的平台配置Android 的intent-filter、iOS 的associated domains这部分内容会在第7课平台适配中详细讲解。六、习题与参考答案本课习题分为三类概念理解1-4 题、代码实践5-10 题、综合设计11-13 题。概念理解习题 1导航的三个核心概念题目用自己的话解释导航图、目的地、路由三者的关系。参考答案导航图是地图描述所有可去的地方和连接关系。目的地是地图上的一个节点代表一个可以到达的位置。路由是目的地的“地址”标识去哪个目的地以及需要带什么参数。三者关系导航图包含多个目的地每个目的地由一个路由标识。延伸思考为什么 Compose Navigation 不直接把 Composable 函数作为目的地而要引入路由这一层抽象因为路由是可序列化的、可编码的、可比较的而 Composable 函数是编译期概念无法在运行时被序列化或传递。习题 2类型安全路由的优势题目字符串路由composable(profile/{id})和类型安全路由composableProfile相比有哪些优势参考答案编译器检查路由类型是否存在、参数类型是否匹配不需要手动解析字符串参数路由和参数的对应关系由类定义重构时不会遗漏IDE 可以提供自动补全和跳转。延伸思考类型安全路由的另一个优势是参数默认值。data class Profile(val id: String, val tab: String info)可以定义默认参数导航时只需传递必要参数。字符串路由无法做到这一点。习题 3返回栈的行为题目用户依次导航到 A、B、C然后按了两次返回键。返回栈中现在有哪些目的地如果 C 导航时设置了popUpTo(A) { inclusive false }结果会怎样参考答案第一次情况栈从[A, B, C]变为[A, B]再到[A]。设置了popUpTo后导航到 C 时 A 及其之上的所有条目被移除inclusive false表示不移除 A 本身但移除 A 之上的栈变为[A, C]按一次返回回到 A。延伸思考popUpTo的inclusive参数是易错点。inclusive true表示目标本身也移除inclusive false表示只移除目标之上的条目。登录场景通常用inclusive true连登录页一起移除tab 切换场景通常用inclusive false保留根页面。习题 4Web 端浏览器导航题目bindToBrowserNavigation()做了什么为什么需要在onNavHostReady中调用参考答案它把 NavController 的返回栈与浏览器的历史记录同步使地址栏反映当前路由浏览器的返回/前进按钮可以导航。在onNavHostReady中调用是因为需要等 NavController 初始化完成、NavHost 准备好之后才能绑定。延伸思考Web 端导航与原生端导航的本质差异在于——Web 端的返回栈由浏览器管理应用只是“告诉”浏览器当前在哪个路由。这意味着浏览器的前进按钮也能触发导航应用需要处理“向前导航”的场景而不仅仅是“向后返回”。代码实践习题 5定义两个路由题目定义Home和Settings两个路由Settings接收一个String类型的section参数。参考答案SerializableobjectHomeSerializabledataclassSettings(valsection:String)习题 6构建简单导航图题目创建一个 NavHost包含 Home 和 Settings 两个目的地。Home 有一个按钮点击后导航到 Settings 的 “account” 分区。参考答案ComposablefunApp(){valnavControllerrememberNavController()NavHost(navController,startDestinationHome){composableHome{Column{Text(首页)Button(onClick{navController.navigate(Settings(account))}){Text(设置)}}}composableSettings{entry-valsettingsentry.toRouteSettings()Text(设置页面:${settings.section})}}}习题 7从 ViewModel 获取路由参数题目创建一个SettingsViewModel从SavedStateHandle中获取section参数。参考答案classSettingsViewModel(savedStateHandle:SavedStateHandle,):ViewModel(){privatevalsettingssavedStateHandle.toRouteSettings()valsection:Stringsettings.section}延伸思考SavedStateHandle.toRouteT()的底层实现是反序列化——它把返回栈条目中存储的参数重新构造为路由对象。这意味着参数必须是可序列化的且类型必须匹配。习题 8清除返回栈题目实现从“登录页”导航到“主页”时把登录页从返回栈中移除。参考答案navController.navigate(Home){popUpToLogin{inclusivetrue}}popUpToLogin使用类型安全的方式指定要弹出到的目的地inclusive true表示连同 Login 本身也移除。习题 9底部导航栏与 launchSingleTop题目实现一个底部导航栏三个 tab 分别对应 Home、Search、Profile。点击 tab 时导航避免重复压栈。参考答案NavigationBar{NavigationBarItem(selectedcurrentDestination?.hasRouteHome()true,onClick{navController.navigate(Home){popUpToHome{saveStatetrue}launchSingleToptruerestoreStatetrue}},icon{Icon(Icons.Default.Home,null)},label{Text(首页)},)// Search 和 Profile 类似}launchSingleTop true避免重复压入restoreState true恢复上次离开时的状态。关键细节popUpToHome中的 Home 是底部导航的“根”。所有 tab 切换都以 Home 为基准弹出但saveState true保证弹出时保存状态。习题 10Web 端绑定浏览器导航题目在 wasmJsMain 中实现bindToBrowserNavigation()的调用。参考答案// wasmJsMainOptIn(ExperimentalBrowserHistoryApi::class)funmain(){valbodydocument.body?:returnComposeViewport(body){App(onNavHostReady{it.bindToBrowserNavigation()})}}延伸思考如果 Web 应用部署在子路径下如example.com/app/需要在bindToBrowserNavigation时配置基础路径否则路由解析会出错。综合设计习题 11多页面应用骨架题目构建一个包含三个页面的应用列表页List、详情页Detail接收 itemId: Int、设置页Settings。实现从列表到详情、从列表到设置的导航以及从详情返回列表。参考答案SerializableobjectListSerializabledataclassDetail(valitemId:Int)SerializableobjectSettingsComposablefunApp(){valnavControllerrememberNavController()NavHost(navController,startDestinationList){composableList{ListScreen(onItemClick{id-navController.navigate(Detail(id))},onSettingsClick{navController.navigate(Settings)},)}composableDetail{entry-valdetailentry.toRouteDetail()DetailScreen(itemIddetail.itemId,onBack{navController.popBackStack()},)}composableSettings{SettingsScreen(onBack{navController.popBackStack()})}}}习题 12登录流程与返回栈清理题目实现登录 → 主页的流程。登录成功后导航到主页并移除登录页。登录页有一个“返回”按钮但栈中已经没有上一个页面时按钮应该不可用或隐藏。参考答案SerializableobjectLoginSerializableobjectHomeComposablefunApp(){valnavControllerrememberNavController()NavHost(navController,startDestinationLogin){composableLogin{LoginScreen(onLoginSuccess{navController.navigate(Home){popUpToLogin{inclusivetrue}}})}composableHome{HomeScreen()}}}导航后登录页从栈中移除用户按返回键不会回到登录页。如果系统返回键没有可返回的页面应用会退出。延伸思考登录页本身通常不需要“返回”按钮——它是应用的起点栈中不应该有上一个页面。如果登录页是从其他地方如“退出登录”进入的情况会复杂一些需要用popUpTo清理整个栈。习题 13带状态的底部导航题目实现三个 tabHome、Search、Profile的底部导航。切换 tab 时保留每个 tab 的滚动位置和输入状态。参考答案SerializableobjectHomeSerializableobjectSearchSerializableobjectProfileComposablefunApp(){valnavControllerrememberNavController()valbackStackEntrybynavController.currentBackStackEntryAsState()valcurrentDestinationbackStackEntry?.destinationScaffold(bottomBar{NavigationBar{valtabslistOf(Triple(Home,Icons.Default.Home,首页),Triple(Search,Icons.Default.Search,搜索),Triple(Profile,Icons.Default.Person,我的),)tabs.forEach{(route,icon,label)-NavigationBarItem(selectedcurrentDestination?.hasRoute(route::class)true,onClick{navController.navigate(route){popUpToHome{saveStatetrue}launchSingleToptruerestoreStatetrue}},icon{Icon(icon,null)},label{Text(label)},)}}}){padding-NavHost(navController,startDestinationHome,modifierModifier.padding(padding),){composableHome{HomeScreen()}composableSearch{SearchScreen()}composableProfile{ProfileScreen()}}}}saveState true和restoreState true配合使用让每个 tab 在切换时保留自己的状态滚动位置、输入内容等。延伸思考hasRouteT()用于判断当前目的地是否匹配某个路由类型。注意它接受的是KClass所以写hasRoute(Home::class)而不是hasRouteHome()。七、本课小结三个核心概念导航图描述所有目的地目的地是页面节点路由是目的地的标识和参数载体。三者关系是“图包含目的地路由标识目的地”。类型安全路由用Serializable的 object无参数或 data class有参数定义路由。composableRoute注册目的地navigate(Route(...))导航。编译器检查类型安全不需要手动解析参数。返回栈popBackStack()弹出栈顶popUpTo在导航时清理返回栈launchSingleTop避免重复压栈。底部导航场景配合saveState/restoreState保留 tab 状态。跨平台差异iOS 上默认的返回手势动画会被自定义转场覆盖。Web 端通过bindToBrowserNavigation()与浏览器历史同步SerialName可以让 URL 更简洁。八、下一课预告第5课 网络与数据层
返回列表