10、条件导航与导航选项:popUpTo、popUpToInclusive、launchSingleTop、根据业务逻辑动态决定跳转目标

导航组件用久了,你会发现一个规律:真正的项目里,导航从来不是一条直线走到底的。用户可能登录后跳到首页,也可能从推送通知直接跳到某个详情页。更麻烦的是,返回栈的管理稍有不慎,用户按返回键就跳到不该去的地方了。

这一章,我们就来啃下这些硬骨头。我会把条件导航和导航选项揉碎了讲,配合我踩过的坑,帮你彻底搞懂。

10.1 导航选项:popUpTo 与 popUpToInclusive

先问个问题:用户登录成功后,从 LoginFragment 跳到 HomeFragment。这时候用户按返回键,应该回到登录页吗?

当然不应该。登录成功后,登录页就该从返回栈里移除。这就是 popUpTopopUpToInclusive 的用武之地。

10.1.1 popUpTo 的基本用法

popUpTo 的意思是:跳转之前,先把返回栈里的一些页面弹掉。说白了,就是清理历史记录。

看个例子:

// 在 LoginFragment 中跳转到 HomeFragment
findNavController().navigate(
    R.id.action_login_to_home,
    null,
    NavOptions.Builder()
        .setPopUpTo(R.id.loginFragment, true)  // 弹出 loginFragment
        .build()
)

这里 setPopUpTo 的第一个参数是目标 Fragment 的 ID,第二个参数就是 popUpToInclusive

  • popUpToInclusive = false:弹出到 loginFragment 为止,但 loginFragment 本身保留。
  • popUpToInclusive = true:弹出到 loginFragment,并且连 loginFragment 一起弹掉。

我习惯在登录成功后把 popUpToInclusive 设为 true。这样用户按返回键就直接退出应用了,不会看到登录页。

核心原则popUpTo 指定一个「清理到哪个页面为止」,popUpToInclusive 决定这个页面本身要不要被清理。

10.1.2 多级返回栈清理

有时候场景更复杂。比如:A → B → C → D,现在要从 D 跳回 A,并且把 B、C、D 全部清理掉。

findNavController().navigate(
    R.id.action_D_to_A,
    null,
    NavOptions.Builder()
        .setPopUpTo(R.id.aFragment, false)  // 弹出到 A,但保留 A
        .build()
)

这里 popUpToInclusive = false,所以 A 保留,B、C、D 被弹出。返回栈里只剩 A。

我曾经在做一个电商项目时,用户从商品列表 → 商品详情 → 下单页 → 支付成功页。支付成功后要跳回首页,并且清空中间所有页面。当时我忘了加 popUpTo,结果用户按返回键又回到了下单页……嗯,那天的 bug 单写得特别长。

10.2 launchSingleTop:避免重复创建

你有没有遇到过这种情况:用户疯狂点击某个按钮,结果同一个 Fragment 被创建了好几次?

launchSingleTop 就是用来解决这个问题的。它的作用和 Activity 的 singleTop 启动模式类似:如果目标 Fragment 已经在返回栈顶部,就不会重新创建,而是复用已有的实例。

NavOptions.Builder()
    .setLaunchSingleTop(true)
    .build()

举个例子:底部导航栏的首页、分类、购物车、个人中心。用户频繁切换时,你肯定不希望每次切换都重新创建 Fragment。

我的建议:底部导航栏的跳转,一律加上 launchSingleTop = true。配合 popUpTo 使用效果更佳。

但要注意:launchSingleTop 只检查「栈顶」是不是同一个 Fragment。如果目标 Fragment 在栈中间,它还是会重新创建。这时候需要结合 popUpTo 来调整栈结构。

10.3 根据业务逻辑动态决定跳转目标

导航图里定义的 action 是静态的,但业务逻辑是动态的。比如:

  • 用户未登录 → 跳登录页
  • 用户已登录但未实名 → 跳实名认证页
  • 用户已登录且已实名 → 直接跳首页

这种场景下,你不能在导航图里写死目标。我一般用两种方式处理。

10.3.1 方式一:代码中动态选择目标 ID

val destinationId = when {
    !isLoggedIn -> R.id.loginFragment
    !isVerified -> R.id.verifyFragment
    else -> R.id.homeFragment
}

findNavController().navigate(
    destinationId,
    null,
    navOptions
)

