导航基础:NavHost、composable 路由、参数传递

导航,说白了就是页面之间的跳转。在传统的 Android 开发中,我们用 Intent、FragmentTransaction 那一套,写起来又臭又长。到了 Compose 时代,导航变得声明式了——你只需要告诉系统「我要去哪里」,剩下的交给框架。

我个人习惯把导航比作「地图 + 路标」。NavHost 就是地图,composable 路由就是路标,参数传递就是你要带上的行李。今天我们就来把这套东西讲透。

1. 导航三件套:NavHost、NavController、composable

先看一个最简单的例子。假设我们有两个页面:首页和详情页。

@Composable
fun MyApp() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = "home"
    ) {
        composable("home") {
            HomeScreen(
                onNavigateToDetail = { id ->
                    navController.navigate("detail/$id")
                }
            )
        }
        composable(
            route = "detail/{itemId}",
            arguments = listOf(
                navArgument("itemId") { type = NavType.StringType }
            )
        ) { backStackEntry ->
            val itemId = backStackEntry.arguments?.getString("itemId")
            DetailScreen(itemId = itemId)
        }
    }
}

嗯,这里要注意几个点:

  • rememberNavController():创建导航控制器,它管理着整个导航栈。记得放在顶层 Composable 里,别放在某个子组件里,否则跳转时会丢失状态。
  • NavHost:相当于一个容器,里面放着你所有的路由定义。startDestination 是默认首页。
  • composable("路由路径"):定义每个页面的路由。路径可以带参数,用花括号包裹。
💡 我的习惯:路由路径用字符串常量管理,比如 object Routes { const val HOME = "home"; const val DETAIL = "detail/{itemId}" }。这样改路径时不会到处找字符串。

2. 参数传递:路径参数 vs 查询参数

参数传递有两种方式,我在项目中都用过,各有适用场景。

2.1 路径参数

路径参数是写在 URL 路径里的,比如 detail/abc123。适合传递「必须要有」的参数,比如 ID。

// 定义
composable(
    route = "detail/{itemId}",
    arguments = listOf(
        navArgument("itemId") { type = NavType.StringType }
    )
) { backStackEntry ->
    val itemId = backStackEntry.arguments?.getString("itemId") ?: ""
    DetailScreen(itemId)
}

// 跳转
navController.navigate("detail/abc123")

这里有个坑:路径参数默认是 String 类型,如果你要传 Int,记得显式指定 NavType.IntType。我曾经因为忘记指定类型,导致参数解析出来是 null,排查了半天。

2.2 查询参数

查询参数是跟在问号后面的,比如 detail?itemId=abc123&source=list。适合传递「可选」或「额外」的信息。

// 定义
composable(
    route = "detail?itemId={itemId}&source={source}",
    arguments = listOf(
        navArgument("itemId") { type = NavType.StringType; defaultValue = "" },
        navArgument("source") { type = NavType.StringType; defaultValue = "unknown" }
    )
) { backStackEntry ->
    val itemId = backStackEntry.arguments?.getString("itemId") ?: ""
    val source = backStackEntry.arguments?.getString("source") ?: "unknown"
    DetailScreen(itemId, source)
}

// 跳转
navController.navigate("detail?itemId=abc123&source=list")
⚠️ 注意:查询参数必须设置 defaultValue,否则 Compose 会报错。因为查询参数是可选的,框架需要知道缺省时用什么值。

3. 导航栈管理:返回、清除、单例

导航不只是跳转,还得管怎么回来。我见过不少新手把返回逻辑写崩了,页面栈乱成一锅粥。

3.1 标准返回

navController.popBackStack() 即可。默认返回上一页,如果栈里没有上一页了,就退出应用。

3.2 清除栈顶

有时候跳转到登录页后,不希望用户按返回键回到首页。这时候用 launchSingleTop = true 配合 popUpTo

