本课目标:理解导航图的三个核心概念(路由、目的地、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 ->
    val userId = backStackEntry.arguments?.getString("userId")
    ProfileScreen(userId)
}

这种方式的问题很明显:字符串拼写错误在编译期无法发现,参数类型需要手动解析,路由和参数之间的对应关系没有编译器保障。当项目规模变大时,路由定义散落在各处,重构几乎不可能。

Navigation 2.8.0 引入了类型安全路由,用 Kotlin 序列化来定义路由。路由不再是字符串,而是一个 @Serializable 的类或对象。编译器可以在编译期检查路由类型的存在性、参数的匹配性,IDE 也能提供自动补全和跳转。

2.2 定义路由

根据是否有参数,路由有两种定义方式:

无参数路由用 object:

@Serializable
object Home

有参数路由用 data class:

@Serializable
data class Profile(val id: String)

参数类型由路由类的属性定义,编译器会检查类型安全。你不需要 NavArgument,也不需要手动解析字符串。

一个实用的规则:参数类型必须是可序列化的。基本类型(String、Int、Long 等)天然支持。自定义类型需要标记 @Serializable。枚举类型在较新版本中也支持。

路由参数的边界:路由参数适合传递标识符(ID、路径、key),不适合传递复杂对象。如果需要传递复杂对象,正确做法是传递对象的 ID,在目标页面根据 ID 重新加载数据。这不仅是技术限制,也是架构原则——路由参数应该足够小,能够被序列化到 URL 中(Web 端会把它编码到地址栏)。

2.3 构建导航图

有了路由定义,就可以构建导航图了。NavHost 是承载导航图的 Composable:

val navController = rememberNavController()

NavHost(
    navController = navController,
    startDestination = Home,
) {
    composable<Home> {
        HomeScreen(onNavigateToProfile = { id ->
            navController.navigate(Profile(id))
        })
    }
    composable<Profile> { backStackEntry ->
        val profile: Profile = backStackEntry.toRoute()
        ProfileScreen(profile.id)
    }
}

关键细节:

NavHost 需要两个参数:navController 和 startDestination。startDestination 是应用启动时显示的页面。

composable<T> 使用泛型参数指定路由类型。composable<Profile> 比 composable("profile") 更安全——编译器会检查 Profile 是否是一个有效的可序列化类型。

在 composable<Profile> 的 lambda 中,backStackEntry.toRoute<Profile>() 从返回栈条目中重建路由对象。这个对象包含了导航时传递的参数。

2.4 导航到目的地

导航通过 navController.navigate() 完成:

navController.navigate(Profile(id = "123"))

传入的是路由对象的实例,而不是字符串或 URL。导航库会根据路由类型找到对应的目的地,并把参数编码到返回栈中。

一个容易忽略的细节:navigate() 默认是异步的。它会把导航请求加入队列,在当前帧结束后处理。这意味着连续调用两次 navigate() 时,第二次调用可能不会立即生效。如果需要同步导航,使用 navigate() 的 navOptions 参数,或者在回调中处理。

2.5 从 ViewModel 中访问参数

如果使用 ViewModel 管理页面状态,可以通过 SavedStateHandle 获取路由参数:

class ProfileViewModel(
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    private val profile = savedStateHandle.toRoute<Profile>()
    private val userId: String = profile.id
}

这样 ViewModel 就不需要依赖 Composable 的 backStackEntry,可以独立测试。这也是第6课引入 ViewModel 后,路由参数传递的标准方式。

三、返回栈操作

3.1 popBackStack 与 popUpTo

navController.popBackStack() 弹出栈顶条目,回到前一个目的地。这是返回操作的底层实现。

navigate() 的 popUpTo 参数更灵活。它允许你在导航到新目的地的同时,从返回栈中移除一些条目:

navController.navigate(Home) {
    popUpTo(Login) { inclusive = true }
}

这个导航会压入 Home,同时移除 Login 及其之上的所有条目。用户按返回键不会回到登录页。inclusive = true 表示连同 Login 本身一起移除。

