【Android之路】 Kotlin 的 data class、enum class、sealed interface
这篇文章分两部分:
- 语言层:什么是
data class
、enum class
、sealed interface
,各自解决什么问题; - 架构层:在 Android(以 Compose 为例)如何用状态驱动 UI,把 UI 和业务逻辑分层组合起来(含完整示例代码)。
一、data class
:为“承载数据”的类而生
适用场景:对象主要用来“存数据”,几乎没复杂行为。
好处:自动生成 equals/hashCode/toString/copy/componentN
,写得少,判等、打印、复制都方便。
data class User(val id: String,val name: String,val age: Int
)val u1 = User("1", "Tom", 18)
println(u1) // User(id=1, name=Tom, age=18)
val u2 = u1.copy(age = 20) // 局部复制
println(u1 == u2) // false(值比较)
常见误区
- 在
data class
里塞大量可变状态与业务逻辑,后期难测难维护。建议:保持“哑数据”定位,让行为放到其他层(例如 use case / reducer)。
二、enum class
:一组有限常量的类型安全表达
适用场景:一组固定且离散的取值,例如运算符、方向、主题模式。
好处:与 when
搭配,分支穷举、编译期检查更安全。
enum class Op { Add, Sub, Mul, Div }fun apply(a: Int, b: Int, op: Op): Int = when (op) {Op.Add -> a + bOp.Sub -> a - bOp.Mul -> a * bOp.Div -> a / b
}
常见误区
- 用字符串/整型常量代替枚举,丢失类型约束,
when
分支漏写编译也不会提醒。
三、sealed interface
(或 sealed class
):受限层级的“代数数据类型”
适用场景:你要表达“一类事物的若干封闭变体”,常用于事件流、UI 状态、网络结果(Success/Error/Loading)。
好处:所有实现类都在同一编译单元内已知,when
能做穷举校验。
sealed interface CalcEvent {data class Digit(val ch: Char) : CalcEventdata object Dot : CalcEventdata class Operator(val op: Op) : CalcEventdata object Equals : CalcEventdata object AllClear : CalcEventdata object ToggleSign : CalcEventdata object Percent : CalcEvent
}
sealed 与 enum 的取舍
- 当“变体”只需名称(无额外字段)→
enum
; - 当“变体”需要携带不同数据(比如 Digit 要带
ch
)→sealed
更合适。
四、把语言特性带进 Android 架构:UI = f(State)
1) 思想要点
- UI 是状态的函数:UI 不直接改数据,只显示
State
; - 单向数据流:UI 发送
Event
→ 业务层(Reducer/UseCase)计算新State
→ UI 订阅并重组; - ViewModel 托管状态:抗配置变更,便于测试。
2) 我们做一个迷你“计算器”来演示
功能:数字、点、小数拼接、四则运算、等号、AC、±、%(核心);
分层:纯业务(Reducer) + ViewModel + Compose UI。
五、领域模型(data/enum/sealed 的组合拳)
// 运算符(有限集合)——用 enum
enum class Op { Add, Sub, Mul, Div }// 页面业务状态(承载数据)——用 data class
data class CalcState(val display: String = "0",val leftOperand: String? = null,val op: Op? = null,val inTypingRight: Boolean = false,val hasResult: Boolean = false,val error: Boolean = false
)// 事件流(变体携带不同数据)——用 sealed interface
sealed interface CalcEvent {data class Digit(val ch: Char) : CalcEventdata object Dot : CalcEventdata class Operator(val op: Op) : CalcEventdata object Equals : CalcEventdata object AllClear : CalcEventdata object ToggleSign : CalcEventdata object Percent : CalcEvent
}
六、业务核心(Reducer:Event -> State),纯 Kotlin、可单测
只展示骨架,细节同理扩展;它不依赖 Android/Compose,测试更轻松。
fun reduce(state: CalcState, event: CalcEvent): CalcState = when (event) {is CalcEvent.Digit -> onDigit(state, event.ch)CalcEvent.Dot -> onDot(state)is CalcEvent.Operator -> onOperator(state, event.op)CalcEvent.Equals -> onEquals(state)CalcEvent.AllClear -> CalcState()CalcEvent.ToggleSign -> onToggleSign(state)CalcEvent.Percent -> onPercent(state)
}private fun onDigit(state: CalcState, ch: Char): CalcState { /* 处理前导0、结果后重启输入…… */ return state }
private fun onDot(state: CalcState): CalcState { /* 保证只出现一个 '.' */ return state }
private fun onOperator(state: CalcState, op: Op): CalcState { /* 固化左操作数/替换运算符/连算 */ return state }
private fun onEquals(state: CalcState): CalcState { /* 左右齐备→计算→结果或错误 */ return state }
private fun onToggleSign(state: CalcState): CalcState { /* 切换 +/-,统一 -0→0 */ return state }
private fun onPercent(state: CalcState): CalcState { /* 当前显示 ÷ 100 */ return state }
关键点:所有规则都在 Reducer,UI 不参与计算、只发事件。
七、ViewModel:Android 世界与纯业务的“桥”
class CalculatorViewModel : ViewModel() {var state by mutableStateOf(CalcState()) // Compose 可观察private setfun onEvent(event: CalcEvent) {state = reduce(state, event)}
}
- 为什么放 VM? 旋转/后台回来不丢状态;UI 订阅
state
就能自动刷新。 - 你也可以用
StateFlow<CalcState>
,更贴近 MVI。
八、UI(Jetpack Compose):数据驱动、无业务
1) UI 模型(非业务)
把键盘当成数据来渲染,data class
承载 UI 信息:
enum class KeyKind { Digit, Operator, Action, Equals }data class KeySpec(val label: String,val kind: KeyKind,val op: Op? = null,val span: Int = 1 // “0” 占两格
)
2) 键盘蓝图(数据驱动布局)
val KEYS: List<List<KeySpec>> = listOf(listOf(KeySpec("AC", KeyKind.Action), KeySpec("+/-", KeyKind.Action), KeySpec("%", KeyKind.Action), KeySpec("÷", KeyKind.Operator, Op.Div)),listOf(KeySpec("7", KeyKind.Digit), KeySpec("8", KeyKind.Digit), KeySpec("9", KeyKind.Digit), KeySpec("×", KeyKind.Operator, Op.Mul)),listOf(KeySpec("4", KeyKind.Digit), KeySpec("5", KeyKind.Digit), KeySpec("6", KeyKind.Digit), KeySpec("−", KeyKind.Operator, Op.Sub)),listOf(KeySpec("1", KeyKind.Digit), KeySpec("2", KeyKind.Digit), KeySpec("3", KeyKind.Digit), KeySpec("+", KeyKind.Operator, Op.Add)),listOf(KeySpec("0", KeyKind.Digit, span = 2), KeySpec(".", KeyKind.Action), KeySpec("=", KeyKind.Equals))
)
3) UI 组件(无业务,只显示+上报)
@Composable
fun CalculatorScreen(vm: CalculatorViewModel) {Column(Modifier.padding(12.dp)) {DisplayArea(vm.state.display)Keypad(KEYS) { spec ->keySpecToEvent(spec)?.let(vm::onEvent)}}
}@Composable fun DisplayArea(text: String) = Text(text, fontSize = 40.sp, textAlign = TextAlign.End, modifier = Modifier.fillMaxWidth())@Composable fun Keypad(keys: List<List<KeySpec>>, onKey: (KeySpec) -> Unit) {Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {keys.forEach { row -> KeyRow(row, onKey) }}
}
@Composable fun KeyRow(row: List<KeySpec>, onKey: (KeySpec) -> Unit) {Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {row.forEach { spec -> KeyButton(spec, onKey, Modifier.weight(spec.span.toFloat())) }}
}
@Composable fun KeyButton(spec: KeySpec, onClick: (KeySpec) -> Unit, modifier: Modifier = Modifier) {Button(onClick = { onClick(spec) }, modifier = modifier.padding(4.dp)) {Text(spec.label, fontSize = 22.sp)}
}
4) UI→事件的映射(把 KeySpec 转成业务事件)
fun keySpecToEvent(spec: KeySpec): CalcEvent? = when (spec.kind) {KeyKind.Digit -> spec.label.singleOrNull()?.takeIf { it.isDigit() }?.let { CalcEvent.Digit(it) }KeyKind.Action -> when (spec.label) { "AC" -> CalcEvent.AllClear; "+/-" -> CalcEvent.ToggleSign; "%" -> CalcEvent.Percent; "." -> CalcEvent.Dot; else -> null }KeyKind.Operator-> spec.op?.let { CalcEvent.Operator(it) }KeyKind.Equals -> CalcEvent.Equals
}
注意:UI 只做映射和上报,不做任何数值计算。
九、组合到 Activity(不依赖 compose 的 viewModel()
扩展也能用)
class MainActivity : ComponentActivity() {private val vm: CalculatorViewModel by viewModels() // Activity 侧获取 VMoverride fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContent {MyTheme {CalculatorScreen(vm) // 显式传入,简单稳妥}}}
}
十、为什么这种分层更“耐用”?
- 可测试:Reducer 是纯函数,直接喂事件断言新状态即可;UI 测不了业务。
- 解耦:UI 改外观不动业务;业务规则调整不改 UI。
- 稳健:状态集中在 VM,配置变更不丢;事件是封闭集合(
sealed
),规则覆盖清晰。 - 可扩展:想加“历史记录/科学计算/横屏双列”,改 UI 蓝图或扩展事件与状态即可。
十一、单元测试示例(Reducer)
class CalcReducerTest {@Test fun `12 plus 3 equals 15`() {var s = CalcState()s = reduce(s, CalcEvent.Digit('1'))s = reduce(s, CalcEvent.Digit('2'))s = reduce(s, CalcEvent.Operator(Op.Add))s = reduce(s, CalcEvent.Digit('3'))s = reduce(s, CalcEvent.Equals)assertEquals("15", s.display)}
}
十二、常见坑位与对策
-
把 UI 和业务搅在一起 → 无法单测。
- 对策:业务写进 Reducer/UseCase,UI 只发事件、订阅状态。
-
浮点误差 →
0.1 + 0.2 != 0.3
。- 对策:内部用
BigDecimal
,显示前统一format
。
- 对策:内部用
-
重复小数点/连续运算符 → “1…2”、“1++2”。
- 对策:在
onDot/onOperator
分支做约束,必要时“替换运算符”。
- 对策:在
-
等号后输入 → 结果被拼接。
- 对策:
hasResult
标记,等号后第一位数字重启输入。
- 对策:
-
除 0 → 崩溃或 NaN。
- 对策:
safeCompute
捕获并进入错误态,数字键恢复。
- 对策:
结语
data class
、enum class
、sealed interface
是 Kotlin 建模的“三剑客”。- 在 Android(Compose)里,结合 State → UI 与 Event → Reducer → State 的单向数据流,用它们可以写出清晰、可测、可维护的 App。
- 这套模式不仅适用于计算器,也适用于表单、列表筛选、复杂对话框、甚至多步骤流程。