19. 密封类的递归结构:构建树形状态模型
树形结构,在 Android 开发里太常见了。
文件系统、组织架构、UI 嵌套层级……几乎每个项目都会遇到。但怎么用 Kotlin 的类型系统优雅地表达它?
我个人习惯用密封类来做。为什么?因为编译器能帮我检查所有分支,少写一堆 if-else。
19.1 树形结构的本质
树,说白了就是「一个节点,要么是叶子,要么是枝干」。
枝干下面还能挂节点,这就形成了递归。
用 Kotlin 表达,最直接的方式就是密封类:
sealed class TreeNode<out T> {
data class Leaf<T>(val value: T) : TreeNode<T>()
data class Branch<T>(
val value: T,
val children: List<TreeNode<T>>
) : TreeNode<T>()
}
你看,Branch 的 children 字段类型又是 TreeNode<T>。这就是递归。
我在项目中遇到过一个问题:有人用接口 + 抽象类来实现树,结果每次新增节点类型都要改好几个地方。密封类就不会,编译器直接告诉你哪里漏了。
19.2 为什么不用枚举或接口?
你可能会问:枚举不行吗?
嗯,枚举不能携带数据。你想想看,一个文件节点,总得有个文件名、大小吧?枚举做不到。
接口呢?接口可以,但 when 表达式没法穷举检查。你少写一个分支,编译器不会报错,运行时直接崩。
密封类刚好卡在中间——既能携带数据,又能穷举分支。
| 方案 | 携带数据 | 穷举检查 | 扩展性 |
|---|---|---|---|
| 枚举 | ❌ | ✅ | 差 |
| 接口 | ✅ | ❌ | 好 |
| 密封类 | ✅ | ✅ | 好 |
19.3 实战:文件系统树
拿文件系统举例。一个文件,要么是普通文件,要么是文件夹。文件夹里可以再装文件或文件夹。
sealed class FileNode {
data class File(
val name: String,
val size: Long,
val extension: String
) : FileNode()
data class Directory(
val name: String,
val children: List<FileNode>
) : FileNode()
}
遍历这棵树,用 when 表达式:
fun printTree(node: FileNode, indent: String = "") {
when (node) {
is FileNode.File -> {
println("${indent}📄 ${node.name} (${node.size} bytes)")
}
is FileNode.Directory -> {
println("${indent}📁 ${node.name}/")
node.children.forEach { child ->
printTree(child, "$indent ")
}
}
}
}
这里有个细节:when 不需要 else 分支。因为密封类只有两个子类,编译器知道你已经覆盖全了。
when 里加了 else,反而会失去编译检查的好处。别偷懒,让编译器帮你兜底。
19.4 状态管理中的树形模型
在 Android 里,树形状态模型最常见的场景就是 UI 嵌套层级。
比如一个复杂的表单,有分组、有输入项、有开关:
sealed class FormItem {
data class Section(val title: String, val items: List<FormItem>) : FormItem()
data class TextInput(val label: String, val value: String) : FormItem()
data class Switch(val label: String, val isOn: Boolean) : FormItem()
data class Button(val text: String, val onClick: () -> Unit) : FormItem()
}
渲染的时候,递归遍历:
@Composable
fun RenderForm(items: List<FormItem>) {
Column {
items.forEach { item ->
when (item) {
is FormItem.Section -> {
Text(text = item.title, style = MaterialTheme.typography.h6)
RenderForm(item.items) // 递归
}
is FormItem.TextInput -> {
OutlinedTextField(value = item.value, onValueChange = { })
}
is FormItem.Switch -> {
Switch(checked = item.isOn, onCheckedChange = { })
}
is FormItem.Button -> {
Button(onClick = item.onClick) {
Text(item.text)
}
}
}
}
}
}
我曾经在一个项目里用这种方式重构了表单页面。原来用 when + 字符串判断,改完之后代码量少了 40%,而且再也没出现过「漏掉某个类型」的 bug。
19.5 递归结构的陷阱
举个例子:
// 危险!没有深度限制
fun deepPrint(node: FileNode, depth: Int = 0) {
if (depth > 10) return // 加个保护
when (node) {
is FileNode.Directory -> {
node.children.forEach { deepPrint(it, depth + 1) }
}
else -> { /* 处理文件 */ }
}
}
我曾经遇到过一个问题:后端返回的目录结构里有个循环引用(A 文件夹包含 B,B 又包含 A)。没有深度限制,直接 StackOverflow。从那以后,我所有递归遍历都加了最大深度。
19.6 用 SVG 画一张树形结构图
下面这张图展示了密封类递归树的核心逻辑:
19.7 总结
密封类的递归结构,说白了就是「用类型系统来建模树」。
- 用密封类定义节点类型
- 用递归字段表达层级关系
- 用
when穷举所有分支
这样做的好处很明显:编译器帮你检查,运行时不会出现「未处理的类型」。我在多个项目里实践过,效果都很好。
嗯,如果你还在用字符串 + if-else 管理树形状态,不妨试试密封类。写起来更干净,跑起来更稳。