这种方式最直接。但要注意:如果跳转的目标不在当前导航图里,会抛出异常。所以建议把公共的 Fragment 放在全局导航图里。

10.3.2 方式二:使用 Deep Link 配合条件判断

有时候我会用 Deep Link 来处理复杂的条件导航。比如:

// 在 Fragment 中
val deepLink = NavDeepLinkRequest.Builder()
    .setUri(Uri.parse("myapp://dashboard"))
    .build()

findNavController().navigate(deepLink)

然后在导航图里配置 Deep Link,并在目标 Fragment 的 onCreate 里做条件判断,决定是否重定向。

我个人更推荐第一种方式。简单、直观、好维护。Deep Link 更适合外部唤起场景。

注意:动态跳转时,一定要处理好 popUpTo。否则返回栈里会残留不该有的页面。我曾经因为忘记清理,导致用户从实名认证页返回时,又看到了登录页……用户体验极差。

10.4 综合实战:一个完整的条件导航流程

我们来写一个完整的例子。假设 App 启动后,需要判断用户状态:

  1. 未登录 → 跳 LoginFragment
  2. 已登录但未实名 → 跳 VerifyFragment
  3. 已登录且已实名 → 跳 HomeFragment

并且要求:登录成功后,LoginFragment 不能留在返回栈里。实名认证成功后,VerifyFragment 也不能留在返回栈里。

// SplashFragment.kt
class SplashFragment : Fragment() {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        val isLoggedIn = PreferenceManager.isLoggedIn(requireContext())
        val isVerified = PreferenceManager.isVerified(requireContext())

        val navController = findNavController()

        when {
            !isLoggedIn -> {
                navController.navigate(
                    R.id.action_splash_to_login,
                    null,
                    NavOptions.Builder()
                        .setPopUpTo(R.id.splashFragment, true)
                        .build()
                )
            }
            !isVerified -> {
                navController.navigate(
                    R.id.action_splash_to_verify,
                    null,
                    NavOptions.Builder()
                        .setPopUpTo(R.id.splashFragment, true)
                        .build()
                )
            }
            else -> {
                navController.navigate(
                    R.id.action_splash_to_home,
                    null,
                    NavOptions.Builder()
                        .setPopUpTo(R.id.splashFragment, true)
                        .build()
                )
            }
        }
    }
}

你看,每个跳转都加了 popUpTo,确保 SplashFragment 被清理掉。这样用户按返回键就不会回到启动页了。

接下来是登录成功后的处理:

// LoginFragment.kt
fun onLoginSuccess() {
    findNavController().navigate(
        R.id.action_login_to_home,
        null,
        NavOptions.Builder()
            .setPopUpTo(R.id.loginFragment, true)
            .setLaunchSingleTop(true)
            .build()
    )
}

这里我同时用了 popUpToInclusive = truelaunchSingleTop = true。前者清理登录页,后者防止首页被重复创建。

10.5 知识体系总览

说了这么多,我们用一个图来总结本章的核心逻辑:

条件导航与导航选项核心逻辑 业务条件判断 未登录 未实名 已实名 LoginFragment VerifyFragment HomeFragment 导航选项配置 • popUpTo:指定清理到哪个页面 • popUpToInclusive:是否连目标页面一起清理 • launchSingleTop:避免栈顶重复创建 • 动态目标:根据业务逻辑在代码中选择 destination ID

10.6 避坑指南

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

  • popUpTo 的 ID 写错了:如果你指定的 Fragment 不在返回栈里,popUpTo 不会报错,但也不会生效。我建议在导航图里给每个 Fragment 加上明确的 ID,不要用自动生成的。
  • launchSingleTop 和 popUpTo 的顺序:先执行 popUpTo,再执行跳转。所以如果你要清理栈顶的 Fragment,popUpToInclusive 设为 true 是有效的。
  • 动态跳转时不要忘了 Safe Args:如果你用 Safe Args 传递参数,动态跳转时没法用 action 传参。这时候可以用 Bundle 手动传。
  • Fragment 重建后的状态恢复launchSingleTop 复用的是 onCreateView 之后的实例,但 onSaveInstanceState 保存的数据还在。如果你需要刷新数据,可以在 onResume 里处理。

一句话总结:条件导航的核心是「动态选择目标 + 合理清理返回栈」。popUpTo 管历史,launchSingleTop 管重复,两者配合,导航才能稳如老狗。


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