写 Kotlin 写久了,凡是涉及状态流转的地方,我大概率会打开一个sealed class而不是interface。刚接触那会儿挺不习惯——为什么子类必须写在同一个包里?为什么不能随便让别的模块扩展?后来连续踩了几个被else分支兜底掩盖的隐蔽 bug,才真的明白密封类不是在限制我的自由,而是在帮我把所有可能的情况提前钉死在编译期。这篇文章就聊聊密封类的核心约束、递归数据建模、智能转换与穷举检查,以及我在客户端项目里实际用下来的一些取舍心得,适合正在学 Kotlin 进阶语法、或者想改进项目里状态管理方式的同学参考。
1. else分支兜底:从一个隐蔽bug看密封类的价值
1.1 一个被漏掉的网络请求状态
先讲一个真实踩坑场景。某同事接手的旧模块里,网络请求结果用一个普通接口建模,大概是这样的:
interface HttpResult { object Loading : HttpResult data class Success(val data: String) : HttpResult data class Error(val code: Int, val message: String) : HttpResult }当时看着没什么问题,业务方也跑了挺久。直到后来需求加了一个"账号登录态失效"的状态,同事往接口下面加了一个data class Unauthorized,然后去旧页面里补对应逻辑。问题就出在这里:页面里渲染结果用的是when (result),而且大部分分支都是"处理完就 return",只有少数地方写了else -> {}兜底。因为when作为语句使用时不强制穷举,编译器在没有任何else的情况下也不会拦住他。结果就是:新增的Unauthorized状态在某个详情页里被静默忽略了,用户看到的现象是——点了重新加载按钮,页面一直转圈,没有任何错误提示,也没有跳转登录页。
这个 bug 的根子不在when写没写else,而在于选了interface这个建模工具。接口的设计目标是开放扩展,谁都可以在自己模块里加一个实现类。编译器在编译当前模块时,根本不可能知道整个项目里到底有多少个HttpResult的实现,因此它没法帮你判断"这个when是不是已经覆盖完所有情况了"。一旦覆盖不全,就只能靠else兜底;而else一出现,就把"未知分支"和"理应存在但忘写了的分支"混杂在了一起。
1.2 接口实现的穷举难题
很多人刚开始学 Kotlin 时,喜欢用enum表示状态,后来发现不够用,就自然切到interface。这两种方案各自的短板恰好互补:
| 建模方式 | 优势 | 短板 |
|---|---|---|
| enum 类 | 编译器能做穷举检查,新增枚举常量时when会提醒 | 每个常量都是同一个类,没法携带不同形状的数据 |
| interface + 实现类 | 每个实现类可以有各自的字段,灵活 | 编译器无法穷举所有实现,when必须依赖else |
| sealed class / interface | 类型集合被编译器感知,既能穷举又能携带数据 | 直接子类必须放在同一个包、同一个编译模块里 |
| data object | 无状态单例的规范表示,有toString/equals | 只能表达"没有附加数据"的情况 |
如果状态只有四五种,且每种都不带数据,用enum是最舒服的。可一旦某个状态需要携带列表、错误码、提示文案,enum 就尴尬了。接口倒是能带数据,但穷举检查彻底失效。密封类很像是把 enum 的穷举能力和接口的扩展能力揉在一起:类型集合是封闭的,但每个成员可以是一个完整的类。
所以当团队约定"所有请求状态、页面状态、插件事件都用密封类建模"之后,类似上面的 bug 几乎绝迹了。不是大家写代码变谨慎了,而是在我准备收工的时候,编译器已经把漏掉的分支怼到脸上,想视而不见都难。
2. 密封类的继承约束到底在约束什么
2.1 同一个包与同一个编译模块
密封类的核心语法很简单,一个sealed关键字,一个抽象类型:
sealed class LoadState { data object Loading : LoadState() data class Success(val data: List<Item>) : LoadState() data class Error(val code: Int, val message: String) : LoadState() }但它的继承规则和普通接口完全不同。密封类的所有直接子类,必须和密封类本身位于同一个包、同一个编译模块里。这里的编译模块通常指构建体系里的一个独立模块,也可以理解为一个单独编译的单元。
这个约束的初衷非常朴素:只有把子类限制在编译器能看到的范围内,编译器才能在做穷举检查时列出一份"完整清单"。如果你的密封类在一个模块里,实现类散落在其他模块,那编译器就无法判断某个when是否已经覆盖完所有分支。
早期 Kotlin 版本的约束更严格,直接子类甚至必须和密封类写在同一个文件里。后来版本放宽到同一个包,又补充了密封接口支持。我自己写项目时反而会刻意利用这个限制:需要对外扩展的状态绝不放进密封类,需要穷举的状态绝不拆到别的包。如果哪天同事把新状态加到了别的包,编译器直接报"子类必须位于父类同一个包"的错误,这本身就是一次代码评审。
这里有一个容易踩的认知误区:密封类只能限制"直接子类",不限制"孙子类"。也就是说,密封类下面有一个普通抽象类作为中间层,这个中间层的子类可以继续扩展。实践中确实见过有人用这种多层结构来做复杂业务的分层状态,但大多数场景没必要,倒是会让自己排查分支时多绕一层。
2.2 编译器的穷举检查原理
密封类的"杀手锏"是穷举检查。当when的 subject 类型是某个密封类时,编译器会去收集它的所有直接子类型,然后逐项比对when里的分支。只要某个子类型既没有对应的is分支、也没有相等的对象分支,并且这个when又被用作表达式(也就是有返回值),编译器就会直接报错:when expression must be exhaustive。
换个说法:密封类让你从"运行时靠 else 猜"变成"编译时靠类型清单检查"。这不是魔法,而是编译器拥有一个封闭的继承列表。刚开始用sealed class时,你可能会怕"漏写分支怎么办",实际恰恰相反,想漏都难,因为漏一个编译器就不让你编译通过。
举个直观例子。给之前的LoadState增加一个Empty状态:
sealed class LoadState { data object Loading : LoadState() data class Success(val data: List<Item>) : LoadState() data class Error(val code: Int, val message: String) : LoadState() data object Empty : LoadState() }只要项目里存在任何一个把LoadState当作when表达式主体的代码,编辑器就会在那一行报红色错误,提示Empty分支缺失。这个连锁反应特别爽:加一个状态,所有需要处理它的地方全部浮出水面,你只需要挨个决定"这里要不要处理空态"。
如果你使用的是when语句(没有返回值),Kotlin 并不强制穷举。这里我强烈建议一个项目约定:处理密封类的when,要么干脆用表达式形式,把返回值接住;要么故意写一个else -> throw IllegalStateException(...)或者注释写明"此处其余分支确实不会出现"。最怕的就是写else -> {},这等于亲手把密封类的优势又还回去了。
3. 数据类型搭配:普通类、数据类、对象与密封接口
3.1 三种声明形式怎么选
密封类内部可以有多种形式的子类型,用哪一种完全取决于这个状态需不需要携带数据:
data class:状态有多个字段时首选,比如Success(data: List<Item>, timestamp: Long)。数据类自带equals、hashCode、copy,在 UI 层比较新旧状态、做局部刷新时非常省事。object:纯单例状态,不带任何字段,比如Loading、Idle。Kotlin 新版本还引入了data object,给单例提供了规范的toString、equals实现,和object的写法区别不大,我一般直接用data object。- 普通
class:当需要自定义equals、或者类本身很复杂不希望被copy误用时,可以用普通类。但在状态建模里很少遇到,大多数情况数据类就够。
举个例子,一个常见的加载状态:
sealed class LoadState { data object Loading : LoadState() data class Success(val data: List<Item>) : LoadState() data class Error(val code: Int, val message: String) : LoadState() data class Empty(val hint: String) : LoadState() }Loading是单例,因为"正在加载"这回事没有附加数据,任何时候都相等;Success和Error用数据类,分别携带数据和错误信息;Empty虽然也带提示文案,但依然用数据类,因为它有一个可变化的字段。
我还见过一种写法,把公共字段塞到密封类父类里:
sealed class LoadState(open val message: String?) { data object Loading : LoadState(null) data class Error(override val message: String) : LoadState(message) }这可以避免子类重复声明相同的字段,但坏处是父类字段会让子类构造函数变得不明显,copy也不会自动处理父类属性。除非项目里有非常强的公共字段复用需求,否则我建议保持子类各自独立,穷举时看得更清楚。
3.2 密封接口与多继承
Kotlin 1.5 之后引入了sealed interface。密封接口和密封类最大的区别在于,一个类可以实现多个接口,而一个类只能继承一个父类。对状态建模来说,密封接口允许某个数据类既属于状态集合,又满足另一套能力接口。
举一个实际会遇到的场景:客户端里有一组下拉菜单动作事件,它们既需要被当成"事件"处理,又需要支持"可被埋点"的能力。如果用密封接口配合普通接口:
sealed interface MenuAction { data object OpenProfile : MenuAction data class OpenArticle(val articleId: String) : MenuAction data class Share(val content: String) : MenuAction }假设Share事件还需要被当作"分享追踪对象"传递给另一个通用埋点组件,可以再定义一个接口,让密封接口的子类同时实现它:
interface TrackableEvent { val eventName: String get() = this::class.simpleName ?: "unknown" } sealed interface MenuAction : TrackableEvent { data object OpenProfile : MenuAction data class OpenArticle(val articleId: String) : MenuAction data class Share(val content: String) : MenuAction }这样MenuAction的每一个子类都天然具备TrackableEvent类型,可以传给埋点收集器。如果只用sealed class,因为 Kotlin 单继承限制,你就没法同时让它继承其他能力接口了。
不过密封接口也继承了"直接实现类必须位于同一个包和模块"的约束。对跨模块的 SPI(服务提供接口)设计来说,它反而没那么合适——那些场景更需要的往往就是"允许外部模块自由扩展"。
4. 花式 when:表达式穷举与智能转换
4.1 表达式形式的穷举强制效果
密封类的真正威力不在定义阶段,而在使用阶段。当你写when表达式来消费一个密封类型时,编译器会要求你把所有子类分支都列出来,列齐了你甚至可以不用谢else:
fun describe(state: LoadState): String = when (state) { is LoadState.Loading -> "加载中" is LoadState.Success -> "成功,共 ${state.data.size} 条" is LoadState.Error -> "失败:${state.message}" is LoadState.Empty -> "暂无内容:${state.hint}" }这个when没有else分支,但你不会收到"must be exhaustive"的报错,因为四个子类型全部覆盖了。反过来,如果你删掉Empty分支,IDE 立刻标红。
这背后是一个权衡:为了换取编译器的穷举保证,你必须放弃一部分自由。但绝大多数场景下,这个自由本来也无价值——一个页面状态如果不处理"空态",那它极可能是漏逻辑,而不是故意为之。
需要注意的是when语句(不是表达式)依然允许不穷举。比如:
when (state) { is LoadState.Success -> updateList(state.data) is LoadState.Error -> showError(state.message) }这段代码能编译,Loading和Empty分支直接被忽略了。有些项目就是这么写状态处理的,后果是新增一个子类型后,不会立刻出错,直到运行到漏掉的路径才出问题。所以我更推荐在这种场景下让when返回一个值,哪怕这个值只是Unit,也能借表达式形式把穷举检查激活。
4.2 智能转换带来的字段访问简化
密封类配合智能转换(smart cast)用起来非常顺手。当你写完is LoadState.Success分支后,分支内部可以直接访问state.data,不需要再做任何强转。原因在于LoadState.Success是LoadState的具体可识别子类型,编译器在is检查成立之后,会把state的静态类型自动收窄为LoadState.Success。
这个特性在多层嵌套判断里尤其明显。比如:
fun handle(state: LoadState) { when (state) { is LoadState.Success -> { // 这里直接用 state.data,无需额外变量 refreshList(state.data) } is LoadState.Error -> { // 这里直接用 state.code / state.message logError(state.code, state.message) } LoadState.Loading -> showProgress() is LoadState.Empty -> showEmpty(state.hint) } }第一次从 Java 转过来的人往往会不习惯:不是还要(LoadState.Success) state强转吗?Kotlin 里不需要。前提是这些属性必须是val,如果你在数据类里把字段声明成var,智能转换在部分场景下会失效,因为编译器无法保证这个属性在检查之后没有被修改。
还有一种比较花哨的用法,把when分支条件合并:
fun isRecoverable(state: LoadState): Boolean = when (state) { is LoadState.Error, is LoadState.Empty -> true is LoadState.Loading -> false is LoadState.Success -> false }多个分支共享同一个返回值时可以用逗号合并。这里的智能转换不会让你在 Error 分支里访问state.message时混淆,因为两个子类型的交集里能访问的是它们各自都有的字段。不过一旦合并分支,分支内部的类型就是联合类型,只能访问公共成员。所以这种写法更适合那些不需要访问具体字段的场景。
5. 递归数据结构:用密封类实现表达式求值
5.1 语法树建模
密封类在递归数据结构上是天然的绝配。最经典的例子是表达式求值:一个表达式要么是数字,要么是两个表达式的加法,要么是两个表达式的乘法。这种"自身包含自身"的结构,用密封类建模非常干净:
sealed class Expr { data class Number(val value: Int) : Expr() data class Add(val left: Expr, val right: Expr) : Expr() data class Mul(val left: Expr, val right: Expr) : Expr() }注意Add和Mul的字段类型是Expr本身,形成递归引用。如果这里换成枚举或普通接口,你很难在类型安全的前提下写出清晰的构造方式;如果不用继承而是用多个互不相干的类,调用方又必须写一堆instanceof。密封类把"节点的类型集合"锁死在一个闭合的层级里,后续写遍历逻辑时就能依赖穷举保证。
这个例子同样演示了密封类的嵌套能力:Expr内部嵌套了Number/Add/Mul,它们都是Expr的直接子类。即使在同一个文件里嵌套声明,也完全满足密封类的约束条件。
5.2 求值器与打印器的实现
有了语法树,写求值器就是一路递归加when穷举:
fun eval(expr: Expr): Int = when (expr) { is Expr.Number -> expr.value is Expr.Add -> eval(expr.left) + eval(expr.right) is Expr.Mul -> eval(expr.left) * eval(expr.right) }再写一个把表达式还原成字符串的 render 函数:
fun render(expr: Expr): String = when (expr) { is Expr.Number -> expr.value.toString() is Expr.Add -> "${render(expr.left)} + ${render(expr.right)}" is Expr.Mul -> "${render(expr.left)} * ${render(expr.right)}" }好玩的部分来了:如果以后要加一个Subtract节点,你的改动集中在三处——定义处、eval、render。因为when是表达式形式,漏掉任何一处,编译器都会在对应函数上报"must be exhaustive"。这种"改一处模型,所有处理点都被编译器点名"的体验,是接口方案永远给不了的。
这个模式可以平移到很多场景:JSON 引擎解析 AST、规则引擎的条件树、编译器的字节码中间表示、甚至客户端里的页面路由表。凡是"节点类型固定但数量可能不断增长"的层级结构,密封类都是第一选择。
5.3 深层递归与栈溢出
必须提醒一点:递归求值在表达式深度很大时会触发StackOverflowError。我在测试一个自动生成的表达式时,写了一个深度 10000 层的嵌套加法,结果求值函数直接崩了。这不代表密封类有问题,但现实业务里如果表达式是由用户输入或远端下发的,就不能放任递归无限制地往下钻。
简单处理办法包括:在递归前检查节点深度;把递归改成显式栈迭代;或者给业务层设置一个"表达式树最大深度"的上限。我的做法是在语法树构建阶段就拦掉超深输入,而不是等到求值时才发现。
6. 客户端实战:用密封类管理页面状态
6.1 页面状态机的统一写法
在客户端开发里,我最常用密封类的地方就是页面状态管理。一个页面从进入开始,无非经历加载中、成功、失败、空态、登录失效等几种状态。用密封类建模容易得出一个高内聚的状态机:
sealed interface LoginUiState { data object Idle : LoginUiState data object Submitting : LoginUiState data class Success(val token: String) : LoginUiState data class Failure(val reason: String) : LoginUiState }UI 层只需要一个render(state: LoginUiState)函数,把所有分支穷举处理,就再也不用担心"状态组合爆炸"的问题。比如我不会在界面里同时存在isSubmitting=true、errorMessage!=null、success=true这种诡异组合,因为这些互斥状态已经由类型系统拆开了。
另一个好处是,状态之间的流转逻辑可以收敛到一个函数里。比如登录页点击提交按钮时,从Idle变成Submitting;接口返回后,从Submitting变成Success(token)或Failure(reason)。每一步的状态都不可变,不用像传统写法那样把一堆 Boolean 翻来覆去地置位。
6.2 带附带数据的状态转移
数据类还带来了copy能力,在状态转移时特别方便。比如用户列表页,下拉刷新成功后想要保留上一次筛选条件,可以这样:
data class Success( val list: List<Item>, val keyword: String, val page: Int, val hasMore: Boolean ) : LoadState()刷新成功时:
val newState = previousState.copy( list = newList, page = previousState.page + 1, hasMore = newList.isNotEmpty() )因为子类是数据类,copy免去了手写"逐个赋值"的样板代码。注意previousState必须先智能转换为Success,copy才能被调用。也就是说,只有当你确定当前状态是成功态时,才能执行这个拷贝。
6.3 反模式:把所有状态塞进一个类
我要专门点名一个常见的反面写法:用一个巨大的data class表示所有状态,内部塞满各种 Boolean 和可空字段:
data class PageUiState( val isLoading: Boolean = false, val isError: Boolean = false, val isSuccess: Boolean = false, val isEmpty: Boolean = false, val data: List<Item> = emptyList(), val errorMessage: String? = null )这种写法最大的问题是:类型上允许"isLoading=true且isSuccess=true且errorMessage=空字符串"这种没有意义的组合。你不得不在每个使用方做防御性判断,不然 UI 就可能渲染出"一边转圈一边显示列表"的鬼样子。而密封类天然不允许这种非法状态出现,因为Loading和Success是两个不可能同时成立的类型。
所以我的建议是:**互斥的状态,交给类型;共享的数据,再考虑放进公共字段。**先用密封类把"当前是哪个状态"锁死,再在具体子类里保存该状态专属的数据。这不是语法炫技,而是从根上消灭了一整类"if 条件判断错乱"的 bug。
7. 版本演进与三个实战心得
7.1 从旧版本到现在的迁移记忆
我最初接触 Kotlin 密封类时,子类必须和密封类写在同一个文件里,一个稍大的状态集合就能把一个文件撑得很长,封装性也差。后来演进成"同一个包即可",文件组织终于自由了一些。再后来加入了密封接口和data object,建模方式越来越灵活。
这些演进中,对日常代码影响最大的是data object的引入。早期处理无状态单例时只能用普通object,打日志时打印出来的是LoadState$Loading@hash,很难看;data object提供了统一的toString,打印状态时一眼就能分辨是哪个分支。如果团队项目里的 Kotlin 版本已经支持data object,建议优先用它替代无参object。
7.2 三个实战心得
第一,不要在密封类的when里随手写else。else会把新增分支的编译期提醒直接抹掉。真遇到确实"其他分支都无需处理"的场景,我宁可显式列出一个空分支体,也不写else。这样将来新增子类型时,编译器会指出所有需要决策的地方,而不是让我默默漏掉。
第二,给状态类选好不可变风格。我通常把状态子类写成data class,字段一律用val。一旦使用var,智能转换效果会打折,而且状态对象在多个协程之间传递时,可变性会带来各种奇怪的时序问题。状态是不可变的,更新状态就是创建新对象,这个心智模型简单得多。
第三,对第三方 SDK 传过来的复杂结果,不要在入口处就拆散成一堆字段。我会先把来源数据统一映射成本地密封类型,再向下分发。这样下层逻辑只依赖我们自己的类型,永远不会因为"上游新增了某种返回结果"而悄悄出错。
举个例子,曾经接一个老接口,响应里藏着十几种业务状态码。团队一开始是把状态码映射成 enum,逻辑里到处when (code),后来新增了一种"限流"码,漏改了两处判断,用户直接被错误引导。改成密封类以后,新增码需要在映射函数和所有消费方同时补分支,问题立刻暴露。这个"入口映射成密封类"的习惯,成了我后面所有客户端项目的标配。
7.3 一个我自己反复用的小技巧
最后分享一个不起眼但很受用的习惯:给密封类的when分支写注释时,不写"这个分支做了什么",而是写"这个分支为什么没有处理另外某种子类型"。因为分支本身的代码已经说明了做什么,真正容易丢失的是决策上下文。比如:
is LoadState.Empty -> { // 空态让用户自己选择是否清空筛选条件,所以这里不做自动恢复 showEmptyHint() }这类注释在三个月后回头看,价值比"显示空态提示"大得多。密封类保证了分支不会漏,但保证不了分支逻辑是否被理解;好的注释恰好能补上这一环。把这套"密封类 + 穷举 when + 状态即数据"的组合用熟,你会发现很多曾经需要用运行时判断和防御性代码解决的事,在编译阶段就已经结束了。