实战:基于密封类的多状态页面管理

好,咱们今天来点真格的。

前面讲了那么多密封类、内联类的理论,你可能觉得「嗯,懂了,但到底怎么用?」。这一章,我们就拿一个真实项目中的场景来练手——多状态页面管理。

说白了,就是一个页面在不同时刻要展示不同内容:加载中、空数据、网络错误、内容正常……你想想看,这种需求几乎每个 App 都有。我早期做项目时,习惯用一堆布尔值来控制:isLoadingisEmptyisError……结果呢?维护起来简直是一场噩梦。

为什么需要密封类来管理页面状态?

先说说传统做法的问题。

假设你有三个状态:加载中、成功、失败。很多人会这样写:

data class UiState(
    val isLoading: Boolean = false,
    val data: List<Item>? = null,
    val error: String? = null
)

看起来挺简洁对吧?但实际用起来,你会发现:

  • isLoading = truedata != null 同时出现怎么办?
  • error 不为空但 data 也有值,该展示哪个?
  • 新增一个「空数据」状态,又得加一个布尔值

我在项目中遇到过这种「状态爆炸」的情况。一个页面十几个布尔值,改一个状态要检查七八个地方,稍不留神就出 bug。

密封类解决的就是这个问题——它让状态互斥,不可能出现「既加载中又成功」的非法组合。

第一步:定义密封类

我们先定义一个通用的页面状态密封类:

sealed class PageState<out T> {
    object Loading : PageState<Nothing>()
    data class Success<T>(val data: T) : PageState<T>()
    data class Error(val message: String, val throwable: Throwable? = null) : PageState<Nothing>()
    object Empty : PageState<Nothing>()
}

这里我用了泛型 T,这样 Success 状态可以携带任意类型的数据。Loading、Error、Empty 不需要数据,所以用 Nothing 作为类型参数。

小技巧: 把 Empty 单独作为一个状态,而不是用 Success(emptyList()) 来表示。为什么?因为空数据页面的 UI 布局通常和正常内容页完全不同,单独处理更清晰。

第二步:在 ViewModel 中使用

接下来,我们在 ViewModel 里用这个密封类:

class HomeViewModel : ViewModel() {
    private val _uiState = MutableStateFlow<PageState<List<Article>>>(PageState.Loading)
    val uiState: StateFlow<PageState<List<Article>>> = _uiState.asStateFlow()

    fun loadArticles() {
        viewModelScope.launch {
            _uiState.value = PageState.Loading
            try {
                val result = repository.fetchArticles()
                if (result.isEmpty()) {
                    _uiState.value = PageState.Empty
                } else {
                    _uiState.value = PageState.Success(result)
                }
            } catch (e: Exception) {
                _uiState.value = PageState.Error(
                    message = "加载失败,请稍后重试",
                    throwable = e
                )
            }
        }
    }
}

你看,状态流转非常清晰:Loading → Success / Empty / Error。不可能出现「又加载中又成功」的情况。编译器帮我们保证了这一点。

核心要点: 密封类的每个子类都是互斥的。一个 PageState 实例,要么是 Loading,要么是 Success,不可能同时是两者。这就是类型安全的状态管理。

第三步:在 Activity/Fragment 中渲染

到了 UI 层,我们用 when 表达式来匹配状态:

lifecycleScope.launch {
    viewModel.uiState.collect { state ->
        when (state) {
            is PageState.Loading -> {
                binding.loadingView.visibility = View.VISIBLE
                binding.contentView.visibility = View.GONE
                binding.errorView.visibility = View.GONE
                binding.emptyView.visibility = View.GONE
            }
            is PageState.Success -> {
                binding.loadingView.visibility = View.GONE
                binding.contentView.visibility = View.VISIBLE
                binding.errorView.visibility = View.GONE
                binding.emptyView.visibility = View.GONE
                adapter.submitList(state.data)
            }
            is PageState.Error -> {
                binding.loadingView.visibility = View.GONE
                binding.contentView.visibility = View.GONE
                binding.errorView.visibility = View.VISIBLE
                binding.emptyView.visibility = View.GONE
                binding.errorMessage.text = state.message
            }
            is PageState.Empty -> {
                binding.loadingView.visibility = View.GONE
                binding.contentView.visibility = View.GONE
                binding.errorView.visibility = View.GONE
                binding.emptyView.visibility = View.VISIBLE
            }
        }
    }
}

这里有个细节:when 必须是 穷举的。如果你漏掉了某个状态,编译器会直接报错。这比用 if-else 判断布尔值安全得多。

我曾经接手过一个项目,里面有个页面状态用五个布尔值控制,结果有个 bug 是「加载中时点击重试,页面卡死」。排查了半天,发现是某个状态组合没处理。如果用密封类,这种问题根本不会出现。

第四步:用内联类优化状态携带的数据

有时候,Success 状态里除了数据,还需要携带一些额外信息,比如「上次刷新时间」。我们可以用内联类来包装:

@JvmInline
value class RefreshInfo(val timestamp: Long)

sealed class PageState<out T> {
    object Loading : PageState<Nothing>()
    data class Success<T>(
        val data: T,
        val refreshInfo: RefreshInfo = RefreshInfo(System.currentTimeMillis())
    ) : PageState<T>()
    // ... 其他状态
}

内联类的好处是:它在运行时不会创建额外的对象,性能开销为零。同时,它让代码语义更清晰——RefreshInfo 明确表示这是一个时间戳,而不是随便一个 Long 值。

注意: 内联类不能用于所有场景。它只能包装一个属性,而且不能有 init 块。如果你需要包装多个值,还是用 data class 吧。

知识体系总览

下面这张图展示了本章的核心逻辑:

基于密封类的多状态页面管理 密封类 PageState Loading Success<T> Error Empty ViewModel: StateFlow<PageState<List<Article>>> 状态流转:Loading → Success / Empty / Error UI 层:when(state) { is Loading -> ... is Success -> ... } 穷举匹配,编译器保证所有状态都被处理

避坑指南

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

  • 不要用 sealed class 替代 data class:密封类管理状态,data class 管理数据。两者各司其职。
  • 状态不要嵌套太深:比如 PageState.Success.SubLoading 这种设计,会让代码变得复杂。保持扁平化。
  • Error 状态要携带原始异常:方便调试。我习惯把 throwable 设为可选参数,UI 层只展示 message,但日志里可以记录完整异常。
  • Loading 状态不要传数据:有些同学会在 Loading 里放一个旧数据,用于「加载中显示缓存」。这违背了密封类的设计初衷。缓存逻辑应该单独处理。

嗯,这一章的内容就到这里。密封类管理页面状态,说白了就是「用类型系统来约束状态」。你想想看,与其靠人脑记住「哪些布尔值不能同时为 true」,不如让编译器替你把关。这才是 Kotlin 带给我们最大的价值。

总结: 密封类 + StateFlow + when 穷举匹配,是 Android 多状态页面管理的黄金组合。代码更安全,维护更轻松,团队协作时也不怕别人乱改状态。

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