第3章 底部输入框组件:多字段状态提升实现

3.1 本章要点

  • 理解 State Hoisting(状态提升) 在多个输入字段间的统一管理模式
  • 掌握卡号、有效期、CVV、持卡人姓名四个输入框的独立状态与共享状态设计
  • 学习如何将输入验证逻辑与状态提升相结合,保证数据单向流动
  • 熟悉 Compose 中 TextField 配合 onValueChange 回调的推荐写法
前置知识:建议先回顾第2章「State Hoisting 核心原理与单向数据流」中关于 remember、mutableStateOf 以及回调提升的基本模式。

3.2 状态提升在输入框中的角色

在支付表单中,四个输入框并非孤立存在:卡号格式关联发卡机构识别,有效期与 CVV 存在长度校验依赖,持卡人姓名的字符集限制也与整体表单提交挂钩。若每个输入框各自持有状态,会导致验证逻辑分散、表单提交时数据采集困难。

State Hoisting 要求将所有输入框的「值」与「错误状态」提升到共同的父组件(例如 PaymentForm)中统一管理,子组件仅通过回调上报用户输入,父组件下发当前值。这种模式让数据流清晰可追踪,也便于添加跨字段校验。

核心原则:输入框组件只负责「显示当前值」和「触发值变更事件」,不持有任何可变状态。所有状态由上层通过参数注入。

3.3 状态提升架构图

下图展示四个输入框的状态如何统一上提到 PaymentFormViewModel 层,每个输入框通过 onValueChange 回调将原始输入向上传递,经过格式化与校验后再向下回写。

底部输入框组 — 状态提升数据流 PaymentFormViewModel 统一持有 cardNumber / expiry / cvv / cardHolder PaymentForm (父组件) 持有状态并通过参数下发 卡号输入框 onValueChange ↑ 有效期输入框 onValueChange ↑ CVV输入框 onValueChange ↑ 持卡人姓名 onValueChange ↑ onValueChange 回调汇总 更新 ViewModel 中的对应字段 校验、格式化、更新状态 触发重组 → 新值下发给输入框 数据流向说明 ① 用户输入 → 回调 ② 父组件校验/格式化 ③ 新值下发 → 重组 UI

3.4 实现流程

按照「自下而上」的 State Hoisting 模式,实现分为四步:

  1. 定义数据类:在 ViewModel 中声明 PaymentFormState 密封类或 data class,包含四个字段及其错误信息。
  2. 设计输入框组件接口:每个输入框接收 value: String、onValueChange: (String) -> Unit、error: String? 三个参数。
  3. 实现各输入框 Composabale:使用 TextField 或 OutlinedTextField,绑定 value 和 onValueChange,并显示错误文本。
  4. 在父组件中组合:PaymentForm 通过 remember 持有状态,分别传递给四个输入框,并处理它们的回调。

3.5 代码实现

以下展示完整的 ViewModel 状态定义、各输入框组件及父组件组合方式。

3.5.1 状态数据类与 ViewModel

// PaymentFormState.kt
data class PaymentFormState(
    val cardNumber: String = "",
    val expiry: String = "",
    val cvv: String = "",
    val cardHolder: String = "",
    val cardNumberError: String? = null,
    val expiryError: String? = null,
    val cvvError: String? = null,
    val cardHolderError: String? = null
)

// PaymentFormViewModel.kt
class PaymentFormViewModel : ViewModel() {
    private val _state = mutableStateOf(PaymentFormState())
    val state: State<PaymentFormState> = _state

    fun onCardNumberChange(raw: String) {
        // 仅允许数字和空格,自动添加空格分组
        val filtered = raw.filter { it.isDigit() || it == ' ' }.take(19)
        _state.value = _state.value.copy(
            cardNumber = filtered,
            cardNumberError = validateCardNumber(filtered)
        )
    }

    fun onExpiryChange(raw: String) {
        val filtered = raw.filter { it.isDigit() || it == '/' }.take(5)
        _state.value = _state.value.copy(
            expiry = filtered,
            expiryError = validateExpiry(filtered)
        )
    }

    fun onCvvChange(raw: String) {
        val filtered = raw.filter { it.isDigit() }.take(4)
        _state.value = _state.value.copy(
            cvv = filtered,
            cvvError = validateCvv(filtered)
        )
    }

