10. 密封类在UI状态管理中的应用:Loading/Success/Error模式

做Android开发这几年,我见过太多人在UI状态管理上栽跟头。最典型的就是用三个布尔值来控制页面状态——isLoadingisSuccessisError。你想想看,这三个布尔值组合起来有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=trueisSuccess=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携带异常:保留原始异常信息,方便调试和上报
我的习惯: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状态的核心逻辑:

密封类 UI 状态管理核心逻辑 sealed class UiState<T> object Loading 无数据,显示加载动画 data class Success 携带数据,显示内容 data class Error 携带错误信息,显示错误页 状态转换规则 初始状态 Loading Success / Error 关键优势 编译器穷举检查 | 状态互斥 | 类型安全 | 无需手动同步

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 -> /* 显示错误 */ }
        )
    }
}

不过我个人不太推荐这种做法。原因有二:一是Resultfold嵌套在when里,可读性下降;二是Result不支持携带额外信息(比如错误码)。

10.8 避坑指南

最后分享几个实战中容易踩的坑:

  • 不要用枚举代替密封类:枚举不能携带数据,每个状态只能是一个单例。需要携带数据时,密封类是唯一选择。
  • 注意LiveData的粘性事件:如果页面重建,LiveData会重新发射旧值。这时候如果状态是Error,可能会重复显示错误弹窗。解决方案是用SingleLiveEventEventWrapper
  • 状态不要嵌套太深:我见过有人把UiState.Success里再套一层密封类。没必要,保持扁平结构就好。

核心要点:密封类 + when穷举 = 编译器帮你保证状态处理的完整性。这是Kotlin相比Java在UI状态管理上最大的优势之一。

嗯,这套模式我用了三年,从没出过状态相关的bug。你可以在下一个项目里试试看。