14. 密封类与协程:构建响应式状态流

状态管理,说白了就是解决「数据从哪里来、到哪里去、中间怎么变」的问题。在 Android 里,我们跟协程打交道久了就会发现——光有 Flow 还不够,你得有个好用的「状态容器」。密封类在这里扮演的角色,比你想的要重要得多。

我个人习惯把密封类当作「状态契约」。它告诉整个团队:这个组件可能处于哪些状态,每个状态带什么数据。协程负责异步流动,密封类负责定义流动的「形状」。两者一结合,响应式状态流就变得清晰可控。

14.1 为什么是密封类 + 协程?

先看一个常见场景:加载用户信息。

如果用普通类或枚举,你可能会写出这样的代码:

// 不推荐:用 Int 常量表示状态
const val STATE_LOADING = 0
const val STATE_SUCCESS = 1
const val STATE_ERROR = 2

data class UiState(
    val state: Int = STATE_LOADING,
    val data: User? = null,
    val errorMsg: String? = null
)

这有什么问题?状态和数据的绑定是松散的。你没法在编译期保证「STATE_SUCCESS 时 data 不为 null」,也没法防止「STATE_LOADING 时 errorMsg 有值」。运行时全靠开发者自觉,出 bug 是迟早的事。

用密封类重构一下:

sealed class UserUiState {
    object Loading : UserUiState()
    data class Success(val user: User) : UserUiState()
    data class Error(val message: String) : UserUiState()
}

每个状态只携带它需要的数据。Loading 不需要 user,Success 不需要 errorMsg。编译器帮你兜底——不合法状态根本构造不出来

核心观点:密封类让状态空间变得「可枚举、可穷举」。协程让状态变化变得「可观察、可响应」。两者结合,就是响应式状态流的基石。

14.2 用 Flow + 密封类构建状态流

我在项目中遇到过这样的需求:一个搜索页面,需要展示「空闲」「加载中」「有结果」「无结果」「错误」五种状态。用密封类定义好状态后,配合 MutableStateFlow 就能轻松驱动 UI。

sealed class SearchState {
    object Idle : SearchState()
    object Loading : SearchState()
    data class Results(val items: List<SearchItem>) : SearchState()
    object Empty : SearchState()
    data class Error(val message: String) : SearchState()
}

class SearchViewModel : ViewModel() {
    private val _state = MutableStateFlow<SearchState>(SearchState.Idle)
    val state: StateFlow<SearchState> = _state.asStateFlow()

    fun search(query: String) {
        viewModelScope.launch {
            _state.value = SearchState.Loading
            try {
                val result = repository.search(query)
                _state.value = if (result.isEmpty()) {
                    SearchState.Empty
                } else {
                    SearchState.Results(result)
                }
            } catch (e: Exception) {
                _state.value = SearchState.Error(e.message ?: "未知错误")
            }
        }
    }
}

UI 层只需要 collect 这个 StateFlow,然后用 when 表达式穷举所有分支:

viewModel.state.collect { state ->
    when (state) {
        is SearchState.Idle -> { /* 显示初始提示 */ }
        is SearchState.Loading -> { /* 显示加载动画 */ }
        is SearchState.Results -> { /* 显示列表 */ }
        is SearchState.Empty -> { /* 显示"无结果"占位 */ }
        is SearchState.Error -> { /* 显示错误提示 */ }
    }
}

你想想看,如果这里用 if-else 或者 switch-case 处理 Int 常量,漏掉一个状态就是运行时崩溃。但用密封类 + when,编译器会强制你处理所有分支。这就是「穷举性检查」带来的安全感。

14.3 内联类优化状态携带的数据

有时候状态里携带的数据很简单,比如一个 ID 或者一个布尔值。直接用 data class 有点「杀鸡用牛刀」,而且会有装箱开销。内联类在这里可以派上用场。

sealed class PlayerState {
    object Idle : PlayerState()
    data class Playing(val trackId: TrackId) : PlayerState()
    data class Paused(val trackId: TrackId, val position: PositionMs) : PlayerState()
}

@JvmInline
value class TrackId(val value: String)

@JvmInline
value class PositionMs(val value: Long)

内联类在编译期会被替换为原始类型,没有额外对象分配。我在做音乐播放器时用过这个模式,状态流转频繁,减少对象创建对性能有明显帮助。

