深入 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 也少一半。