深入 Compose 声明式表单架构:从字段级验证到动态表单的复杂交互状态管理全链路

表单不是 UI,是状态机

去年在重构一个电商后台的 SKU 配置表单时,我被 Compose 表单的状态管理绊住了。一个包含动态规格组合、跨字段校验、异步查重的页面,用传统 View 的思路——每个 EditText 自己管状态,提交时全量校验——代码直接奔着 800 行去了。

Compose 的声明式范式对表单其实很友好,前提是:把表单当成状态机来设计,而不是当成 UI 来堆。

表单状态有三个维度:每个字段的值(Value)、校验结果(Error)、字段间的依赖关系(Dependency)。这三个维度相互耦合,处理不当就会出现「改了 A 字段,B 字段的校验状态没更新」这类 Bug。问题是,官方文档只讲了 TextField 的基础用法,对这三个维度的工程化组织方式几乎没有涉及。

字段级状态:单一数据源

一个带校验的输入框,最常见的 bad case:

// ❌ 状态分散,容易不一致
var text by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }

OutlinedTextField(
    value = text,
    onValueChange = { 
        text = it
        error = validate(it)  // 每次输入都校验,频繁触发重组
    },
    isError = error != null,
    supportingText = { error?.let { Text(it) } }
)

问题不在语法,在职责混乱:onValueChange 里既改值又做校验,校验逻辑和 UI 耦合了。更隐蔽的问题是——如果校验逻辑涉及网络请求,输入频率会直接打爆接口。

字段应该封装成独立的状态持有者:

class FieldState(
    initial: String = "",
    private val validators: List<(String) -> String?> = emptyList()
) {
    var value by mutableStateOf(initial)
        private set
    var error by mutableStateOf<String?>(null)
        private set
    
    fun onChange(newValue: String) {
        value = newValue
        error = null  // 输入时清掉错误,提交时再校验
    }
    
    fun validate(): Boolean {
        error = validators.firstNotNullOfOrNull { it(value) }
        return error == null
    }
}

输入时不校验,提交时集中校验——这个决策来自实际踩坑。实时校验会导致用户没填完就看到红色提示,体验打折扣;换成提交时统一校验后,用户投诉直接少了。

FieldState 对外只暴露 onChange 和 validate,外界不能直接改 value,保证了单一数据源。校验规则通过构造函数注入的 validators 列表声明,加新规则不需要改类内部逻辑。

表单级编排:组合而非继承

单个字段搞定了,表单不止是字段的集合。提交时要知道哪些没过、计算整体合法性、处理字段联动。

拆成两层:FieldState 管单个字段,FormState 管编排:

class FormState {
    val name = FieldState(
        validators = listOf(
            { if (it.isBlank()) "名称不能为空" else null },
            { if (it.length < 2) "名称至少 2 个字符" else null }
        )
    )
    val email = FieldState(
        validators = listOf(
            { if (!it.contains("@")) "邮箱格式不正确" else null }
        )
    )
    
    val isValid: Boolean
        get() = listOf(name, email).all { 
            it.error == null && it.value.isNotBlank() 
        }
    
    fun validateAll(): Boolean {
        return listOf(name, email).map { it.validate() }.all { it }
    }
    
    fun snapshot(): Map<String, String> {
        return mapOf("name" to name.value, "email" to email.value)
    }
}

用组合而不是继承,因为不同表单的字段差异太大,继承树会迅速膨胀。组合让每个 FieldState 独立声明校验器列表,扩展时只加规则不改结构。

在 Composable 中使用:

@Composable
fun RememberFormState() = remember { FormState() }

@Composable
fun MyForm(form: FormState = RememberFormState()) {
    Column {
        FormField(field = form.name, label = "名称")
        FormField(field = form.email, label = "邮箱")
        Button(onClick = {
            if (form.validateAll()) submit(form.snapshot())
        }) { Text("提交") }
    }
}

