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 穷举检查永远可靠
密封类 = 编译器保证的穷举检查 + 类型安全的智能转换。这是普通继承做不到的。
密封类构建递归数据结构
密封类还有一个很酷的用法——构建递归数据结构,比如数学表达式:
sealed class Expr
data class Num(val value: Int) : Expr()
data class Add(val left: Expr, val right: Expr) : Expr()
data class Mul(val left: Expr, val right: Expr) : Expr()
fun eval(expr: Expr): Int = when (expr) {
is Num -> expr.value
is Add -> eval(expr.left) + eval(expr.right)
is Mul -> eval(expr.left) * eval(expr.right)
}
// 计算 (2 + 3) * 4
val expression = Mul(Add(Num(2), Num(3)), Num(4))
println(eval(expression)) // 20
每个 Expr 的子类的 left 和 right 又是 Expr——这就是递归。密封类让这种"有限种组合"的树形结构变得类型安全又易于扩展。
小结:什么时候用密封类?
记住这个判断标准:
- 状态/类型是有限的 → 考虑密封类
- 每种状态需要携带不同的数据 → 必须用密封类(枚举做不到)
- 需要编译器帮你检查分支完整性 → 密封类 + when 是最佳搭档
- 只是几个简单常量,不需要不同数据 → 枚举类就够了
常见使用场景:
- UI 状态建模(Loading / Success / Error / Empty)
- 网络请求结果封装
- 导航事件定义
- 业务状态机
- 递归数据结构(表达式树、JSON 节点等)
密封类是 Kotlin 类型系统里最实用的特性之一。它让你把"运行时的 if-else 判断"变成"编译时的类型检查",把"可能漏掉的分支"变成"编译器强制你处理的情况"。写 Kotlin 如果不用密封类,就像有了瑞士军刀却只用它开啤酒——太浪费了。
今日挑战
把你项目中的一个"用枚举 + 可空字段表示的状态"重构为密封类。比如:
// 重构前
enum class DownloadState { PENDING, DOWNLOADING, DONE, FAILED }
data class Task(
val state: DownloadState,
val progress: Int? = null,
val errorMessage: String? = null
)
// 重构后——试试 sealed class
写完之后,感受一下是不是少了很多 if (state == XXX) 和 ?.let 的判断?欢迎在评论区分享你的重构前后对比!