17. 状态管理中的模式匹配:用密封类简化复杂逻辑

状态管理,说白了就是处理「当前是什么情况,接下来该干什么」。

我早期做 Android 开发时,习惯用枚举或整数常量来表示状态。比如 STATE_LOADINGSTATE_SUCCESSSTATE_ERROR。然后写一堆 if-else 或 when 分支。代码一多,就乱成一锅粥。

后来我接触到 Kotlin 的密封类,才发现——原来状态管理可以这么干净。

17.1 为什么枚举不够用?

枚举能表示「状态的名字」,但带不了「状态的附属数据」。

举个例子:网络请求的结果。

  • 加载中:不需要额外数据
  • 成功:需要返回的数据
  • 失败:需要错误信息、错误码

用枚举,你只能这样写:

enum class UiState {
    LOADING,
    SUCCESS,
    ERROR
}

然后呢?成功的数据放哪?错误信息放哪?

你只能在外面再搞一个 data class 或者用 Any? 强转。我曾经在一个项目里看到这样的代码:

when (state) {
    UiState.SUCCESS -> {
        val data = extraData as List<Item>
        // ...
    }
    UiState.ERROR -> {
        val msg = extraData as String
        // ...
    }
}

这种写法,说白了就是埋雷。哪天类型不对应,直接崩。

17.2 密封类登场

密封类(sealed class)允许你定义一组有限的子类型。每个子类型可以有自己的属性。

看这个例子:

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 code: Int = -1) : UiState<Nothing>()
}

每个状态都自带数据。Loading 不需要数据,Success 带数据,Error 带错误信息。

使用时,配合 when 表达式,编译器会帮你检查是否覆盖了所有分支:

fun render(state: UiState<List<Item>>) {
    when (state) {
        is UiState.Loading -> showLoading()
        is UiState.Success -> showData(state.data)
        is UiState.Error -> showError(state.message, state.code)
    }
}

注意这里:state.data 是自动推导出来的类型,不需要强转。这就是密封类的威力。

核心优势:密封类把「状态」和「数据」绑定在一起,编译器帮你保证类型安全。

17.3 我在项目中的实际用法

我之前做一个电商 App 的订单列表页。订单状态有十几种:待支付、已支付、已发货、已签收、退款中、已取消……

每个状态需要展示不同的 UI:

  • 待支付:显示「去支付」按钮
  • 已发货:显示物流信息
  • 退款中:显示进度条

如果用枚举 + if-else,那个 Activity 会变成几百行的怪物。

我用密封类重构后,结构清晰多了:

sealed class OrderState {
    object PendingPayment : OrderState()
    data class Paid(val orderId: String) : OrderState()
    data class Shipped(val trackingNumber: String, val company: String) : OrderState()
    data class Refunding(val progress: Float) : OrderState()
    object Cancelled : OrderState()
}

然后在 ViewHolder 里:

fun bind(state: OrderState) {
    when (state) {
        is OrderState.PendingPayment -> {
            btnPay.visibility = View.VISIBLE
            tvStatus.text = "待支付"
        }
        is OrderState.Shipped -> {
            tvTracking.text = "${state.company}: ${state.trackingNumber}"
        }
        // 其他分支...
    }
}

每个分支只处理自己的逻辑。新增一个状态,编译器会提醒你哪里没覆盖到。

小技巧:如果某个状态不需要数据,用 object 而不是 data class。这样不会创建多余的实例。

17.4 密封类 vs 密封接口

Kotlin 1.5 之后引入了 sealed interface。什么时候用哪个?

我个人习惯:

  • 如果子类型之间共享一些公共逻辑,用密封类(可以定义构造函数和属性)
  • 如果只是纯标记,不需要共享状态,用密封接口

举个例子:

sealed interface NavigationEvent {
    object GoBack : NavigationEvent
    data class OpenDetail(val id: String) : NavigationEvent
    data class ShowToast(val message: String) : NavigationEvent
}

这里不需要公共属性,用接口更轻量。

17.5 避坑指南

我曾经犯过一个错误:在密封类的子类型里使用了 var 属性。

// 不要这样做
data class Error(var message: String) : UiState<Nothing>()

密封类通常用于表示不可变的状态。一旦创建,就不应该修改。用 val 才是正道。

另一个坑:密封类的子类型必须定义在同一个文件里。如果你把子类型分散到多个文件,编译器会报错。

注意:密封类的子类型只能定义在密封类所在的文件内。这是 Kotlin 的强制要求。

17.6 结合 Flow 使用

在 MVVM 架构中,我经常把密封类和 Kotlin Flow 结合:

class OrderViewModel : ViewModel() {
    private val _uiState = MutableStateFlow<UiState<List<Order>>>(UiState.Loading)
    val uiState: StateFlow<UiState<List<Order>>> = _uiState.asStateFlow()

    fun loadOrders() {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            try {
                val orders = repository.getOrders()
                _uiState.value = UiState.Success(orders)
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e.message ?: "未知错误")
            }
        }
    }
}

Activity 或 Fragment 里收集这个 Flow,然后根据状态渲染 UI。干净、安全、可测试。

17.7 知识体系图

下面这张图展示了密封类在状态管理中的核心位置:

密封类在状态管理中的角色 密封类 UiState Loading (object) Success (data class) Error (data class) ViewModel 发射 when 表达式匹配 UI 渲染 编译器保证:所有分支必须覆盖,类型自动推导

17.8 总结

密封类不是 Kotlin 里最炫酷的特性,但绝对是最实用的之一。

它让状态管理变得可预测、类型安全、易于扩展。你想想看,每次新增一个状态,编译器都会提醒你哪里需要处理。这种安全感,是枚举和 if-else 给不了的。

我个人建议:只要你的状态超过 3 种,或者状态需要携带数据,就优先考虑密封类。别犹豫。

一句话总结:密封类 + when 表达式 = 状态管理的黄金搭档。


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