这篇文章分两部分:
- 语言层:什么是
data class
、enum class
、sealed interface
,各自解决什么问题; - 架构层:在 Android(以 Compose 为例)如何用状态驱动 UI,把 UI 和业务逻辑分层组合起来(含完整示例代码)。
一、data class
:为"承载数据"的类而生
适用场景 :对象主要用来"存数据",几乎没复杂行为。
好处 :自动生成 equals/hashCode/toString/copy/componentN
,写得少,判等、打印、复制都方便。
kotlin
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
搭配,分支穷举、编译期检查更安全。
kotlin
enum class Op { Add, Sub, Mul, Div }
fun apply(a: Int, b: Int, op: Op): Int = when (op) {
Op.Add -> a + b
Op.Sub -> a - b
Op.Mul -> a * b
Op.Div -> a / b
}
常见误区
- 用字符串/整型常量代替枚举,丢失类型约束,
when
分支漏写编译也不会提醒。
三、sealed interface
(或 sealed class
):受限层级的"代数数据类型"
适用场景 :你要表达"一类事物的若干封闭 变体",常用于事件流、UI 状态 、网络结果(Success/Error/Loading)。
好处 :所有实现类都在同一编译单元内已知,when
能做穷举校验。
kotlin
sealed interface CalcEvent {
data class Digit(val ch: Char) : CalcEvent
data object Dot : CalcEvent
data class Operator(val op: Op) : CalcEvent
data object Equals : CalcEvent
data object AllClear : CalcEvent
data object ToggleSign : CalcEvent
data 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 的组合拳)
kotlin
// 运算符(有限集合)------用 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) : CalcEvent
data object Dot : CalcEvent
data class Operator(val op: Op) : CalcEvent
data object Equals : CalcEvent
data object AllClear : CalcEvent
data object ToggleSign : CalcEvent
data object Percent : CalcEvent
}
六、业务核心(Reducer:Event -> State),纯 Kotlin、可单测
只展示骨架,细节同理扩展;它不依赖 Android/Compose,测试更轻松。
kotlin
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 世界与纯业务的"桥"
kotlin
class CalculatorViewModel : ViewModel() {
var state by mutableStateOf(CalcState()) // Compose 可观察
private set
fun onEvent(event: CalcEvent) {
state = reduce(state, event)
}
}
- 为什么放 VM? 旋转/后台回来不丢状态;UI 订阅
state
就能自动刷新。 - 你也可以用
StateFlow<CalcState>
,更贴近 MVI。
八、UI(Jetpack Compose):数据驱动、无业务
1) UI 模型(非业务)
把键盘当成数据 来渲染,data class
承载 UI 信息:
kotlin
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) 键盘蓝图(数据驱动布局)
kotlin
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 组件(无业务,只显示+上报)
kotlin
@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 转成业务事件)
kotlin
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()
扩展也能用)
kotlin
class MainActivity : ComponentActivity() {
private val vm: CalculatorViewModel by viewModels() // Activity 侧获取 VM
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyTheme {
CalculatorScreen(vm) // 显式传入,简单稳妥
}
}
}
}
十、为什么这种分层更"耐用"?
- 可测试:Reducer 是纯函数,直接喂事件断言新状态即可;UI 测不了业务。
- 解耦:UI 改外观不动业务;业务规则调整不改 UI。
- 稳健 :状态集中在 VM,配置变更不丢;事件是封闭集合(
sealed
),规则覆盖清晰。 - 可扩展:想加"历史记录/科学计算/横屏双列",改 UI 蓝图或扩展事件与状态即可。
十一、单元测试示例(Reducer)
kotlin
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。
- 这套模式不仅适用于计算器,也适用于表单、列表筛选、复杂对话框、甚至多步骤流程。