卡号输入状态提升:格式化、分组、最大长度控制、实时校验

原理

在支付输入场景中,卡号不是普通文本——它需要满足可读性(每4位分组显示)、长度约束(16~19位,取决于卡组织)、数据完整性(Luhn算法校验)。若将格式化与校验逻辑直接写在Composable内,会导致UI与业务强耦合、难以测试、状态分散。State Hoisting(状态提升)将原始输入、格式化后的显示值、校验错误信息全部上提至ViewModel或状态容器,Composable仅负责渲染单向数据流:用户输入 → ViewModel处理 → 新状态回写给UI。

卡号状态提升数据流 用户输入 原始值 分组格式化 最大长度控制 实时校验 显示状态 ViewModel UI仅展示hoisted状态;所有格式化、长度截断、校验逻辑集中在ViewModel/UseCase中,Composable保持无状态。

实现流程

  1. 定义数据类:在领域层创建 CardNumberState,包含原始值、格式化显示值、错误信息。
  2. 提升状态:ViewModel 暴露 MutableStateFlow<CardNumberState>,Composable 通过 collectAsState() 订阅。
  3. 格式化与分组:每输入4个数字后插入空格,允许退格时移除空格。
  4. 最大长度控制:检测卡号类型(Visa 16位、MasterCard 16~19位等),提前截断。
  5. 实时校验:使用Luhn算法每输入一个字符即校验,更新错误状态。
  6. 回传事件:TextField 的 onValueChange 回调调用 ViewModel 中的 onCardNumberChanged()。

关键代码示例

// CardNumberState.kt
data class CardNumberState(
    val rawNumber: String = "",
    val formattedNumber: String = "",
    val error: String? = null,
    val isValid: Boolean = false
)

// CardNumberViewModel.kt
class CardNumberViewModel : ViewModel() {
    private val _state = MutableStateFlow(CardNumberState())
    val state: StateFlow<CardNumberState> = _state.asStateFlow()

    fun onCardNumberChanged(input: String) {
        val digits = input.filter { it.isDigit() }.take(MAX_LENGTH)
        val formatted = formatByGroup(digits, GROUP_SIZE = 4)
        val error = validateLuhn(digits)
        _state.value = CardNumberState(
            rawNumber = digits,
            formattedNumber = formatted,
            error = error,
            isValid = error == null && digits.length in 13..19
        )
    }
}

// CardNumberField.kt (Composable)
@Composable
fun CardNumberField(viewModel: CardNumberViewModel = viewModel()) {
    val state by viewModel.state.collectAsState()
    var textFieldValue by remember { mutableStateOf(TextFieldValue("")) }

    OutlinedTextField(
        value = textFieldValue,
        onValueChange = { newValue ->
            viewModel.onCardNumberChanged(newValue.text)
            textFieldValue = textFieldValue.copy(
                text = state.formattedNumber,
                selection = TextRange(state.formattedNumber.length)
            )
        },
        isError = state.error != null,
        supportingText = state.error?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
        placeholder = { Text("xxxx xxxx xxxx xxxx") }
    )
}

调试要点

  • 打印每次 onCardNumberChanged 调用后的 rawNumber 长度,确认最大长度截断生效。
  • 在边角测试:快速连续输入20个数字,观察分组格式化是否产生多余空格。
  • 测试退格删除时,格式化状态是否正确移除末尾空格。

部署注意事项

关注点建议
卡组织检测提前从后端获取BIN表或在本地集成libphonenumber等库。
多语言适配格式化仅基于数字,无需本地化,但错误提示应使用字符串资源。
Accessibility对 OutlinedTextField 设置 contentDescription 为“卡号输入框”。

常见错误

❌ 在 TextField 的 onValueChange 中直接修改 state.formattedNumber 而不通知 ViewModel
导致状态不同步,用户删除字符后格式化错乱。
✅ 始终通过 ViewModel 的单一函数处理输入,ViewModel 内部维护唯一真相源。
🔍 性能陷阱
每次输入都重新计算Luhn校验,对于短卡号(如16位)影响很小,但若与实时网络校验结合,应加入debounce(300ms防抖)。

性能与稳定性优化

  • 使用StateFlow而非LiveData:避免生命周期无关的发射,且天然支持冷流。
  • 分组格式化避免中间对象:使用 buildString 拼接,减少String创建次数。
  • Luhn校验算法采用提前终止:若长度不足16位或含有非数字字符立即返回错误。
  • 考虑使用 derivedStateOf:如果格式化仅依赖原始值,可在Composable内用 remember(rawNumber) { formatByGroup(...) } 缓存。