remember { FormState() } 确保 Compose 重组不会重建 FormState,状态在整个组合生命周期内保持。

动态表单:列表中的状态身份

静态字段好处理,动态表单才是真正的挑战。比如可增删的收货地址列表,核心问题是:列表项增删时,Compose 如何正确识别每个 item 的状态?

@Composable
fun AddressList(
    addresses: List<Address>,
    fieldStates: Map<String, FieldState>,
    onRemove: (String) -> Unit
) {
    LazyColumn {
        items(
            items = addresses,
            key = { it.id }  // 用业务 id,别用 index
        ) { address ->
            val field = fieldStates[address.id] ?: return@items
            AddressCard(
                address = address,
                field = field,
                onRemove = { onRemove(address.id) }
            )
        }
    }
}

key = { it.id } 是关键。用过默认 index 当 key 的话会踩到一个典型的坑:删除 index=1 后,原 index=2 的 item 会复用 index=1 的 FieldState,导致编辑到一半的内容串到别的行。

删除时记得同步清理状态:

fun removeAddress(id: String) {
    addresses = addresses.filter { it.id != id }
    addressFields.remove(id)  // 内存泄漏就藏在这一行
}

漏掉 remove(id) 的话,addressFields 会持续膨胀,而且这些 FieldState 持有 mutableStateOf,永远不会被 GC。

跨字段联动:派生状态与防抖

表单里最难的不是校验,是联动。「选了省份后城市列表刷新」「勾选了「其他」后自定义输入框出现」。直接在 onValueChange 里写联动逻辑会让代码变成一团乱麻。

解法是:把联动关系声明为派生状态。

// 省份-城市联动
class AddressFormState {
    val province = FieldState()
    val city = FieldState()
    
    // 城市字段是否可用,由省份值派生
    val cityEnabled: Boolean
        @Composable get() = province.value.isNotBlank()
    
    // 城市选项列表,由省份值派生
    @Composable
    fun cityOptions(): List<String> {
        val p = province.value
        return remember(p) {
            if (p.isBlank()) emptyList()
            else cityRepository.queryByProvince(p)
        }
    }
}

@Composable 的 getter 充当响应式依赖——province.value 变化时,依赖它的 UI 自动重组。Compose Snapshot 系统的依赖跟踪在这里直接起作用了。

但网络请求不能直接放 Composable 里。queryByProvince 是同步写法,实际项目中它是异步的。正确姿势是用 LaunchedEffect 做防抖:

@Composable
fun rememberCityOptions(province: String): List<String> {
    var options by remember { mutableStateOf(emptyList<String>()) }
    LaunchedEffect(province) {
        if (province.isBlank()) {
            options = emptyList()
        } else {
            delay(300)  // 防抖,用户连续切换省份时不发请求
            options = cityRepository.queryByProvince(province)
        }
    }
    return options
}

LaunchedEffect(province) 只在 province 变化时触发,内置的协程作用域会在 province 再次变化时自动取消上一次请求——防抖和防竞态一起解决了。

实践建议

这套方案在几个中大型项目里跑了一年多,踩过的坑变成了经验:

不要用第三方表单库。Compose 的 mutableStateOf + remember 已经足够灵活。第三方库引入的 DSL 反而限制了扩展性——复杂表单的定制需求千差万别,自己写 200 行状态管理代码比适配一个库的成本低。

异步校验走 ViewModel 层。用户名查重这类校验,在 FieldState 里加一个 suspend fun validateAsync(),用 viewModelScope 调用,别在 Compose 层直接写协程。

复杂联动用 sealed class 建模。联动关系超过 5 个时,手写派生状态容易遗漏。用 sealed class 建模表单的展示模式(编辑态、只读态、分步态),配合 when 分支渲染不同 UI,代码意图比堆 if-else 清晰得多。

表单架构没有银弹,但把状态管理从 UI 层剥离、用声明式思维建模字段关系,能让代码从 800 行缩到 300 行——Bug 也少一半。