实战:基于密封类的导航状态管理

导航状态管理,说白了就是解决「用户点了个按钮,接下来该显示什么页面」的问题。听起来简单,但我在项目中见过太多因为导航状态混乱导致的崩溃——比如 Fragment 重叠、返回栈错乱、甚至闪退。

今天我们就用密封类来彻底解决这个问题。我个人习惯把导航看作一个有限状态机,而密封类恰好是描述有限状态的最佳工具。

为什么导航状态需要密封类?

先想想看,传统的导航怎么做?

  • 用 Int 常量表示页面:val PAGE_HOME = 1
  • 用字符串表示路由:"home"
  • 用枚举表示目标:enum class Page { HOME, DETAIL }

这些方式有什么问题?

类型不安全。你传一个 PAGE_HOME 过去,接收方还得判断这个 Int 到底代表什么。更糟糕的是,如果某个页面需要携带参数(比如商品 ID),你只能塞到 Bundle 里,编译期完全无法检查。

扩展性差。每加一个页面,你就要改好几个地方:路由表、导航处理逻辑、参数解析……改漏了就崩。

我在项目中遇到过最惨的一次:因为改了一个页面的 Int 常量值,导致三个 Fragment 跳转错乱,排查了整整一个下午。从那以后,我就下定决心用密封类重构导航。

密封类导航的核心设计

我们先定义导航目标。每个目标就是一个密封类的子类:

sealed class NavTarget {

    data object Home : NavTarget()

    data class Detail(val productId: String) : NavTarget()

    data class Search(val keyword: String? = null) : NavTarget()

    data object Settings : NavTarget()

    data class WebView(val url: String, val title: String) : NavTarget()
}

嗯,这里要注意:data object 是 Kotlin 1.9 引入的语法,如果你还在用旧版本,可以用 object 代替。我个人建议升级到 1.9+,data object 自动生成 toString()equals(),调试起来方便很多。

然后定义导航事件。导航不仅仅是「跳转到某个页面」,还包含「返回」「弹出到根」「替换当前页面」等操作:

sealed class NavigationEvent {

    data class NavigateTo(val target: NavTarget) : NavigationEvent()

    data class NavigateAndClearStack(val target: NavTarget) : NavigationEvent()

    data object GoBack : NavigationEvent()

    data object PopToRoot : NavigationEvent()

    data class ReplaceCurrent(val target: NavTarget) : NavigationEvent()
}

你想想看,这样设计的好处是什么?

第一,所有导航操作都在编译期确定。你想跳转到 Detail 页面,必须传一个 productId,少传了编译器直接报错。再也不怕运行时参数缺失。

第二,导航逻辑集中管理。所有页面跳转都通过同一个事件通道,你可以在一个地方做日志、埋点、权限检查。

状态管理中的导航

在 ViewModel 里,我们把导航事件封装成 SharedFlow

class MainViewModel : ViewModel() {

    private val _navigationEvent = MutableSharedFlow<NavigationEvent>()
    val navigationEvent: SharedFlow<NavigationEvent> = _navigationEvent.asSharedFlow()

    fun onProductClicked(productId: String) {
        viewModelScope.launch {
            _navigationEvent.emit(
                NavigationEvent.NavigateTo(NavTarget.Detail(productId))
            )
        }
    }

    fun onBackPressed() {
        viewModelScope.launch {
            _navigationEvent.emit(NavigationEvent.GoBack)
        }
    }

    fun onLogout() {
        viewModelScope.launch {
            _navigationEvent.emit(
                NavigationEvent.NavigateAndClearStack(NavTarget.Home)
            )
        }
    }
}

为什么用 SharedFlow 而不是 StateFlow

因为导航事件是一次性的。你不想每次旋转屏幕都重新触发一次导航跳转。SharedFlow 默认不重放事件,正好符合这个需求。我曾经看到有人用 LiveData 做导航,结果横竖屏切换时疯狂跳转页面……嗯,那画面太美我不敢看。

Activity 中的导航处理

在 Activity 里,我们收集导航事件并执行实际的跳转:

