2. 支付输入状态模型设计:卡号、有效期、CVV、持卡人姓名状态类型
支付表单的每个输入字段并非简单的字符串——它们各自承载着不同的显示、校验与交互语义。将每个字段的“当前状态”建模为独立且可预测的状态机,是 State Hoisting 落地的第一步。本章定义四个字段的状态类型、数据模型及转换规则。
2.1 状态类型设计原则
- 不可变:每个状态对象都是不可变数据类,UI 通过副本更新状态。
- 穷举性:所有可能的 UI 表现(空、输入中、校验中、有效、无效、聚焦)都对应明确状态值。
- 正交性:每个字段的状态类型独立,字段之间不共享状态枚举。
- 可组合:状态携带元数据,例如无效原因、掩码后的显示文本。
讲师提示:四个字段的校验规则差异很大,但状态骨架一致。推荐使用泛型
FieldState<T> 统一建模,再针对每个字段补充专用子类。
2.2 字段状态枚举与语义
| 状态 | 语义 | 适用字段 |
|---|---|---|
Idle |
字段为空,未获得焦点,无校验请求 | 全部 |
Focused |
用户正在输入,暂不显示校验错误 | 全部 |
Validating |
正在进行异步校验(如 Luhn 检查、过期日期验证) | 卡号、有效期 |
Valid |
校验通过,字段内容正确 | 全部 |
Invalid |
校验失败,携带错误消息 | 全部 |
Formatted |
自动格式化的显示值(如卡号分组显示) | 卡号、有效期(MM/YY) |
2.3 数据模型定义(Kotlin 风格)
// 泛型状态基类
sealed class FieldState<out T> {
data class Idle(val raw: String = "") : FieldState<Nothing>()
data class Focused(val raw: String) : FieldState<Nothing>()
data class Validating(val raw: String) : FieldState<Nothing>()
data class Valid<T>(val value: T) : FieldState<T>()
data class Invalid(val raw: String, val error: String) : FieldState<Nothing>()
data class Formatted(val raw: String, val display: String) : FieldState<Nothing>()
}
// 具体字段类型
typealias CardNumberState = FieldState<String> // Valid 时携带已校验的卡号
typealias ExpiryState = FieldState<Pair<Int, Int>> // (月, 年)
typealias CvvState = FieldState<String>
typealias CardHolderState = FieldState<String>
注意:不要在同一字段状态中混合“原始值”与“校验结果”。例如
Valid 只携带经过校验并转换后的原始值,UI 需从 Formatted 或 Focused 获取显示文本。职责分离可避免状态膨胀。
2.4 状态模型结构图
2.5 常见错误与调试
- 状态未映射聚焦状态:只定义 Valid/Invalid,导致 UI 无法区分“正在输入”与“已完成输入”。表现为输入框永远显示错误红线。
- 原始值与校验值混用:在 Valid 状态下依然存储原始字符串,格式化显示时重新处理,破坏不可变性。应使用
Formatted状态携带显示字符串。 - 状态复制导致 CPU 抖动:每次输入变更都创建全新的 State 对象,对 Compose 来说是正常的;但若在
derivedStateOf或Snapshot中频繁触发校验,会造成帧率下降。可在Validating状态下加入防抖(debounce)。
调试建议:在 ViewModel 或 Composable 中打印状态变化日志,例如
Log.d("PaymentState", "card: $cardState"),配合 Compose 的 Live Edit 观察状态机是否按预期转换。特别注意 Idle → Focused → Validating → Valid/Invalid 路径是否完整。
2.6 性能与稳定性优化
- 使用 @Stable 注解:为状态数据类标记
@Stable,帮助 Compose 跳过不必要的重组。 - 字段级状态提升:每个字段持有自己的
MutableState<FieldState>,而不是将整个表单合并为一个 State 对象。减少无关字段的联动重组。 - 校验逻辑延迟执行:非聚焦且非空时才触发后端 Luhn 校验;聚焦状态仅做前端格式过滤,避免网络请求打断输入。
// 优化示例:卡号状态副本仅在进入 Validating 时启动校验协程
fun CvvState.validateAsync() {
if (this !is FieldState.Focused) return
// 启动协程:模拟 200ms 后返回 Valid 或 Invalid
}