【Android之路】 Kotlin 的 data class、enum class、sealed interface

这篇文章分两部分:

  1. 语言层:什么是 data classenum classsealed interface,各自解决什么问题;
  2. 架构层:在 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)
    }
}

十二、常见坑位与对策

  1. 把 UI 和业务搅在一起 → 无法单测。

    • 对策:业务写进 Reducer/UseCase,UI 只发事件、订阅状态。
  2. 浮点误差0.1 + 0.2 != 0.3

    • 对策:内部用 BigDecimal,显示前统一 format
  3. 重复小数点/连续运算符 → "1...2"、"1++2"。

    • 对策:在 onDot/onOperator 分支做约束,必要时"替换运算符"。
  4. 等号后输入 → 结果被拼接。

    • 对策:hasResult 标记,等号后第一位数字重启输入。
  5. 除 0 → 崩溃或 NaN。

    • 对策:safeCompute 捕获并进入错误态,数字键恢复。

结语

  • data classenum classsealed interface 是 Kotlin 建模的"三剑客"。
  • 在 Android(Compose)里,结合 State → UIEvent → Reducer → State 的单向数据流,用它们可以写出清晰、可测、可维护的 App。
  • 这套模式不仅适用于计算器,也适用于表单、列表筛选、复杂对话框、甚至多步骤流程。
相关推荐
小趴菜82272 小时前
Android TabLayout使用记录
android
半夏知半秋2 小时前
基于skynet框架业务中的gateway实现分析
服务器·开发语言·后端·学习·gateway
Leo655358 小时前
JDK8 的排序、分组求和,转换为Map
java·开发语言
磨十三9 小时前
C++ 标准库排序算法 std::sort 使用详解
开发语言·c++·排序算法
两只程序猿10 小时前
数据可视化 | Violin Plot小提琴图Python实现 数据分布密度可视化科研图表
开发语言·python·信息可视化
折翅鵬10 小时前
Android 程序员如何系统学习 MQTT
android·学习
野生技术架构师10 小时前
1000 道 Java 架构师岗面试题
java·开发语言
折翅鵬10 小时前
Kotlin Value Class 全面解析:类型安全与零开销封装
kotlin
搬砖的小码农_Sky10 小时前
如何将安卓应用迁移到鸿蒙?
android·华为·harmonyos