navController.navigate("login") {
    popUpTo("home") { inclusive = true }
}

这段代码的意思是:跳转到 login 页面,同时把 home 页面从栈里移除。这样用户登录后按返回键,直接退出应用,不会回到首页。

3.3 单例模式

如果你不想重复创建同一个页面(比如首页),用 launchSingleTop = true

navController.navigate("home") {
    launchSingleTop = true
}

这样如果首页已经在栈顶,就不会再创建一个新的。

4. 实战:一个完整的导航示例

我们做一个简单的商品列表 → 商品详情 → 商品编辑的流程。看看参数传递和栈管理怎么配合。

// 路由定义
object Routes {
    const val PRODUCT_LIST = "product_list"
    const val PRODUCT_DETAIL = "product_detail/{productId}"
    const val PRODUCT_EDIT = "product_edit?productId={productId}"
}

// NavHost 配置
NavHost(
    navController = navController,
    startDestination = Routes.PRODUCT_LIST
) {
    composable(Routes.PRODUCT_LIST) {
        ProductListScreen(
            onProductClick = { productId ->
                navController.navigate("product_detail/$productId")
            }
        )
    }

    composable(
        route = Routes.PRODUCT_DETAIL,
        arguments = listOf(
            navArgument("productId") { type = NavType.StringType }
        )
    ) { backStackEntry ->
        val productId = backStackEntry.arguments?.getString("productId") ?: ""
        ProductDetailScreen(
            productId = productId,
            onEditClick = {
                navController.navigate("product_edit?productId=$productId")
            },
            onBackClick = {
                navController.popBackStack()
            }
        )
    }

    composable(
        route = Routes.PRODUCT_EDIT,
        arguments = listOf(
            navArgument("productId") { type = NavType.StringType; defaultValue = "" }
        )
    ) { backStackEntry ->
        val productId = backStackEntry.arguments?.getString("productId") ?: ""
        ProductEditScreen(
            productId = productId,
            onSaveSuccess = {
                // 保存成功后,返回详情页并刷新
                navController.popBackStack()
            }
        )
    }
}

你想想看,这个流程里参数传递是不是很清晰?路径参数传必填的 productId,查询参数传可选的编辑模式。返回时用 popBackStack,不会搞乱栈。

5. 避坑指南

最后分享几个我踩过的坑:

  • 参数类型不匹配:路径参数默认 String,传 Int 时要显式指定 NavType.IntType。否则解析出来是 null。
  • 忘记设置 defaultValue:查询参数必须设置 defaultValue,否则编译不报错,运行时崩溃。
  • 导航栈溢出:如果用户频繁跳转,栈会越来越大。建议在不需要返回的页面用 popUpTo 清除。
  • rememberNavController 位置不对:一定要放在最顶层的 Composable 里,否则子组件重新组合时,导航状态会丢失。
核心总结:
  • NavHost 是地图,composable 是路标,NavController 是方向盘。
  • 路径参数传必填数据,查询参数传可选数据。
  • 用 popUpTo 和 launchSingleTop 管理栈,别让栈无限膨胀。
  • 参数类型和 defaultValue 一定要写对,否则运行时等着崩溃吧。

导航这块其实不难,但细节很多。你只要把「地图、路标、行李」这三个概念理清楚,写起来就很顺手。我在项目中用这套模式写了十几个页面,从来没出过问题。

Compose 导航核心结构 NavHost(导航地图) composable("home") 首页 无参数 composable("detail/{id}") 详情页 路径参数: id ... 更多路由 参数传递方式 路径参数 detail/abc123 必填参数 查询参数 detail?id=abc&src=list 可选参数,需默认值 NavController navigate / popBackStack 管理导航栈

这张图把导航的核心结构画出来了。NavHost 是容器,里面放多个 composable 路由。参数传递分路径参数和查询参数两种,NavController 负责跳转和返回。你只要记住这个结构,写导航代码就不会乱。

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