G导航与多页面
给会 XML 的人:先把传统多页面三套做法摆清(多 Activity、手工 Fragment、Navigation XML),再落到 Navigation Compose。现在业务 App 常态是单 Activity + 多目的地;底栏、列表进详情、登录清栈、返回传值都按真实频率写完。
🔗 引用:Navigation Compose · Navigation 总览(XML 同源) · 传参 · 深层链接
先按场景选做法
| 你想做的事 | XML 传统 | Compose | 频率 |
|---|---|---|---|
| 首页 / 分类 / 我的 三个 Tab | BottomNav + hide/show 或 NavigationUI | NavigationBar + NavHost + popUpTo | 常用 |
| 列表点进详情,系统返回回来 | startActivity 或 navigate(R.id.detail) | nav.navigate("detail/$id") | 常用 |
| 详情页藏掉底栏 | 详情另开 Activity,或目的地不在底栏 graph | 当前 route 不是 Tab 就不画底栏 | 常用 |
| 登录成功进首页,返回不能回登录 | CLEAR_TASK / popUpTo inclusive | popUpTo("login") { inclusive = true } | 常用 |
| 把 id / 标题传到下一页 | Intent extra / Safe Args | 路径参数 "detail/{id}" | 常用 |
| 下一页改完,返回列表刷新 | setResult / Fragment Result | 上一页 SavedStateHandle | 常用 |
| 页内三个子页签(不进返回栈) | TabLayout + ViewPager2 | TabRow + 本页状态,结构章 | 常用 |
| 推送 / 网页打开指定详情 | intent-filter + Navigation deepLink | navDeepLink | 偶尔 |
| 每个 Tab 自己一条返回栈 | 嵌套 nav_graph / 多 NavHostFragment | 嵌套 navigation() | 偶尔 |
| 路由对象化、少拼字符串 | Safe Args 插件 | 类型安全路由 2.8+ | 偶尔 |
XML 传统:三套多页面 常用
概念
Compose 出现前,业务里其实不是「只有一种导航」。三套会长期共存,读旧项目先认是哪一套,再对 Compose。
| 套 | 骨架 | 还在哪用 | 和 Compose 的关系 |
|---|---|---|---|
| 多 Activity | 一页一个 Activity,startActivity | 老项目、独立流程(登录墙、WebView、相机) | 概念上 ≈ 多个 NavHost 之外的「系统页面」 |
| 手工 Fragment | FragmentManager add/replace/hide/show | 大量主站:底栏四个 Fragment 保活 | 最难迁:自己管栈,和官方 Navigation 行为不一致 |
| Navigation XML | nav_graph.xml + NavHostFragment | 2019 后官方推荐,单 Activity | 和 Navigation Compose 同一套术语:目的地、返回栈、popUpTo |
① 多 Activity
系统 Task 上压 Activity。传参靠 Intent;回来靠 ActivityResultLauncher(旧 startActivityForResult)。launchMode / Intent Flag 决定会不会开出一堆重复页。
// 列表 → 详情
startActivity(Intent(this, DetailActivity::class.java).putExtra("id", id))
// 详情 → 列表(带结果)
setResult(RESULT_OK, Intent().putExtra("changed", true))
finish()
常见 Flag:FLAG_ACTIVITY_SINGLE_TOP(栈顶是自己就复用)、CLEAR_TOP(清到已有实例)、NEW_TASK | CLEAR_TASK(登录后洗掉任务栈)。
痛点:主题、底栏、ViewModel 难共享;转场重;深层链接要自己在 Activity 里拆 Intent。
② 手工 Fragment + 底栏
一个 MainActivity 里四个 Fragment,BottomNavigationView 点一下 hide 当前、show 目标,避免 replace 把状态毁掉。详情往往 replace + addToBackStack,或索性再开 Activity。
fun switchTab(tag: String) {
val tx = supportFragmentManager.beginTransaction()
tabs.forEach { (t, f) -> if (t == tag) tx.show(f) else tx.hide(f) }
tx.commit()
}
// 详情(会进 Fragment 返回栈)
supportFragmentManager.beginTransaction()
.replace(R.id.container, DetailFragment.newInstance(id))
.addToBackStack("detail")
.commit()
痛点:返回键、旋转、进程被杀后恢复,全靠自己。深层链接、Safe Args、NavigationUI 都享受不到。Compose 不要再手写一套 hide/show。
③ Navigation Component(XML 图)
官方在 Compose 之前就推的单 Activity 方案。图是 XML,运行时仍是 NavController。Compose 只是把图从 XML 换成 Kotlin lambda,栈规则没换。
<navigation
app:startDestination="@id/home">
<fragment
android:id="@+id/home"
android:name=".HomeFragment">
<action
android:id="@+id/to_detail"
app:destination="@id/detail"/>
</fragment>
<fragment
android:id="@+id/detail"
android:name=".DetailFragment">
<argument
android:name="id"
app:argType="string"/>
</fragment>
</navigation>
val nav = findNavController(R.id.nav_host)
bottomNav.setupWithNavController(nav)
// Safe Args
nav.navigate(HomeFragmentDirections.toDetail(id))
// 登录后清栈
nav.navigate(R.id.home, null, NavOptions.Builder()
.setPopUpTo(R.id.login, true)
.build())
<!-- activity_main.xml -->
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
app:navGraph="@navigation/nav_graph"
app:defaultNavHost="true"/>
app:defaultNavHost="true":系统返回键交给 NavController,不要自己再 onBackPressed 抢一次。
XML 对照总表(迁 Compose 时当字典)
| XML / View 体系 | Compose |
|---|---|
Activity | 通常只留一个;里面是 setContent { App() } |
Fragment 目的地 | composable("route") { Screen() } |
NavHostFragment | NavHost |
nav_graph.xml | NavHost { … } 代码图 |
NavController.navigate(R.id.xxx) | nav.navigate("xxx") |
Safe Args Directions | 字符串路由或 2.8+ 类型安全路由 |
setupWithNavController | 自己读 currentBackStackEntryAsState 再 navigate |
launchMode=singleTop | launchSingleTop = true |
FLAG_ACTIVITY_CLEAR_TOP | popUpTo(...) { inclusive = … } |
setResult / Fragment Result API | 上一目的地的 SavedStateHandle |
NavArgs / Intent extra | entry.arguments / 类型安全参数 |
dialog 目的地 | dialog("route") { } 或本页 if (open) |
TabLayout + ViewPager2 | 本页 TabRow,一般不进 Nav 栈 |
易错点
- 把每个 Compose 屏幕做成 Activity —— 转场、状态、底栏都会变难受。
- XML 里已经用了 Navigation Component,迁 Compose 时不要退回手工 Fragment 事务。
- ViewPager 的「左右滑几页」不是返回栈;系统返回不该把 Tab 当页面 pop。
▶ 同一条「列表进详情」:三套 XML 怎么写
startActivity(
Intent(this, DetailActivity::class.java)
.putExtra("id", id)
)
parentFragmentManager.beginTransaction()
.replace(R.id.container, DetailFragment.newInstance(id))
.addToBackStack(null)
.commit()
findNavController().navigate(
ListFragmentDirections.actionListToDetail(id)
)
Compose 只保留第三套的心智:有图、有栈、有参数。写法见下节。
Navigation Compose 是什么 常用
概念
用 Kotlin 声明目的地与返回栈。核心仍是:路由标识一页,navigate 压栈,系统返回 / popBackStack 出栈。和 XML nav_graph 是同一套 Navigation,不是新发明。
和 XML 对照
XML:nav_graph.xml + NavHostFragment。Compose:NavHost + composable("route") { }。控制器都叫 NavController。
依赖与基本对象 常用
概念
依赖 Navigation Compose(跟 BOM)。三个角色:
rememberNavController()— 控制器(≈findNavController())NavHost(navController, startDestination)— 宿主(≈NavHostFragment)composable("route") { Screen() }— 目的地(≈ graph 里的fragment)
怎么用
@Composable
fun AppNav() {
val nav = rememberNavController()
NavHost(navController = nav, startDestination = "home") {
composable("home") {
HomeScreen(onOpenList = { nav.navigate("list") })
}
composable("list") {
ListScreen(onOpen = { id -> nav.navigate("detail/$id") })
}
composable(
route = "detail/{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType })
) { entry ->
val id = entry.arguments?.getString("id").orEmpty()
DetailScreen(id = id, onBack = { nav.popBackStack() })
}
}
}
屏幕尽量只收 onOpen / onBack 回调,不要把 NavController 一层层传到叶子组件(测试和 Preview 都更简单)。需要 BackHandler 拦截返回时见副作用。
易错点
- 多个 Activity 思维硬套 —— 单 Activity + 多 composable 是常态。
- 在叶子里
nav.navigate,再在中间层也 navigate → 连跳两页。 startDestination必须是图里已声明的 route,拼写错会运行期炸。
导航与返回栈 常用
概念
navigate 压栈;系统返回 / 顶栏返回 / popBackStack 出栈。这和 XML Navigation 的 app:popUpTo、app:launchSingleTop、app:restoreState 是同一组选项,只是从 XML 属性换成了 Kotlin lambda。
| 选项 | XML / Activity 直觉 | 干什么 |
|---|---|---|
launchSingleTop = true | singleTop / SINGLE_TOP | 栈顶已是该目的地就复用,不叠第二份 |
popUpTo(id) { inclusive = false } | CLEAR_TOP 但保留目标 | 弹到某页为止,该页还在 |
inclusive = true | 连目标一起弹掉 | 登录页洗掉自己 |
saveState / restoreState | NavigationUI 底栏默认 | 切 Tab 时保住各 Tab 滚动位置 |
怎么用
nav.navigate("home") {
popUpTo(nav.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
易错点
底栏每次 navigate 不 popUpTo → 栈无限长,从「我的」连按返回会穿过首页、列表、再首页。XML 里 setupWithNavController 帮你写了这几行;Compose 要自己抄。
底栏与当前目的地同步 常用
概念
壳子在结构 · NavigationBar。选中态必须跟当前 route,不要另起一套下标(除非页内 Tab)。进详情时通常不显示底栏——XML 里这往往是「详情是另一个 Activity」顺带得到的;Compose 要自己根据 route 判断。
和 XML 对照
bottomNav.setupWithNavController(nav)
// 详情若在同一 graph:
nav.addOnDestinationChangedListener { _, dest, _ ->
bottomNav.isVisible = dest.id in tabIds
}
val route = backStack?.destination?.route
val showBar = route in setOf("home", "list", "me")
Scaffold(bottomBar = { if (showBar) NavigationBar { … } })
▶ 三 Tab:首页 / 列表 / 我的,再进详情
@Composable
fun MainTabs() {
val nav = rememberNavController()
val backStack by nav.currentBackStackEntryAsState()
val route = backStack?.destination?.route
val tabs = listOf("home", "list", "me")
Scaffold(
bottomBar = {
if (route in tabs) {
NavigationBar {
NavigationBarItem(
selected = route == "home",
onClick = { nav.navigate("home") { popUpToStart(nav) } },
icon = { Icon(Icons.Default.Home, contentDescription = "首页") },
label = { Text("首页") }
)
// list / me 同理
}
}
}
) { padding ->
NavHost(nav, startDestination = "home", modifier = Modifier.padding(padding)) {
composable("home") { HomeScreen() }
composable("list") { FeedScreen(onOpen = { nav.navigate("detail/$it") }) }
composable("me") { MeScreen() }
composable("detail/{id}") { DetailScreen(onBack = { nav.popBackStack() }) }
}
}
}
fun NavOptionsBuilder.popUpToStart(nav: NavController) {
popUpTo(nav.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
传参 常用
概念
主线用字符串 route:"detail/{id}" 必填路径参数,"search?q={q}" 查询参数。类型用 navArgument 声明,和 XML <argument app:argType> 对应。
和 XML 对照
| XML | Compose 字符串路由 |
|---|---|
putExtra("id", id) | navigate("detail/$id") |
Safe Args args.id | entry.arguments?.getString("id") |
app:argType="integer" | NavType.IntType |
android:defaultValue="@null" nullable | nullable = true + 查询参数 |
怎么用
composable(
route = "detail/{id}?title={title}",
arguments = listOf(
navArgument("id") { type = NavType.StringType },
navArgument("title") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { entry ->
val id = entry.arguments?.getString("id").orEmpty()
val title = entry.arguments?.getString("title")
DetailScreen(id, title)
}
nav.navigate("detail/$id?title=${Uri.encode(title)}")
只传可放进 URL 的简单类型(字符串、数字、布尔)。大对象、Bitmap、整个 UiState 不要塞路由——用共享 ViewModel(同 Activity 作用域)或先存仓库再只传 id。
类型安全路由 偶尔
Navigation 2.8+ 可用 Kotlin Serialization,少拼字符串,对标 XML Safe Args 插件:
@Serializable data object Home
@Serializable data class Detail(val id: String)
NavHost(nav, startDestination = Home) {
composable<Home> { HomeScreen(onOpen = { nav.navigate(Detail(it)) }) }
composable<Detail> { entry ->
val args = entry.toRoute<Detail>()
DetailScreen(args.id)
}
}
易错点
- 参数含
/、?不Uri.encode→ 路由解析错位。 - 忘记声明
navArgument类型,全当 String,数字页会解析失败。 navigate("detail/null")把空 id 传进去 —— 列表点击要先保证 id 有效。
▶ 从列表点进详情再返回
onClick = { nav.navigate("detail/$itemId") }
composable("detail/{id}") { entry ->
DetailScreen(
id = entry.arguments?.getString("id").orEmpty(),
onBack = { nav.popBackStack() }
)
}
登录后清栈 常用
概念
登录成功进入主页后,系统返回不该回到登录。XML 里是 FLAG_ACTIVITY_NEW_TASK | CLEAR_TASK,或 Navigation popUpTo(login, inclusive = true)。
怎么用
nav.navigate("home") {
popUpTo("login") { inclusive = true }
}
// 退出登录:清主图,回到登录
nav.navigate("login") {
popUpTo(nav.graph.id) { inclusive = true }
}
启动图也可以按「已登录否」选 startDestination(读 DataStore / 内存 token)。不要两个 start 都挂着再靠第一次重组去 navigate,会闪一帧登录页。
易错点
只 navigate("home") 不清 login → 用户从首页按返回又看到登录。和 XML 里忘了 finish() 登录 Activity 是同一个坑。
返回传值 常用
概念
详情改了标题,列表要刷新。XML:Activity setResult,或 Fragment Result API(setFragmentResult)。Compose:写上一页 BackStackEntry 的 SavedStateHandle,上一页 getLiveData / 当 State 收。
怎么用
// 详情保存成功
nav.previousBackStackEntry
?.savedStateHandle
?.set("needRefresh", true)
nav.popBackStack()
// 列表
val handle = nav.currentBackStackEntry?.savedStateHandle
val refresh by handle
?.getStateFlow("needRefresh", false)
?.collectAsStateWithLifecycle()
?: remember { mutableStateOf(false) }
LaunchedEffect(refresh) {
if (refresh) {
vm.reload()
handle?.set("needRefresh", false)
}
}
和 XML 对照
Fragment Result 是「谁登记谁收」;SavedStateHandle 是「钉在某一栈帧上」。旋转后还在,这点和 remember 局部变量不同。
易错点
往 currentBackStackEntry 上 set 再 pop —— 写的是详情自己,列表收不到。必须 previousBackStackEntry。
对话框:本页状态 vs 导航目的地
概念
确认删除、普通 Alert:用本页 if (open),见结构章。只有「对话框要进返回栈、分享链接能单独打开它」才做成 dialog("route") 目的地(XML graph 里的 <dialog>)。
NavHost(nav, "home") {
composable("home") { HomeScreen(onHelp = { nav.navigate("help") }) }
dialog("help") { HelpDialog(onDismiss = { nav.popBackStack() }) }
}
BottomSheet 同理:临时用 ModalBottomSheet 状态;要进栈再用 accompanist / material 的 bottomSheet 目的地(看你 BOM 是否带)。
深层链接 偶尔
概念
推送、H5、App Links 打开「就进这一页」。XML 在 destination 上写 <deepLink app:uri="https://shop.example/item/{id}"/>;Compose 写 deepLinks 列表。Manifest 仍要 intent-filter(和 XML 时代一样)。
composable(
route = "detail/{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType }),
deepLinks = listOf(
navDeepLink { uriPattern = "https://shop.example/item/{id}" }
)
) { … }
易错点
只写了 composable 的 deepLink、Manifest 没声明 → 系统根本不会把 URL 交给你。多 Activity 旧工程还要把 filter 挂在那个 NavHost 所在的 Activity 上。
嵌套图 · 每 Tab 一条栈 偶尔
概念
默认一个 NavHost 一条栈:从首页进详情再切「我的」,返回可能先出详情。若产品要求「每个底栏 Tab 记住自己点到哪」,XML 用嵌套 navigation graph 或多个 NavHostFragment;Compose 用 navigation(route, startDestination) { } 嵌套,底栏 navigate 到各子图路由并 saveState/restoreState。
NavHost(nav, startDestination = "home") {
navigation(route = "home", startDestination = "home/feed") {
composable("home/feed") { FeedScreen(onOpen = { nav.navigate("home/detail/$it") }) }
composable("home/detail/{id}") { DetailScreen() }
}
navigation(route = "me", startDestination = "me/root") {
composable("me/root") { MeScreen() }
composable("me/settings") { SettingsScreen() }
}
}
多数电商/内容 App 用「单栈 + 详情藏底栏」就够。多栈实现成本高,确认产品真要再上。
易错合集
- 底栏不
popUpTo + launchSingleTop + restoreState→ 返回乱跳、滚动位置丢。 - 用本地下标当选中态,不读
currentBackStackEntryAsState→ 深链进来底栏高亮是错的。 - 组合体里
if (loggedIn) nav.navigate()→ 每帧都 navigate;应在LaunchedEffect / 点击回调里走。 - 大对象塞进 route;旋转或进程死后还原失败。
- Preview 里放真 NavHost 又缺 startDestination 数据 → 屏幕单独 Preview,导航在
AppNav测。 - XML 互操作:Fragment 里嵌 Compose 岛时,用
rememberNavController和 Activity 的findNavController不是同一个栈,跨岛跳页会迷路。渐进迁移见XML 迁移。
key / 分页仍在列表章;本页只负责「怎么把 id 送走再回来」。