为什么需要 popUpTo:考虑登录场景。用户从登录页导航到主页,如果不清理栈,返回栈是 [Login, Home]。用户按返回键会回到登录页,但此时用户已经登录,回到登录页是逻辑错误。popUpTo(Login) { inclusive = true } 确保登录页从栈中移除,返回栈变为 [Home]。

3.2 launchSingleTop

launchSingleTop = true 确保如果目标目的已经在栈顶,不会重复压入:

navController.navigate(Home) {
    launchSingleTop = true
}

这个参数在底部导航栏场景中特别有用:用户在多个 tab 之间切换时,不应该为每次点击都创建一个新的返回栈条目。如果用户连续点击“首页” tab 三次,返回栈中只有一个 Home,而不是三个。

3.3 saveState 与 restoreState

底部导航场景中,用户切换 tab 时希望保留每个 tab 的状态(滚动位置、输入内容)。saveState 和 restoreState 配合实现这个需求:

navController.navigate(route) {
    popUpTo<Home> { saveState = true }
    launchSingleTop = true
    restoreState = true
}

saveState = true 保存当前 tab 的状态,restoreState = true 恢复目标 tab 上次保存的状态。这对用户体验至关重要——用户从“消息” tab 切到“我的”再切回来,“消息”的滚动位置应该保持不变。

四、导航动画与转场

NavHost 支持为页面切换添加动画。enterTransition 和 exitTransition 控制进入和退出动画:

NavHost(
    navController = navController,
    startDestination = Home,
    enterTransition = { slideInHorizontally() },
    exitTransition = { slideOutHorizontally() },
) {
    // ...
}

每个 composable 也可以单独设置动画,覆盖 NavHost 的默认值。

跨平台注意事项:在 iOS 上,默认的返回手势会触发原生风格的滑动动画。如果你自定义了 enterTransition 或 exitTransition,这个默认动画会被禁用。如果你希望保留 iOS 的原生返回手势体验,不要覆盖默认动画。这是 CMP 跨平台开发中“平台一致性 vs 平台原生体验”的典型取舍。

一个实用的折中方案:只在 Android 和桌面端自定义动画,iOS 上保留默认。可以通过 expect/actual 机制(第7课内容)实现平台差异化。

五、Web 端浏览器导航

5.1 bindToBrowserNavigation

Compose Multiplatform 的 Web 端完全支持导航库的 API,并且可以让浏览器地址栏和返回/前进按钮与导航图同步。

核心方法是在 main 函数中调用 bindToBrowserNavigation():

@Composable
fun App(
    onNavHostReady: suspend (NavController) -> Unit = {}
) {
    val navController = rememberNavController()
    LaunchedEffect(navController) {
        onNavHostReady(navController)
    }
    // NavHost ...
}

