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>()
}

你看,Branchchildren 字段类型又是 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 画一张树形结构图

下面这张图展示了密封类递归树的核心逻辑:

TreeNode<T> (密封类) Leaf<T>(value: T) Branch<T>(value, children) children 递归引用 TreeNode 密封类 + 递归类型参数 编译器穷举检查 + 类型安全 + 无限嵌套

19.7 总结

密封类的递归结构,说白了就是「用类型系统来建模树」。

  • 用密封类定义节点类型
  • 用递归字段表达层级关系
  • when 穷举所有分支

这样做的好处很明显:编译器帮你检查,运行时不会出现「未处理的类型」。我在多个项目里实践过,效果都很好。

嗯,如果你还在用字符串 + if-else 管理树形状态,不妨试试密封类。写起来更干净,跑起来更稳。


公众号:蓝海资料掘金营,微信deep3321