G导航与多页面

给会 XML 的人:先把传统多页面三套做法摆清(多 Activity、手工 Fragment、Navigation XML),再落到 Navigation Compose。现在业务 App 常态是单 Activity + 多目的地;底栏、列表进详情、登录清栈、返回传值都按真实频率写完。

先按场景选做法

常用业务 App 几乎天天写 偶尔对上场景再用
你想做的事XML 传统Compose频率
首页 / 分类 / 我的 三个 TabBottomNav + hide/show 或 NavigationUINavigationBar + NavHost + popUpTo常用
列表点进详情,系统返回回来startActivitynavigate(R.id.detail)nav.navigate("detail/$id")常用
详情页藏掉底栏详情另开 Activity,或目的地不在底栏 graph当前 route 不是 Tab 就不画底栏常用
登录成功进首页,返回不能回登录CLEAR_TASK / popUpTo inclusivepopUpTo("login") { inclusive = true }常用
把 id / 标题传到下一页Intent extra / Safe Args路径参数 "detail/{id}"常用
下一页改完,返回列表刷新setResult / Fragment Result上一页 SavedStateHandle常用
页内三个子页签(不进返回栈)TabLayout + ViewPager2TabRow + 本页状态,结构章常用
推送 / 网页打开指定详情intent-filter + Navigation deepLinknavDeepLink偶尔
每个 Tab 自己一条返回栈嵌套 nav_graph / 多 NavHostFragment嵌套 navigation()偶尔
路由对象化、少拼字符串Safe Args 插件类型安全路由 2.8+偶尔
选型口诀:App 级换页 → NavHost页内 Tab → 本页状态弹窗 → 本页 Dialog 状态(不必做成目的地);系统级独立任务(分享到外部、文件选择)才再开 Activity。

XML 传统:三套多页面 常用

概念

Compose 出现前,业务里其实不是「只有一种导航」。三套会长期共存,读旧项目先认是哪一套,再对 Compose。

骨架还在哪用和 Compose 的关系
多 Activity一页一个 Activity,startActivity老项目、独立流程(登录墙、WebView、相机)概念上 ≈ 多个 NavHost 之外的「系统页面」
手工 FragmentFragmentManager add/replace/hide/show大量主站:底栏四个 Fragment 保活最难迁:自己管栈,和官方 Navigation 行为不一致
Navigation XMLnav_graph.xml + NavHostFragment2019 后官方推荐,单 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,栈规则没换

res/navigation/nav_graph.xml
<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>
Activity + NavigationUI
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() }
NavHostFragmentNavHost
nav_graph.xmlNavHost { … } 代码图
NavController.navigate(R.id.xxx)nav.navigate("xxx")
Safe Args Directions字符串路由或 2.8+ 类型安全路由
setupWithNavController自己读 currentBackStackEntryAsStatenavigate
launchMode=singleToplaunchSingleTop = true
FLAG_ACTIVITY_CLEAR_TOPpopUpTo(...) { inclusive = … }
setResult / Fragment Result API上一目的地的 SavedStateHandle
NavArgs / Intent extraentry.arguments / 类型安全参数
dialog 目的地dialog("route") { } 或本页 if (open)
TabLayout + ViewPager2本页 TabRow,一般进 Nav 栈

易错点

  • 把每个 Compose 屏幕做成 Activity —— 转场、状态、底栏都会变难受。
  • XML 里已经用了 Navigation Component,迁 Compose 时不要退回手工 Fragment 事务。
  • ViewPager 的「左右滑几页」不是返回栈;系统返回不该把 Tab 当页面 pop。
▶ 同一条「列表进详情」:三套 XML 怎么写
Activity
startActivity(
    Intent(this, DetailActivity::class.java)
        .putExtra("id", id)
)
Fragment 事务
parentFragmentManager.beginTransaction()
    .replace(R.id.container, DetailFragment.newInstance(id))
    .addToBackStack(null)
    .commit()
Navigation XML + Safe Args
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:popUpToapp:launchSingleTopapp:restoreState 是同一组选项,只是从 XML 属性换成了 Kotlin lambda。

选项XML / Activity 直觉干什么
launchSingleTop = truesingleTop / SINGLE_TOP栈顶已是该目的地就复用,不叠第二份
popUpTo(id) { inclusive = false }CLEAR_TOP 但保留目标弹到某页为止,该页还在
inclusive = true连目标一起弹掉登录页洗掉自己
saveState / restoreStateNavigationUI 底栏默认切 Tab 时保住各 Tab 滚动位置

怎么用

nav.navigate("home") {
    popUpTo(nav.graph.findStartDestination().id) {
        saveState = true
    }
    launchSingleTop = true
    restoreState = true
}

易错点

底栏每次 navigatepopUpTo → 栈无限长,从「我的」连按返回会穿过首页、列表、再首页。XML 里 setupWithNavController 帮你写了这几行;Compose 要自己抄。

底栏与当前目的地同步 常用

概念

壳子在结构 · NavigationBar。选中态必须跟当前 route,不要另起一套下标(除非页内 Tab)。进详情时通常不显示底栏——XML 里这往往是「详情是另一个 Activity」顺带得到的;Compose 要自己根据 route 判断。

和 XML 对照

NavigationUI
bottomNav.setupWithNavController(nav)
// 详情若在同一 graph:
nav.addOnDestinationChangedListener { _, dest, _ ->
    bottomNav.isVisible = dest.id in tabIds
}
Compose
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 对照

XMLCompose 字符串路由
putExtra("id", id)navigate("detail/$id")
Safe Args args.identry.arguments?.getString("id")
app:argType="integer"NavType.IntType
android:defaultValue="@null" nullablenullable = 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 是否带)。

嵌套图 · 每 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 送走再回来」。
Android Compose 现代开发知识体系 · 主线必读 / 扩展选读。
术语表常驻;组件与属性先看分讲,再查两张总表。案例默认收起。