sealed + when:让非法状态无法表示
你的 UiState 里有 isLoading: Boolean、hasError: Boolean、data: List?、errorMsg: String?。这四个字段能组合出十六种状态,而你的业务只承认四种。剩下十二种 —— 「正在加载并且出错并且有数据」这种鬼东西 —— 全都是合法的、可构造的、你的 UI 必须防御的。那十二种就是你 bug 的产地。这一章教你把它们从类型里删掉:不是靠约定,不是靠注释,是让它们压根无法被写出来。
先数一数你的鬼状态
这台机器会枚举出所有组合,把「业务承认的」和「鬼状态」标出来。勾上你自己 UiState 里真实有的字段,看看数字:
五个字段 = 32 种可表示状态,业务只要 4 种。28 个鬼状态。它们的代价体现在三个地方:
- UI 层的防御性代码:
if (isLoading && !hasError)、data?.takeIf { !isLoading }—— 每一处都是在手工排除本不该存在的状态。 - 永远写不完的测试:你测了 4 种正常路径,那 28 种呢?
- 最贵的那种 bug:某次异步回调把
isLoading置回 false 但忘了清errorMsg,于是用户看到一个转圈的加载页叠着一个错误提示。而这在类型层面是完全合法的。
解法:和类型
问题的根源是你在用积类型(product type)建模一个和类型(sum type)的东西。
- 积类型 = 「而且」:
data class就是积 —— 一个 User 同时有 name 而且有 age。状态数是乘起来的(2 × 2 × 2 × 2 = 16)。 - 和类型 = 「或者」:
sealed就是和 —— 一个 UiState 要么是 Loading 要么是 Success。状态数是加起来的(1 + 1 + 1 + 1 = 4)。
UI 状态天生是「或者」,你却用「而且」去表示它 —— 于是多出来的全是垃圾。换成和类型:
sealed interface UiState {
data object Loading : UiState
data class Success(val items: List<Item>) : UiState // 数据只住在这里
data object Empty : UiState
data class Error(val msg: String) : UiState // 错误只住在这里
}
注意那两句注释 —— 这是最关键的一条军规:
① 状态携带自己的数据。items 只存在于 Success 里,别的状态想读都读不到。于是「Loading 时 data 是什么?」这个问题从根上消失了 —— 而在旧写法里,你必须回答它(通常答案是「null,但别忘了判空」)。
② when 一定要当表达式用。见下一节 —— 这是 Kotlin 最容易漏掉的一层保护。
③ 重构时先算一遍乘法。把「旧代码能表示 32 种状态,业务只要 4 种」写进 PR 描述里。这比任何布道都有说服力,因为它是一个数字。
穷尽检查:Kotlin 白送你的编译期覆盖率
fun render(state: UiState) = when (state) { // 少写一个分支 → 编译错误
Loading -> spinner()
is Success -> list(state.items) // 这里 items 不可能是 null
Empty -> emptyView()
is Error -> toast(state.msg)
}
三个月后产品说「加一个 Refreshing 状态」。你在 sealed 里加一个子类型 —— 然后编译器立刻在整个项目里把所有需要处理它的地方标红。
这就是 sealed 的真正价值:它不是「更优雅的写法」,它是一个会在未来替你干活的机器人。加一个状态,编译器就把所有遗漏的地方交给你。这在动辄几十个 UI 分支的大项目里,是救命的。
else,那会把机器人关掉when (state) {
is Success -> render(state.items)
else -> spinner() // ← 你亲手关掉了穷尽检查
}
加了 else,将来新增 Refreshing 时编译器一声不吭,用户会看到一个永远转圈的加载页。这个 bug 会在测试里活下来,因为没人想到要测「新状态」。
规则:对 sealed / enum 的 when,永远不写 else。宁可多写几个分支 —— 那几行的成本,远低于一次线上事故。真的有一堆分支处理方式相同?用逗号合并:Loading, Empty -> spinner()。
一个历史遗留的细节
老 Kotlin 里有个坑:只有 when 表达式(结果被赋值或返回)才做穷尽检查,when 语句不做。于是这段代码在几年前是合法的:
when (state) { // 当语句用:老版本里不检查穷尽!
Loading -> spinner()
is Error -> toast(state.msg)
// 漏了 Success 和 Empty,编译器一句话没有
}
好消息:这个坑已经填了。从 Kotlin 1.7 起,对 sealed / enum / Boolean 的 when 语句不穷尽也是编译错误。但如果你的团队里还有人抱着「when 一定要写成表达式才安全」的老经验 —— 那条经验现在只是风格建议了(当然它依然是好风格:表达式形态更难写出遗漏的副作用)。
「Success 但列表是空的」这种情况,以前只能在分支里嵌一层 if —— 而那一层 if 是逃出穷尽检查视线的:
// 2.1 之前
when (state) {
is Success -> if (state.items.isEmpty()) showEmpty() // 嵌套 if,逻辑藏起来了
else showList(state.items)
...
}
// 2.1 起:在 is 分支后面直接挂守卫
when (state) {
is Success if state.items.isEmpty() -> showEmpty()
is Success -> showList(state.items)
is Error if state.isRetryable -> retry()
is Error -> toast(state.msg)
Loading -> spinner()
}
所有分派逻辑回到同一个平面上,一眼看完,而且穷尽检查照常生效。这是从 Haskell/Rust 那边搬过来的守卫(guard)—— 一个 Java 至今没有的东西(Java 21 的 switch 有 when 子句,其实是同一个概念,只是名字反过来了)。
以前 when 分支里必须写全 UiState.Loading,长得让人想哭。现在在期望类型已知的位置,编译器能自己补上限定名:
when (state) {
Loading -> spinner() // 不用再写 UiState.Loading
is Success -> ...
}
纯粹的可读性改进,属于「升级即得、什么都不用改」的那类好事。
sealed class 还是 sealed interface?
默认用 sealed interface。理由:
- 它允许多重继承。一个类型可以同时属于两个 sealed 层级 —— 比如
NetworkError既是FetchError又是Retryable。用sealed class(单继承)就做不到。 - 它不带构造函数和状态,更干净。
什么时候用 sealed class?当所有子类型确实共享状态时(比如所有的 Error 都有一个 timestamp)—— 但即使这种情况,通常也可以用接口里的抽象 val 解决。
顺带两个语法点:
data object(1.9 起):没有数据的状态用它,白送一个好看的toString()(Loading而不是com.foo.UiState$Loading@3f2a1b)和正确的equals。别再用object了。- sealed 的封闭范围:子类型必须和父类型在同一个模块、同一个包里(1.5 起放宽到这个粒度,以前必须在同一文件)。这就是「sealed(密封)」的含义 —— 编译器能看全所有可能,才敢做穷尽检查。
sealed vs enum:什么时候用哪个
enum | sealed | |
|---|---|---|
| 实例数量 | 固定的几个单例 | 子类型固定,但 Success(items) 可以有无数个实例 |
| 能携带数据吗 | 只能带相同结构的数据(每个枚举值都有那几个字段) | 每个子类型带自己的数据,结构可以完全不同 |
| 穷尽 when | ✓ | ✓ |
| 典型场景 | 真正的枚举:星期、性别、日志级别、排序方向 | 状态、结果、事件、错误 —— 任何「分支之间携带不同数据」的东西 |
判据一句话:分支之间需要带不同的数据吗?需要 → sealed;不需要 → enum。
落到你的项目里
Android 上,这套建模有三个立刻能用的落点:
① UI 状态(上面讲的)。② 用户意图 / 事件:
sealed interface Intent {
data class Search(val q: String) : Intent
data object Refresh : Intent
data class Delete(val id: ItemId) : Intent
}
// reducer 是一个纯函数,穷尽 when 保证你处理了每一种意图
fun reduce(state: UiState, intent: Intent): UiState = when (intent) { ... }
如果你在用 MVI 或者 Compose 的 state hoisting —— 恭喜,你已经在这个模式里了。sealed 给它上了编译器的保险。
③ 错误:sealed interface FetchError —— 让「这个函数会怎么失败」写在类型里,而不是藏在文档里。这是第 20 章的正题。
这是 Java 这几年最实在的一次进步:record(积类型)+ sealed interface(和类型)+ switch 模式匹配,Kotlin 的三件套在 Java 21 里凑齐了:
sealed interface UiState permits Loading, Success, Error {}
record Success(List<Item> items) implements UiState {}
record Error(String msg) implements UiState {}
String render(UiState s) {
return switch (s) { // 穷尽检查 ✓
case Success v when v.items().isEmpty() -> "empty"; // 守卫 ✓
case Success v -> list(v.items());
case Error e -> toast(e.msg());
case Loading l -> "spinner";
};
}
连解构(record pattern:case Success(List items))都有。所以本章的思想不是 Kotlin 专属的 —— 如果你的项目里还有 Java 模块,同样的建模照样能做。
Kotlin 剩下的优势是写起来的分量:data object Loading : UiState 一行,Java 要三行;data class 的 copy() Java 没有。差距不再是「能不能」,而是「顺不顺手」—— 而在你每天要写几十遍的东西上,顺手就是生产力。
1. 本周就做这一件事:找出项目里最脏的那个 UI 状态类(那个有 isLoading + error + 可空 data 的),先数一数它能表示多少种状态,再重构成 sealed。把「32 → 4」这个数字写进 PR 描述。
2. 重构后统计两个数字:UI 层删掉了多少处判空和条件组合?测试少写了多少个 case?—— 这两个数字是你说服团队的弹药。
3. 全项目搜 else ->。每一处都问:主语是 sealed 或 enum 吗?如果是,删掉 else 补全分支 —— 你可能会当场发现一两个漏处理的状态。
4. 如果项目已经升到 2.1+:找一处「is X 里嵌了 if」的 when,改写成 guard condition,体会一下分派逻辑回到同一平面的清爽。
下一章是熔炼炉的最后一站:by —— 那个你只用过 by lazy 的关键字,其实是 Kotlin 最被低估的扩展点。