    fun onCardHolderChange(raw: String) {
        val filtered = raw.filter { it.isLetter() || it == ' ' || it == '-' }.take(30)
        _state.value = _state.value.copy(
            cardHolder = filtered,
            cardHolderError = validateCardHolder(filtered)
        )
    }

    // 简化的校验占位,实际项目需更完善
    private fun validateCardNumber(num: String): String? =
        if (num.replace(" ", "").length != 16) "卡号必须为16位数字" else null

    private fun validateExpiry(exp: String): String? =
        if (exp.length < 5) "请输入完整有效期 (MM/YY)" else null

    private fun validateCvv(cvv: String): String? =
        if (cvv.length !in 3..4) "CVV 为3-4位数字" else null

    private fun validateCardHolder(name: String): String? =
        if (name.isBlank()) "持卡人姓名不能为空" else null
}

3.5.2 各输入框组件实现

// CardNumberInput.kt
@Composable
fun CardNumberInput(
    value: String,
    onValueChange: (String) -> Unit,
    error: String?,
    modifier: Modifier = Modifier
) {
    OutlinedTextField(
        value = value,
        onValueChange = onValueChange,
        label = { Text("卡号") },
        placeholder = { Text("1234 5678 9012 3456") },
        isError = error != null,
        supportingText = error?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
        singleLine = true,
        modifier = modifier.fillMaxWidth()
    )
}

// ExpiryInput.kt
@Composable
fun ExpiryInput(
    value: String,
    onValueChange: (String) -> Unit,
    error: String?,
    modifier: Modifier = Modifier
) {
    OutlinedTextField(
        value = value,
        onValueChange = onValueChange,
        label = { Text("有效期") },
        placeholder = { Text("MM/YY") },
        isError = error != null,
        supportingText = error?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
        singleLine = true,
        modifier = modifier.fillMaxWidth()
    )
}

// CvvInput.kt
@Composable
fun CvvInput(
    value: String,
    onValueChange: (String) -> Unit,
    error: String?,
    modifier: Modifier = Modifier
) {
    OutlinedTextField(
        value = value,
        onValueChange = onValueChange,
        label = { Text("CVV") },
        placeholder = { Text("123") },
        isError = error != null,
        supportingText = error?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
        visualTransformation = PasswordVisualTransformation(),
        singleLine = true,
        modifier = modifier.fillMaxWidth()
    )
}

// CardHolderInput.kt
@Composable
fun CardHolderInput(
    value: String,
    onValueChange: (String) -> Unit,
    error: String?,
    modifier: Modifier = Modifier
) {
    OutlinedTextField(
        value = value,
        onValueChange = onValueChange,
        label = { Text("持卡人姓名") },
        placeholder = { Text("ZHANG SAN") },
        isError = error != null,
        supportingText = error?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, capitalization = KeyboardCapitalization.Characters),
        singleLine = true,
        modifier = modifier.fillMaxWidth()
    )
}

3.5.3 父组件组合(状态提升汇聚点)

// PaymentForm.kt
@Composable
fun PaymentForm(
    viewModel: PaymentFormViewModel = viewModel(),
    modifier: Modifier = Modifier
) {
    val state = viewModel.state.value

    Column(
        modifier = modifier
            .padding(16.dp)
            .verticalScroll(rememberScrollState())
    ) {
        CardNumberInput(
            value = state.cardNumber,
            onValueChange = viewModel::onCardNumberChange,
            error = state.cardNumberError
        )
        Spacer(modifier = Modifier.height(12.dp))

        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.spacedBy(12.dp)
        ) {
            ExpiryInput(
                value = state.expiry,
                onValueChange = viewModel::onExpiryChange,
                error = state.expiryError,
                modifier = Modifier.weight(1f)
            )
            CvvInput(
                value = state.cvv,
                onValueChange = viewModel::onCvvChange,
                error = state.cvvError,
                modifier = Modifier.weight(1f)
            )
        }
        Spacer(modifier = Modifier.height(12.dp))

        CardHolderInput(
            value = state.cardHolder,
            onValueChange = viewModel::onCardHolderChange,
            error = state.cardHolderError
        )
        Spacer(modifier = Modifier.height(20.dp))

        // 提交按钮示例
        Button(
            onClick = { /* 触发提交逻辑 */ },
            modifier = Modifier.fillMaxWidth(),
            enabled = listOf(
                state.cardNumberError,
                state.expiryError,
                state.cvvError,
                state.cardHolderError
            ).all { it == null }
        ) {
            Text("提交支付")
        }
    }
}
要点总结:每个输入框组件都是纯展示组件,不引用任何 ViewModel。状态提升到 PaymentForm 层,再由 ViewModel 统一管理。这种结构让单元测试和 UI 调试变得非常简单——只需传入不同状态即可验证渲染。