小技巧:内联类适合包装「单一值」的类型,比如 ID、时间戳、枚举值。如果状态需要携带多个字段,还是用 data class 更合适。

14.4 处理「多事件」场景

状态流适合描述「当前是什么」,但有些场景需要表达「发生了什么」。比如:导航到详情页、弹出 Snackbar、触发一次震动。这些是一次性事件,不应该被重复消费。

我建议用密封类定义事件,配合 Channel 或 SharedFlow 来传递:

sealed class UiEvent {
    data class NavigateToDetail(val id: String) : UiEvent()
    data class ShowSnackbar(val message: String) : UiEvent()
    object GoBack : UiEvent()
}

class MainViewModel : ViewModel() {
    private val _events = Channel<UiEvent>(Channel.BUFFERED)
    val events: Flow<UiEvent> = _events.receiveAsFlow()

    fun onItemClick(id: String) {
        viewModelScope.launch {
            _events.send(UiEvent.NavigateToDetail(id))
        }
    }
}

UI 层用 Lifecycle.repeatOnLifecycle 收集事件,确保每个事件只被处理一次:

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.events.collect { event ->
            when (event) {
                is UiEvent.NavigateToDetail -> { /* 导航 */ }
                is UiEvent.ShowSnackbar -> { /* 显示 Snackbar */ }
                is UiEvent.GoBack -> { /* 返回 */ }
            }
        }
    }
}
注意:不要把一次性事件放到 StateFlow 里。StateFlow 会保留最新值,屏幕旋转后重新 collect 会再次触发事件,导致重复导航或重复弹窗。Channel 或 SharedFlow 配合 replay=0 才是正确选择。

14.5 状态合并与组合

实际项目中,一个页面往往依赖多个数据源。比如:用户信息 + 设置项 + 网络状态。用密封类分别定义每个子状态,然后用 combine 合并成页面级状态。

sealed class UserState {
    object Loading : UserState()
    data class LoggedIn(val user: User) : UserState()
    object LoggedOut : UserState()
}

sealed class NetworkState {
    object Connected : NetworkState()
    object Disconnected : NetworkState()
}

data class PageState(
    val user: UserState,
    val network: NetworkState
)

val pageStateFlow = combine(userStateFlow, networkStateFlow) { user, network ->
    PageState(user, network)
}

UI 层拿到 PageState 后,可以同时根据用户状态和网络状态做决策。比如:用户已登录但网络断开,显示「离线模式」提示。

我曾经在一个电商项目里用过这种模式。商品列表、购物车、用户信息各自独立,但页面需要同时展示。用 combine 合并后,任何一个子状态变化,页面都会自动刷新。代码结构清晰,调试也方便。

14.6 知识体系总览

下面这张图概括了密封类与协程在状态管理中的协作关系:

密封类 + 协程:响应式状态流架构 数据源 Room / Retrofit / DataStore 协程 Flow map / combine / flatMapLatest 密封类状态 Loading / Success / Error 穷举性检查 ViewModel StateFlow / Channel 分发 UI 层 collect / when 穷举 关键特性 • 编译期状态穷举 • 类型安全的数据绑定 • 一次性事件隔离 • 多状态组合合并 • 内联类零开销包装 • 协程自动取消

14.7 避坑指南

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

  • 不要用 sealed class 的 object 携带可变状态。 object 是单例,多个地方引用会共享状态,容易出并发问题。需要可变数据就用 data class。
  • StateFlow 的初始值要慎重。 如果初始状态是 Loading,UI 一 collect 就会显示加载动画。有时候更好的做法是用 Idle 作为初始状态,等用户触发操作再切到 Loading。
  • Channel 记得设置容量。 默认是 RENDEZVOUS(无缓冲),发送方会挂起直到接收方消费。如果事件发送频率高,建议用 BUFFERED 或指定容量,避免死锁。
  • 内联类不要滥用。 它不能继承其他类,也不能被其他类继承。只适合包装「单一原始值」的场景。包装复杂对象反而会增加心智负担。

嗯,到这里你应该能感受到:密封类 + 协程的组合,不是简单的「1+1=2」。它让状态管理从「靠约定」变成了「靠编译器」。你写代码的时候,编译器会帮你检查有没有漏掉状态、有没有非法数据。这种安全感,是运行时测试给不了的。

一句话总结:用密封类定义「状态是什么」,用协程 Flow 描述「状态怎么变」,用 when 穷举处理「状态怎么用」。三者缺一不可。

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