导航与路由:Navigation Compose入门

导航,说白了就是页面之间的跳转逻辑。在传统Android开发中,我们用FragmentManager或者Intent来做这件事。但在Jetpack Compose的世界里,Navigation Compose成了官方推荐方案。

我第一次接触Navigation Compose时,心里其实有点抵触——毕竟Fragment那套东西用了好多年。但真正上手后,我发现这套方案简洁得让人舒服。你想想看,不用再写一堆FragmentTransaction的样板代码,多好。

Navigation Compose的核心概念

Navigation Compose的核心就三个东西:NavControllerNavHost路由

  • NavController:导航的总指挥,负责记录页面栈、执行跳转和返回
  • NavHost:导航的容器,定义了哪些路由对应哪些Composable页面
  • 路由:每个页面的唯一标识,说白了就是一个字符串

我习惯把路由定义成常量,放在一个单独的文件里。这样后期维护时,改一个地方就行,不用满项目搜索字符串。

object Routes {
    const val HOME = "home"
    const val PROFILE = "profile/{userId}"
    const val SETTINGS = "settings"
}

NavHost与NavController的用法

先看一个最简单的例子。我们在Activity里创建一个NavController,然后把它传给NavHost。

@Composable
fun MyApp() {
    val navController = rememberNavController()
    
    NavHost(
        navController = navController,
        startDestination = Routes.HOME
    ) {
        composable(Routes.HOME) {
            HomeScreen(
                onNavigateToProfile = { userId ->
                    navController.navigate("profile/$userId")
                }
            )
        }
        composable(
            route = Routes.PROFILE,
            arguments = listOf(
                navArgument("userId") { type = NavType.StringType }
            )
        ) { backStackEntry ->
            val userId = backStackEntry.arguments?.getString("userId") ?: ""
            ProfileScreen(userId = userId)
        }
    }
}

这里有个细节要注意:rememberNavController()必须在Composable作用域内调用。我曾经在ViewModel里尝试创建NavController,结果编译直接报错——它依赖CompositionLocal,只能在Composable环境里用。

参数传递

参数传递分两种:路径参数查询参数

参数类型 写法 适用场景
路径参数 profile/{userId} 必须传递的参数,如用户ID
查询参数 search?query={keyword} 可选参数,如搜索关键词

路径参数在路由定义时用花括号占位,然后在navArgument里声明类型。查询参数则用?拼接,类似URL的query string。

// 路径参数示例
composable(
    route = "profile/{userId}",
    arguments = listOf(
        navArgument("userId") { 
            type = NavType.StringType
            defaultValue = "unknown"
        }
    )
)

// 查询参数示例
composable(
    route = "search?query={query}",
    arguments = listOf(
        navArgument("query") { 
            type = NavType.StringType
            nullable = true
        }
    )
)

嗯,这里要注意:路径参数不能为null,但查询参数可以。如果你需要传递可选值,用查询参数更合适。

深层链接

深层链接(Deep Link)允许外部应用直接跳转到你App的某个页面。比如用户点击一个链接,直接打开商品详情页。

配置深层链接分两步:

  1. 在AndroidManifest.xml里声明intent-filter
  2. 在NavHost的composable里添加deepLinks参数
<!-- AndroidManifest.xml -->
<activity android:name=".MainActivity">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:scheme="myapp"
            android:host="profile"
            android:pathPrefix="/{userId}" />
    </intent-filter>
</activity>
// NavHost中的深层链接配置
composable(
    route = "profile/{userId}",
    deepLinks = listOf(
        navDeepLink { uriPattern = "myapp://profile/{userId}" }
    )
) { backStackEntry ->
    // 处理页面逻辑
}

我曾经踩过一个坑:深层链接的uriPattern必须和AndroidManifest里的scheme、host、path完全匹配,否则死活跳不过去。调试时记得用adb命令测试:adb shell am start -d "myapp://profile/123"

底部导航栏实现

底部导航栏是大多数App的标配。在Navigation Compose里实现它,核心思路是:用Scaffold包裹BottomNavigation,然后和NavHost联动。

data class BottomNavItem(
    val route: String,
    val label: String,
    val icon: ImageVector
)

val bottomNavItems = listOf(
    BottomNavItem(Routes.HOME, "首页", Icons.Default.Home),
    BottomNavItem(Routes.SEARCH, "搜索", Icons.Default.Search),
    BottomNavItem(Routes.PROFILE, "我的", Icons.Default.Person)
)

@Composable
fun MainScreen() {
    val navController = rememberNavController()
    
    Scaffold(
        bottomBar = {
            NavigationBar {
                val navBackStackEntry by navController.currentBackStackEntryAsState()
                val currentRoute = navBackStackEntry?.destination?.route
                
                bottomNavItems.forEach { item ->
                    NavigationBarItem(
                        icon = { Icon(item.icon, contentDescription = item.label) },
                        label = { Text(item.label) },
                        selected = currentRoute == item.route,
                        onClick = {
                            navController.navigate(item.route) {
                                popUpTo(navController.graph.startDestinationId) {
                                    saveState = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        }
                    )
                }
            }
        }
    ) { innerPadding ->
        NavHost(
            navController = navController,
            startDestination = Routes.HOME,
            modifier = Modifier.padding(innerPadding)
        ) {
            composable(Routes.HOME) { HomeScreen() }
            composable(Routes.SEARCH) { SearchScreen() }
            composable(Routes.PROFILE) { ProfileScreen() }
        }
    }
}

这里有个关键点:popUpTolaunchSingleTop的组合。如果不加这两个参数,每次点击底部导航都会创建一个新的页面实例,返回时就会出现奇怪的行为。我刚开始做的时候没注意,结果用户反馈说「点返回键直接退出App了」——其实就是页面栈乱了。

核心要点:底部导航的跳转一定要用popUpTo(startDestinationId) { saveState = true } + launchSingleTop = true + restoreState = true。这能保证页面栈干净,状态也能正确恢复。

知识体系总览

下面这张图概括了本章的核心内容。我画图时习惯把关键流程和注意事项都标出来,方便复习时一眼看到重点。

Navigation Compose 知识体系 NavController NavHost 路由定义 参数传递 路径参数 / 查询参数 深层链接 外部跳转 / URI匹配 底部导航 Scaffold + NavigationBar ⚠️ 常见陷阱 • NavController 必须在 Composable 作用域内创建 • 底部导航跳转必须使用 popUpTo + launchSingleTop

个人建议:刚开始学Navigation Compose时,不要急着把所有功能都塞进去。先跑通一个最简单的两页面跳转,再逐步添加参数传递、深层链接、底部导航。我每次带新人都是这个思路——先跑通,再优化。

注意:Navigation Compose的版本和Compose版本需要匹配。我用的是androidx.navigation:navigation-compose:2.7.7,对应Compose BOM 2024.02.00。版本不匹配会导致编译错误,或者运行时出现奇怪的行为。

好了,这一章的内容就到这里。导航是App的骨架,把骨架搭好了,后面的页面填充就顺理成章。下一章我们会深入Compose的布局系统,看看那些好用的布局组件到底怎么用。


公众号:蓝海资料掘金营,微信deep3321