实战:基于密封类的导航状态管理
导航状态管理,说白了就是解决「用户点了个按钮,接下来该显示什么页面」的问题。听起来简单,但我在项目中见过太多因为导航状态混乱导致的崩溃——比如 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 创建逻辑。这就是密封类带来的「编译期安全」。
带参数的导航
实际项目中,很多页面需要携带复杂参数。比如从商品列表跳到商品详情,除了 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 是否发射了正确的事件:
@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 代码就能验证导航逻辑。这就是我为什么极力推荐密封类导航——它让测试变得简单到令人发指。
好了,关于密封类导航状态管理,核心思路就是这些。记住:用密封类定义状态,用 SharedFlow 传递事件,用 when 表达式处理逻辑。这套模式我在多个生产项目中验证过,稳定可靠,值得一试。
公众号:蓝海资料掘金营,微信deep3321