卡号输入状态提升:格式化、分组、最大长度控制、实时校验
原理
在支付输入场景中,卡号不是普通文本——它需要满足可读性(每4位分组显示)、长度约束(16~19位,取决于卡组织)、数据完整性(Luhn算法校验)。若将格式化与校验逻辑直接写在Composable内,会导致UI与业务强耦合、难以测试、状态分散。State Hoisting(状态提升)将原始输入、格式化后的显示值、校验错误信息全部上提至ViewModel或状态容器,Composable仅负责渲染单向数据流:用户输入 → ViewModel处理 → 新状态回写给UI。
实现流程
- 定义数据类:在领域层创建
CardNumberState,包含原始值、格式化显示值、错误信息。 - 提升状态:ViewModel 暴露
MutableStateFlow<CardNumberState>,Composable 通过collectAsState()订阅。 - 格式化与分组:每输入4个数字后插入空格,允许退格时移除空格。
- 最大长度控制:检测卡号类型(Visa 16位、MasterCard 16~19位等),提前截断。
- 实时校验:使用Luhn算法每输入一个字符即校验,更新错误状态。
- 回传事件: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 内部维护唯一真相源。
导致状态不同步,用户删除字符后格式化错乱。
✅ 始终通过 ViewModel 的单一函数处理输入,ViewModel 内部维护唯一真相源。
🔍 性能陷阱
每次输入都重新计算Luhn校验,对于短卡号(如16位)影响很小,但若与实时网络校验结合,应加入debounce(300ms防抖)。
每次输入都重新计算Luhn校验,对于短卡号(如16位)影响很小,但若与实时网络校验结合,应加入debounce(300ms防抖)。
性能与稳定性优化
- 使用StateFlow而非LiveData:避免生命周期无关的发射,且天然支持冷流。
- 分组格式化避免中间对象:使用
buildString拼接,减少String创建次数。 - Luhn校验算法采用提前终止:若长度不足16位或含有非数字字符立即返回错误。
- 考虑使用
derivedStateOf:如果格式化仅依赖原始值,可在Composable内用remember(rawNumber) { formatByGroup(...) }缓存。