3.6 调试方法

调试场景 推荐手段 预期观察点
输入框不响应输入 检查 onValueChange 是否被正确调用,以及 ViewModel 中的 _state 是否更新后触发重组 断点打在 ViewModel 的对应方法入口,确认 raw 值是否到达
错误提示不显示 验证 isError 和 supportingText 的条件分支 传入非空 error 字符串时组件应显示红色边框和提示文字
格式化后光标跳尾 Compose 的 TextField 在 value 变化时会自动将光标定位到文本末尾,这是正常行为。若需保持光标位置,需使用 TextFieldValue 观察输入过程中光标是否异常
调试小技巧:可以在 PaymentForm 中添加一个 Debug 文本块,实时显示当前 state 的 JSON 快照,方便观察字段变化是否与预期一致。

3.7 常见错误与避坑

  • ❌ 在输入框组件内部使用 remember { mutableStateOf("") }:这会导致状态被封闭在组件内部,父组件无法获取最新值,违背 State Hoisting 原则。所有状态必须由上层注入。
  • ❌ 直接修改 ViewModel 中的可变状态:例如 _state.value.cardNumber = raw 绕过 copy(),这不会触发 Compose 重组。必须使用不可变 data class 的 copy() 方法。
  • ❌ 在 onValueChange 中做耗时格式化:如正则匹配、网络查询等,会阻塞 UI 线程。建议使用 snapshotFlow 或协程异步处理复杂校验。
  • ❌ 忽略 keyboardOptions 配置:卡号、CVV 输入框若不指定 KeyboardType.Number,用户可能看到全键盘,影响体验。
重要提醒:对于卡号输入框的自动空格分组,不要在 onValueChange 中直接格式化后回写,因为这样会与用户正在输入的光标位置发生冲突。推荐仅在失去焦点(onFocusChanged)或提交时做格式化显示,或者使用 VisualTransformation 方案。

3.8 性能与稳定性优化

优化目标 具体措施 预期收益
减少不必要的重组 使用 derivedStateOf 将校验结果与输入值解耦,避免每次输入都触发全部字段校验 输入卡号时有效期和 CVV 不会重新校验
输入防抖 对 onValueChange 回调使用 snapshotFlow 配合 debounce,适用于需要实时校验但不想频繁重组的高频输入 减少低效重组次数,提升动画流畅度
资源复用 将 KeyboardOptions、VisualTransformation 等实例定义为 remember 或顶层 val,避免重组时重新创建 降低 GC 压力,输入响应更跟手
状态快照隔离 在 ViewModel 中使用 mutableStateOf 包裹整个 data class,而不是每个字段单独声明 mutableStateOf 单次状态变更触发一次重组,避免多个字段各自触发多次重组
优化建议:在 PaymentForm 的 Column 外部包裹 Key 或使用 stable 标记确保组合键不变时跳过重组。对于低端设备,可以开启 @Stable 注解让 Compose 编译器生成更高效的跳过逻辑。

3.9 完整集成检查清单

  • ✅ 每个输入框的 value 全部来自上层状态,没有内部 remember 状态
  • ✅ onValueChange 回调全部指向 ViewModel 的更新方法
  • ✅ 错误状态通过 isError + supportingText 展示,不依赖额外 Text 组件
  • ✅ 键盘类型已根据字段语义正确配置(数字键盘 vs 文本键盘)
  • ✅ 提交按钮的 enabled 状态绑定所有字段的校验结果
  • ✅ ViewModel 中使用 mutableStateOf 持有不可变 data class
  • ✅ 已添加 verticalScroll 防止键盘弹出时输入框被遮挡