// wasmJsMain
@OptIn(ExperimentalBrowserHistoryApi::class)
fun main() {
    val body = document.body ?: return
    ComposeViewport(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 注解指定序列化名称:

@Serializable
@SerialName("start")
data object StartScreen

这样路由会变成 #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}") 和类型安全路由 composable<Profile> 相比,有哪些优势?

参考答案:编译器检查路由类型是否存在、参数类型是否匹配;不需要手动解析字符串参数;路由和参数的对应关系由类定义,重构时不会遗漏;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(保留根页面)。

习题 4:Web 端浏览器导航

题目:bindToBrowserNavigation() 做了什么?为什么需要在 onNavHostReady 中调用?

参考答案:它把 NavController 的返回栈与浏览器的历史记录同步,使地址栏反映当前路由,浏览器的返回/前进按钮可以导航。在 onNavHostReady 中调用是因为需要等 NavController 初始化完成、NavHost 准备好之后才能绑定。

延伸思考:Web 端导航与原生端导航的本质差异在于——Web 端的返回栈由浏览器管理,应用只是“告诉”浏览器当前在哪个路由。这意味着浏览器的前进按钮也能触发导航,应用需要处理“向前导航”的场景,而不仅仅是“向后返回”。

代码实践

习题 5:定义两个路由

题目:定义 Home 和 Settings 两个路由,Settings 接收一个 String 类型的 section 参数。

参考答案:

@Serializable
object Home

@Serializable
data class Settings(val section: String)
习题 6:构建简单导航图

题目:创建一个 NavHost,包含 Home 和 Settings 两个目的地。Home 有一个按钮,点击后导航到 Settings 的 “account” 分区。

参考答案:

@Composable
fun App() {
    val navController = rememberNavController()
    NavHost(navController, startDestination = Home) {
        composable<Home> {
            Column {
                Text("首页")
                Button(onClick = {
                    navController.navigate(Settings("account"))
                }) {
                    Text("设置")
                }
            }
        }
        composable<Settings> { entry ->
            val settings = entry.toRoute<Settings>()
            Text("设置页面: ${settings.section}")
        }
    }
}
习题 7:从 ViewModel 获取路由参数

题目:创建一个 SettingsViewModel,从 SavedStateHandle 中获取 section 参数。

参考答案:

class SettingsViewModel(
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    private val settings = savedStateHandle.toRoute<Settings>()
    val section: String = settings.section
}

延伸思考:SavedStateHandle.toRoute<T>() 的底层实现是反序列化——它把返回栈条目中存储的参数重新构造为路由对象。这意味着参数必须是可序列化的,且类型必须匹配。

习题 8:清除返回栈

题目:实现从“登录页”导航到“主页”时,把登录页从返回栈中移除。

参考答案:

navController.navigate(Home) {
    popUpTo<Login> { inclusive = true }
}

popUpTo<Login> 使用类型安全的方式指定要弹出到的目的地,inclusive = true 表示连同 Login 本身也移除。

习题 9:底部导航栏与 launchSingleTop

题目:实现一个底部导航栏,三个 tab 分别对应 Home、Search、Profile。点击 tab 时导航,避免重复压栈。

参考答案:

NavigationBar {
    NavigationBarItem(
        selected = currentDestination?.hasRoute<Home>() == true,
        onClick = {
            navController.navigate(Home) {
                popUpTo<Home> { saveState = true }
                launchSingleTop = true
                restoreState = true
            }
        },
        icon = { Icon(Icons.Default.Home, null) },
        label = { Text("首页") },
    )
    // Search 和 Profile 类似
}

launchSingleTop = true 避免重复压入,restoreState = true 恢复上次离开时的状态。

关键细节:popUpTo<Home> 中的 Home 是底部导航的“根”。所有 tab 切换都以 Home 为基准弹出,但 saveState = true 保证弹出时保存状态。

习题 10:Web 端绑定浏览器导航

题目:在 wasmJsMain 中实现 bindToBrowserNavigation() 的调用。

参考答案:

// wasmJsMain
@OptIn(ExperimentalBrowserHistoryApi::class)
fun main() {
    val body = document.body ?: return
    ComposeViewport(body) {
        App(onNavHostReady = { it.bindToBrowserNavigation() })
    }
}

延伸思考:如果 Web 应用部署在子路径下(如 example.com/app/),需要在 bindToBrowserNavigation 时配置基础路径,否则路由解析会出错。

综合设计

习题 11:多页面应用骨架

题目:构建一个包含三个页面的应用:列表页(List)、详情页(Detail,接收 itemId: Int)、设置页(Settings)。实现从列表到详情、从列表到设置的导航,以及从详情返回列表。

参考答案:

@Serializable
object List

@Serializable
data class Detail(val itemId: Int)

@Serializable
object Settings

@Composable
fun App() {
    val navController = rememberNavController()
    NavHost(navController, startDestination = List) {
        composable<List> {
            ListScreen(
                onItemClick = { id -> navController.navigate(Detail(id)) },
                onSettingsClick = { navController.navigate(Settings) },
            )
        }
        composable<Detail> { entry ->
            val detail = entry.toRoute<Detail>()
            DetailScreen(
                itemId = detail.itemId,
                onBack = { navController.popBackStack() },
            )
        }
        composable<Settings> {
            SettingsScreen(onBack = { navController.popBackStack() })
        }
    }
}
习题 12:登录流程与返回栈清理

题目:实现登录 → 主页的流程。登录成功后导航到主页,并移除登录页。登录页有一个“返回”按钮(但栈中已经没有上一个页面时,按钮应该不可用或隐藏)。

参考答案:

@Serializable
object Login

@Serializable
object Home

@Composable
fun App() {
    val navController = rememberNavController()
    NavHost(navController, startDestination = Login) {
        composable<Login> {
            LoginScreen(
                onLoginSuccess = {
                    navController.navigate(Home) {
                        popUpTo<Login> { inclusive = true }
                    }
                }
            )
        }
        composable<Home> {
            HomeScreen()
        }
    }
}

导航后登录页从栈中移除,用户按返回键不会回到登录页。如果系统返回键没有可返回的页面,应用会退出。

延伸思考:登录页本身通常不需要“返回”按钮——它是应用的起点,栈中不应该有上一个页面。如果登录页是从其他地方(如“退出登录”)进入的,情况会复杂一些,需要用 popUpTo 清理整个栈。

习题 13:带状态的底部导航

题目:实现三个 tab(Home、Search、Profile)的底部导航。切换 tab 时保留每个 tab 的滚动位置和输入状态。

参考答案:

@Serializable
object Home

@Serializable
object Search

@Serializable
object Profile

@Composable
fun App() {
    val navController = rememberNavController()
    val backStackEntry by navController.currentBackStackEntryAsState()
    val currentDestination = backStackEntry?.destination

    Scaffold(
        bottomBar = {
            NavigationBar {
                val tabs = listOf(
                    Triple(Home, Icons.Default.Home, "首页"),
                    Triple(Search, Icons.Default.Search, "搜索"),
                    Triple(Profile, Icons.Default.Person, "我的"),
                )
                tabs.forEach { (route, icon, label) ->
                    NavigationBarItem(
                        selected = currentDestination?.hasRoute(route::class) == true,
                        onClick = {
                            navController.navigate(route) {
                                popUpTo<Home> { saveState = true }
                                launchSingleTop = true
                                restoreState = true
                            }
                        },
                        icon = { Icon(icon, null) },
                        label = { Text(label) },
                    )
                }
            }
        }
    ) { padding ->
        NavHost(
            navController,
            startDestination = Home,
            modifier = Modifier.padding(padding),
        ) {
            composable<Home> { HomeScreen() }
            composable<Search> { SearchScreen() }
            composable<Profile> { ProfileScreen() }
        }
    }
}

saveState = true 和 restoreState = true 配合使用,让每个 tab 在切换时保留自己的状态(滚动位置、输入内容等)。

延伸思考:hasRoute<T>() 用于判断当前目的地是否匹配某个路由类型。注意它接受的是 KClass,所以写 hasRoute(Home::class) 而不是 hasRoute<Home>()。

七、本课小结

三个核心概念:导航图描述所有目的地,目的地是页面节点,路由是目的地的标识和参数载体。三者关系是“图包含目的地,路由标识目的地”。

类型安全路由:用 @Serializable 的 object(无参数)或 data class(有参数)定义路由。composable<Route> 注册目的地,navigate(Route(...)) 导航。编译器检查类型安全,不需要手动解析参数。

返回栈:popBackStack() 弹出栈顶,popUpTo 在导航时清理返回栈,launchSingleTop 避免重复压栈。底部导航场景配合 saveState / restoreState 保留 tab 状态。

跨平台差异:iOS 上默认的返回手势动画会被自定义转场覆盖。Web 端通过 bindToBrowserNavigation() 与浏览器历史同步,@SerialName 可以让 URL 更简洁。

八、下一课预告

第5课 网络与数据层

Logo

一站式 AI 云服务平台

更多推荐