实战:基于密封类的多状态页面管理
好,咱们今天来点真格的。
前面讲了那么多密封类、内联类的理论,你可能觉得「嗯,懂了,但到底怎么用?」。这一章,我们就拿一个真实项目中的场景来练手——多状态页面管理。
说白了,就是一个页面在不同时刻要展示不同内容:加载中、空数据、网络错误、内容正常……你想想看,这种需求几乎每个 App 都有。我早期做项目时,习惯用一堆布尔值来控制:isLoading、isEmpty、isError……结果呢?维护起来简直是一场噩梦。
为什么需要密封类来管理页面状态?
先说说传统做法的问题。
假设你有三个状态:加载中、成功、失败。很多人会这样写:
data class UiState(
val isLoading: Boolean = false,
val data: List<Item>? = null,
val error: String? = null
)
看起来挺简洁对吧?但实际用起来,你会发现:
isLoading = true且data != 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 作为类型参数。
第二步:在 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。不可能出现「又加载中又成功」的情况。编译器帮我们保证了这一点。
第三步:在 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 吧。
知识体系总览
下面这张图展示了本章的核心逻辑:
避坑指南
最后,分享几个我踩过的坑:
- 不要用 sealed class 替代 data class:密封类管理状态,data class 管理数据。两者各司其职。
- 状态不要嵌套太深:比如 PageState.Success.SubLoading 这种设计,会让代码变得复杂。保持扁平化。
- Error 状态要携带原始异常:方便调试。我习惯把 throwable 设为可选参数,UI 层只展示 message,但日志里可以记录完整异常。
- Loading 状态不要传数据:有些同学会在 Loading 里放一个旧数据,用于「加载中显示缓存」。这违背了密封类的设计初衷。缓存逻辑应该单独处理。
嗯,这一章的内容就到这里。密封类管理页面状态,说白了就是「用类型系统来约束状态」。你想想看,与其靠人脑记住「哪些布尔值不能同时为 true」,不如让编译器替你把关。这才是 Kotlin 带给我们最大的价值。
公众号:蓝海资料掘金营,微信deep3321