10. 密封类在UI状态管理中的应用:Loading/Success/Error模式
做Android开发这几年,我见过太多人在UI状态管理上栽跟头。最典型的就是用三个布尔值来控制页面状态——isLoading、isSuccess、isError。你想想看,这三个布尔值组合起来有8种状态,但实际有效的只有3种。剩下的5种是什么?是bug的温床。
我个人习惯用密封类来管理UI状态。说白了,就是把所有可能的状态定义清楚,编译器帮你兜底。今天我们就聊聊这个模式。
10.1 为什么需要Loading/Success/Error模式
先看一个反面教材。我在一个老项目里见过这样的代码:
class OldViewModel : ViewModel() {
private val _isLoading = MutableLiveData<Boolean>()
private val _isSuccess = MutableLiveData<Boolean>()
private val _errorMessage = MutableLiveData<String>()
val isLoading: LiveData<Boolean> get() = _isLoading
val isSuccess: LiveData<Boolean> get() = _isSuccess
val errorMessage: LiveData<String> get() = _errorMessage
}
问题在哪?
- 状态组合爆炸:三个LiveData互相独立,可能出现
isLoading=true且isSuccess=true的荒谬情况 - 状态同步困难:每次更新都要手动重置其他状态,漏一个就出bug
- 可读性差:UI层要写一堆
if-else判断组合
我曾经在一个电商App里踩过这个坑。商品详情页同时显示加载中动画和错误提示,用户直接懵了。从那以后,我再也不用多LiveData管理状态了。
10.2 密封类定义UI状态
用密封类重构,代码清爽多了:
sealed class UiState<out T> {
object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String, val throwable: Throwable? = null) : UiState<Nothing>()
}
这里有几个设计要点:
- 泛型
out T:保证协变,UiState<String>可以赋值给UiState<Any> Nothing类型:Loading和Error不需要数据,用Nothing表示「永远不会有值」- Error携带异常:保留原始异常信息,方便调试和上报
errorCode: Int字段。这样UI层可以根据错误码做差异化处理,比如401跳登录,500显示重试按钮。
10.3 ViewModel中的使用
ViewModel里只需要一个LiveData:
class ProductViewModel : ViewModel() {
private val _uiState = MutableLiveData<UiState<Product>>()
val uiState: LiveData<UiState<Product>> get() = _uiState
fun loadProduct(productId: String) {
_uiState.value = UiState.Loading
viewModelScope.launch {
try {
val product = repository.getProduct(productId)
_uiState.value = UiState.Success(product)
} catch (e: Exception) {
_uiState.value = UiState.Error(
message = "加载失败:${e.localizedMessage}",
throwable = e
)
}
}
}
}
注意看,状态转换是线性的:Loading → Success 或 Loading → Error。不可能出现同时Loading又Success的情况。这就是密封类的威力——编译器保证你只能选一个。
10.4 UI层响应式处理
在Activity或Fragment里,用when表达式处理状态:
viewModel.uiState.observe(viewLifecycleOwner) { state ->
when (state) {
is UiState.Loading -> {
progressBar.visibility = View.VISIBLE
errorView.visibility = View.GONE
contentView.visibility = View.GONE
}
is UiState.Success -> {
progressBar.visibility = View.GONE
errorView.visibility = View.GONE
contentView.visibility = View.VISIBLE
bindData(state.data)
}
is UiState.Error -> {
progressBar.visibility = View.GONE
contentView.visibility = View.GONE
errorView.visibility = View.VISIBLE
errorText.text = state.message
}
}
}
这里有个细节:when必须是穷举的。如果你漏掉一个分支,编译器会报错。这比if-else安全得多。
when里用else分支。用了else就失去了穷举检查的好处。我见过有人偷懒写else -> {},结果新增状态时编译器不报错,运行时直接崩。
10.5 扩展:带数据的Loading状态
有时候我们需要在加载时保留旧数据。比如下拉刷新时,页面不应该白屏。这时候可以扩展一下:
sealed class UiState<out T> {
object Loading : UiState<Nothing>()
data class LoadingWithData<T>(val cachedData: T) : UiState<T>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String, val throwable: Throwable? = null) : UiState<Nothing>()
}
UI层处理时,LoadingWithData可以显示旧数据+加载指示器:
is UiState.LoadingWithData -> {
// 显示缓存数据,同时显示加载动画
bindData(state.cachedData)
refreshIndicator.visibility = View.VISIBLE
}
这个模式在列表页特别实用。用户不会因为刷新而丢失浏览位置。
10.6 知识体系图
下面这张图总结了密封类管理UI状态的核心逻辑:
10.7 进阶:结合Result类型
如果你用Kotlin的Result类,可以进一步简化:
sealed class UiState<out T> {
object Loading : UiState<Nothing>()
data class Data<T>(val result: Result<T>) : UiState<T>()
}
// 使用
when (state) {
is UiState.Loading -> { /* 显示加载 */ }
is UiState.Data -> {
state.result.fold(
onSuccess = { data -> /* 显示数据 */ },
onFailure = { error -> /* 显示错误 */ }
)
}
}
不过我个人不太推荐这种做法。原因有二:一是Result的fold嵌套在when里,可读性下降;二是Result不支持携带额外信息(比如错误码)。
10.8 避坑指南
最后分享几个实战中容易踩的坑:
- 不要用枚举代替密封类:枚举不能携带数据,每个状态只能是一个单例。需要携带数据时,密封类是唯一选择。
- 注意LiveData的粘性事件:如果页面重建,LiveData会重新发射旧值。这时候如果状态是Error,可能会重复显示错误弹窗。解决方案是用
SingleLiveEvent或EventWrapper。 - 状态不要嵌套太深:我见过有人把
UiState.Success里再套一层密封类。没必要,保持扁平结构就好。
核心要点:密封类 + when穷举 = 编译器帮你保证状态处理的完整性。这是Kotlin相比Java在UI状态管理上最大的优势之一。
嗯,这套模式我用了三年,从没出过状态相关的bug。你可以在下一个项目里试试看。