class MainActivity : AppCompatActivity() {

    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.navigationEvent.collect { event ->
                    handleNavigation(event)
                }
            }
        }
    }

    private fun handleNavigation(event: NavigationEvent) {
        when (event) {
            is NavigationEvent.NavigateTo -> {
                navigateTo(event.target)
            }
            is NavigationEvent.NavigateAndClearStack -> {
                supportFragmentManager.popBackStackImmediate(null, POP_BACK_STACK_INCLUSIVE)
                navigateTo(event.target)
            }
            is NavigationEvent.GoBack -> {
                onBackPressedDispatcher.onBackPressed()
            }
            is NavigationEvent.PopToRoot -> {
                supportFragmentManager.popBackStackImmediate(null, POP_BACK_STACK_INCLUSIVE)
            }
            is NavigationEvent.ReplaceCurrent -> {
                supportFragmentManager.popBackStackImmediate()
                navigateTo(event.target)
            }
        }
    }

    private fun navigateTo(target: NavTarget) {
        val fragment = when (target) {
            is NavTarget.Home -> HomeFragment()
            is NavTarget.Detail -> DetailFragment.newInstance(target.productId)
            is NavTarget.Search -> SearchFragment.newInstance(target.keyword)
            is NavTarget.Settings -> SettingsFragment()
            is NavTarget.WebView -> WebViewFragment.newInstance(target.url, target.title)
        }

        supportFragmentManager.commit {
            replace(R.id.fragment_container, fragment)
            addToBackStack(target::class.qualifiedName)
        }
    }
}

注意 when 表达式。因为 NavTarget 是密封类,编译器会强制你处理所有子类。如果你新增了一个 NavTarget.Profile,编译器会报错,提醒你在这里补上对应的 Fragment 创建逻辑。这就是密封类带来的「编译期安全」。

核心原则: 导航事件用 SharedFlow 发射,导航目标用密封类定义,导航处理用 when 表达式穷举。三者缺一不可。

带参数的导航

实际项目中,很多页面需要携带复杂参数。比如从商品列表跳到商品详情,除了 productId,可能还需要来源埋点信息:

data class DetailParams(
    val productId: String,
    val source: String,  // "home_recommend" 或 "search_result"
    val campaignId: String? = null
)

sealed class NavTarget {
    // ... 其他子类
    data class Detail(val params: DetailParams) : NavTarget()
}

这样设计的好处是:参数结构清晰,新增字段不影响其他页面。我曾经接手过一个项目,所有页面的参数都塞在一个 HashMap<String, String> 里,光看代码根本不知道某个页面需要哪些参数……那维护成本,啧啧。

避坑指南

我曾经踩过的坑:
  • 不要在 ViewModel 里直接持有 Fragment 引用。导航事件应该通过事件流传递,而不是直接调用 Fragment 的方法。
  • 不要用 StateFlow 做导航。StateFlow 会重放最新值,导致屏幕旋转时重复跳转。
  • 不要在 Fragment 的 onCreate 里收集导航事件。应该在 onStart 或 onResume 里收集,避免生命周期问题。

知识体系总览

下面这张图总结了密封类导航状态管理的核心结构:

密封类导航状态管理架构 ViewModel NavigationEvent 发射 SharedFlow<NavigationEvent> emit(NavigateTo(Detail(id))) 密封类定义 sealed class NavTarget sealed class NavigationEvent 编译期类型安全 Activity collect 导航事件 when 表达式穷举 Fragment 跳转 核心优势 ✅ 编译期类型安全 ✅ 参数强校验 ✅ 导航逻辑集中 ✅ 易于测试 新增页面只需:添加 NavTarget 子类 → 在 when 中处理 → 完成 用户操作 ViewModel 发射事件 Activity 处理导航 Fragment 切换 数据流方向:用户 → ViewModel → 密封类事件 → Activity → Fragment

测试导航逻辑

密封类导航的另一个好处是:测试非常方便。因为导航事件是纯数据类,你可以在单元测试中验证 ViewModel 是否发射了正确的事件:

@Test
fun `点击商品应该发射导航到详情事件`() = runTest {
    val viewModel = MainViewModel()

    // 收集导航事件
    val events = mutableListOf<NavigationEvent>()
    val job = viewModel.navigationEvent.onEach { events.add(it) }.launchIn(this)

    // 执行操作
    viewModel.onProductClicked("product_123")

    // 验证
    assertEquals(1, events.size)
    val navigateEvent = events[0] as NavigationEvent.NavigateTo
    val detailTarget = navigateEvent.target as NavTarget.Detail
    assertEquals("product_123", detailTarget.params.productId)

    job.cancel()
}

你看,不需要 Mock Fragment,不需要启动 Activity,纯 Kotlin 代码就能验证导航逻辑。这就是我为什么极力推荐密封类导航——它让测试变得简单到令人发指。

小技巧: 如果你的项目使用了 Jetpack Navigation,可以把密封类导航事件映射到 NavController 的 navigate() 调用。这样既享受了密封类的类型安全,又保留了 Navigation 组件的动画和深层链接功能。

好了,关于密封类导航状态管理,核心思路就是这些。记住:用密封类定义状态,用 SharedFlow 传递事件,用 when 表达式处理逻辑。这套模式我在多个生产项目中验证过,稳定可靠,值得一试。


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