实战:表单验证的状态管理——密封类+内联类方案
好了,咱们直接进入正题。表单验证这件事,说简单也简单,说复杂……嗯,我见过太多项目因为状态管理混乱,最后改一个字段校验要翻遍整个 Activity。今天我就拿一个注册表单来演示,怎么用密封类加内联类,把状态理得清清楚楚。
传统方案有什么痛点?
先说说我以前踩过的坑。最早做表单验证,我习惯用几个布尔值:isNameValid、isEmailValid、isPasswordValid……然后写一个 isFormValid 方法去判断。你想想看,字段一多,这些布尔值满天飞,改一个地方忘了同步,bug 就来了。
更麻烦的是错误提示。每个字段可能有不同的错误类型:空值、格式不对、长度不够……用字符串去存错误信息?那简直是灾难。我曾经维护过一个项目,错误提示字符串散落在各个 ViewModel 和 Fragment 里,想统一改个文案都得全局搜索。
密封类:让状态变得可穷举
密封类的好处,说白了就是「编译器帮你检查」。你定义好所有可能的状态,少写一个分支?编译直接报错。我特别喜欢这种安全感。
来看一个注册表单的字段状态定义:
sealed class FieldState<out T> {
object Idle : FieldState<Nothing>()
object Valid : FieldState<Nothing>()
data class Invalid(val error: FieldError) : FieldState<Nothing>()
data class ValidWithValue<T>(val value: T) : FieldState<T>()
}
这里我用了泛型 T,因为不同字段的合法值类型不一样——用户名是字符串,年龄是整数,邮箱也是字符串但格式校验不同。用泛型可以复用同一个密封类。
注意那个 FieldError,它也是个密封类:
sealed class FieldError {
object Empty : FieldError()
data class TooShort(val minLength: Int) : FieldError()
data class TooLong(val maxLength: Int) : FieldError()
data class InvalidFormat(val expected: String) : FieldError()
object Duplicate : FieldError()
}
为什么要单独抽出来?因为错误类型本身也需要可穷举。我在项目中遇到过产品经理临时加需求:「密码长度不够时,要提示具体差几位」。如果之前用字符串存错误,改起来就麻烦了。但用密封类,加一个 data class TooShort(val minLength: Int, val actualLength: Int) 就行,编译器会提醒你所有用到的地方。
内联类:零开销的包装
内联类在 Kotlin 1.5 之后稳定了。它的作用就是「包装一个值,但不增加运行时开销」。用在表单验证里,可以给原始类型加上语义。
举个例子,邮箱地址本质上是个字符串,但你不能随便拿个字符串就当邮箱用。用内联类包装一下:
@JvmInline
value class EmailAddress(val value: String) {
init {
require(value.contains("@")) { "Invalid email format" }
}
}
@JvmInline
value class PhoneNumber(val value: String) {
init {
require(value.matches(Regex("^\\+?[0-9]{7,15}$"))) { "Invalid phone" }
}
}
你看,这两个类在运行时就是普通的 String,没有额外对象开销。但编译器会阻止你把邮箱地址传给需要电话号码的地方。我在重构一个老项目时,用内联类替换了原来到处传的 String 参数,编译错误帮我找到了 3 个潜在的 bug。
init 块里可以做校验,但注意它只在构造时执行一次。如果性能敏感,可以用 require() 而不是 check(),因为 require 在 release 构建中会被移除。
组合起来:表单状态管理
现在我们把密封类和内联类组合起来。一个表单的整体状态,可以这样定义:
data class RegistrationFormState(
val username: FieldState<Username> = FieldState.Idle,
val email: FieldState<EmailAddress> = FieldState.Idle,
val password: FieldState<Password> = FieldState.Idle,
val confirmPassword: FieldState<Password> = FieldState.Idle,
val phone: FieldState<PhoneNumber> = FieldState.Idle,
val isSubmitting: Boolean = false
)
每个字段的状态都是 FieldState,而合法值被内联类包装。这样你在 ViewModel 里处理用户输入时,可以写出非常清晰的代码:
fun onUsernameChanged(input: String) {
_state.update { oldState ->
val newFieldState = when {
input.isBlank() -> FieldState.Invalid(FieldError.Empty)
input.length < 3 -> FieldState.Invalid(FieldError.TooShort(3))
input.length > 20 -> FieldState.Invalid(FieldError.TooLong(20))
!input.matches(Regex("^[a-zA-Z0-9_]+$")) ->
FieldState.Invalid(FieldError.InvalidFormat("字母、数字、下划线"))
else -> FieldState.ValidWithValue(Username(input))
}
oldState.copy(username = newFieldState)
}
}
注意这里 Username(input) 的构造——如果输入不合法,内联类的 init 块会抛异常。但我们已经在 when 里做了前置校验,所以只有合法值才会被包装。这就是「让非法状态不可表示」的实践。
SVG:表单验证状态流转图
在 ViewModel 中消费状态
状态定义好了,怎么在 UI 层消费?我用 StateFlow 把状态暴露出去,然后在 Compose 或 View 系统中观察。这里的关键是:UI 层只负责展示,不负责判断逻辑。
// 在 ViewModel 中
private val _state = MutableStateFlow(RegistrationFormState())
val state: StateFlow<RegistrationFormState> = _state.asStateFlow()
// 在 Compose 中
val state by viewModel.state.collectAsState()
TextField(
value = state.username.displayText(),
onValueChange = viewModel::onUsernameChanged,
isError = state.username is FieldState.Invalid,
errorMessage = (state.username as? FieldState.Invalid)?.error?.toUserMessage()
)
注意那个 toUserMessage() 扩展函数。我习惯把错误信息格式化放在扩展函数里,这样 ViewModel 和 UI 层都不需要关心具体文案:
fun FieldError.toUserMessage(): String = when (this) {
FieldError.Empty -> "此项不能为空"
is FieldError.TooShort -> "最少需要 $minLength 个字符"
is FieldError.TooLong -> "最多允许 $maxLength 个字符"
is FieldError.InvalidFormat -> "格式不正确,需要 $expected"
FieldError.Duplicate -> "该内容已被使用"
}
避坑指南
- 内联类不要滥用——如果包装类型经常变化(比如同一个字段在不同页面有不同校验规则),用内联类反而增加复杂度。我一般只在「这个值有明确的业务含义且不会变」时使用。
- 密封类的 when 要写全——Kotlin 1.7 之后,密封类的 when 默认要求穷举。但如果你用了
else分支,编译器就不会帮你检查了。我建议永远不要写else,让编译器当你的守门员。 - StateFlow 的 update 是原子操作——多个字段同时更新时,用
update而不是直接赋值。我见过有人用_state.value = _state.value.copy(...),这在并发场景下会丢失更新。
总结一下
密封类 + 内联类的组合,说白了就是「用类型系统来管理状态」。密封类让所有可能的状态一目了然,内联类让合法值自带语义且零开销。我在几个中型项目里用了这套方案,最直观的感受是:改需求时不再心惊胆战。加一个新字段、改一条校验规则,编译器会告诉你哪里需要改。
嗯,这套方案也不是银弹。如果你的表单只有两三个字段,用简单的布尔值可能更省事。但一旦字段超过五个,或者校验逻辑经常变化,我强烈建议你试试这个方案。毕竟,代码是写给人看的,顺便让机器能跑就行。