Kotlin 密封类实战:用 sealed class 告别 if-else 地狱的状态建模
昨天我们聊了枚举类,它非常适合表示"固定几个常量"的场景——比如星期、性别、订单状态。但你有没有遇到过这种情况:状态确实是有限的,但每个状态需要携带的数据完全不同?
来看一个你可能写过的代码:
// 用枚举表示 UI 状态——是不是感觉哪里不对?
enum class UiState {
LOADING,
SUCCESS,
ERROR
}
// 使用时不得不这样:
data class UiModel(
val state: UiState,
val data: List<String>? = null, // 只有 SUCCESS 时有用
val errorMessage: String? = null, // 只有 ERROR 时有用
val errorCode: Int? = null // 只有 ERROR 时有用
)
问题很明显:data 在 LOADING 状态下毫无意义,errorMessage 在 SUCCESS 状态下也是多余的。你不得不写一堆可空字段,然后在用的时候到处判空——这就是典型的"用枚举硬套不同数据结构"的尴尬。
今天的主角 密封类(sealed class),就是来解决这个问题的。
密封类:枚举类的"超级进化版"
密封类的核心思想很简单:把子类限定在一个固定集合里。编译器知道所有可能的子类,所以能帮你做穷举检查。
// 用密封类重新建模
sealed class UiState {
object Loading : UiState()
data class Success(val data: List<String>) : UiState()
data class Error(val message: String, val code: Int) : UiState()
}
对比一下枚举版:
| 特性 | 枚举类 | 密封类 |
|---|---|---|
| 每个状态可以有不同数据 | 不行,所有成员结构相同 | 可以,每个子类独立定义 |
| 可以有多个实例 | 不行,每个常量只有一个实例 | 可以,Success 可以创建无数个 |
| 子类可以是 data class | 不行 | 可以 |
| 子类可以是 object | 可以(类似枚举) | 可以 |
| 支持 when 穷举检查 | 支持 | 支持 |
一句话总结:枚举适合"固定几个单例常量",密封类适合"固定几种类型但每种可以有不同的数据"。
密封类 + when:编译器帮你兜底
密封类最爽的地方,就是和 when 表达式的配合——不需要 else 分支。编译器知道所有子类,如果你漏了一个,直接编译报错:
fun render(state: UiState): String = when (state) {
is UiState.Loading -> "加载中..."
is UiState.Success -> "数据:${state.data}" // 智能转换:这里 state 自动变成 Success 类型
is UiState.Error -> "错误 ${state.code}:${state.message}"
// 不需要 else!如果你新增一个子类,这里编译会报错提醒你来处理
}
注意 is UiState.Success -> 后面的 state.data——你不需要手动 as Success 强转,Kotlin 编译器会自动把 state 智能转换为 Success 类型。这就是密封类的"类型安全"威力。
如果你故意漏掉一个分支试试:
fun render(state: UiState) = when (state) {
is UiState.Loading -> "加载中"
is UiState.Success -> "成功"
// 编译错误:when 表达式没有穷尽所有分支,缺少 UiState.Error
}
编译器直接报错,不给你运行时"惊喜"的机会。
密封类也能用泛型
实际项目中,Success 状态携带的数据类型经常变化——有时是用户列表,有时是订单详情。用泛型就能搞定:
sealed class Result<out T> {
object Loading : Result<Nothing>()
data class Success<out T>(val data: T) : Result<T>()
data class Error(val message: String, val cause: Throwable? = null) : Result<Nothing>()
}
// 使用
fun fetchUser(): Result<User> {
return try {
val user = api.getUser()
Result.Success(user)
} catch (e: Exception) {
Result.Error("获取用户失败", e)
}
}
// 消费
when (val result = fetchUser()) {
is Result.Loading -> showSpinner()
is Result.Success -> showUser(result.data) // result.data 类型自动推断为 User
is Result.Error -> showError(result.message)
}
这里 out T 是协变标记,Nothing 表示"没有数据"——Loading 和 Error 不携带 T 类型的数据,所以用 Nothing(Kotlin 类型系统的最底部类型)。
sealed interface:更灵活的选择
从 Kotlin 1.5 开始,你还可以用 sealed interface。接口的好处是一个类可以实现多个接口,不受单继承限制:
sealed interface NavigationEvent {
data class ToDetail(val itemId: String) : NavigationEvent
data class ToProfile(val userId: Long) : NavigationEvent
object Back : NavigationEvent
}
// 一个类可以同时实现多个 sealed interface
data class ToDetailAndLog(val itemId: String) : NavigationEvent, Loggable
实际项目中,优先用 sealed interface,除非你的子类需要共享属性或方法。
实战:给 RecyclerView 建一个事件模型
来看一个更实际的例子——用密封类建模用户交互事件:
sealed class UserEvent {
data class Click(val itemId: Long) : UserEvent()
data class LongPress(val itemId: Long, val position: Int) : UserEvent()
data class Swipe(val direction: Direction, val itemId: Long) : UserEvent()
object ScrollEnd : UserEvent()
enum class Direction { LEFT, RIGHT }
}
fun handleEvent(event: UserEvent) = when (event) {
is UserEvent.Click -> openDetail(event.itemId)
is UserEvent.LongPress -> showContextMenu(event.itemId, event.position)
is UserEvent.Swipe -> {
when (event.direction) {
UserEvent.Direction.LEFT -> archive(event.itemId)
UserEvent.Direction.RIGHT -> markRead(event.itemId)
}
}
UserEvent.ScrollEnd -> loadMore()
}
每个事件携带不同的数据,处理逻辑清晰分明。新增一种事件类型?编译器会告诉你 handleEvent 里需要加一个新分支——再也不会出现"忘了处理某种情况"的 Bug。
密封类 vs 普通继承
你可能会问:普通抽象类也能实现类似的效果啊,为什么要用密封类?
关键区别在于封闭性:
// 普通抽象类——任何人都可以在任何地方新增子类
abstract class Shape
class Circle(val radius: Double) : Shape()
class Rectangle(val width: Double, val height: Double) : Shape()
// 别人可以随时加一个 Triangle,你的 when 就漏了
// 密封类——子类集合是固定的,编译器全知道
sealed class Shape
data class Circle(val radius: Double) : Shape()
data class Rectangle(val width: Double, val height: Double) : Shape()
// 别人无法在外部新增子类,when 穷举检查永远可靠
密封类 = 编译器保证的穷举检查 + 类型安全的智能转换。这是普通继承做不到的。