22、DataBinding与表单验证:输入校验规则、错误信息显示、实时验证、提交逻辑

表单验证这件事,说实话,是每个Android开发者都绕不开的坎。我早期做项目时,习惯在Activity里写一堆if-else,每次提交前挨个检查输入框。代码又臭又长,维护起来简直想哭。后来用了DataBinding,配合ObservableField和自定义验证逻辑,整个体验完全不一样了。

这一章,我们就来聊聊怎么用DataBinding优雅地搞定表单验证。包括校验规则怎么写、错误信息怎么展示、如何做到实时验证,以及最终的提交逻辑怎么组织。

核心思路:把验证逻辑从Activity中剥离出来,放到ViewModel或BindingAdapter中。UI只负责展示,数据驱动验证状态。

22.1 基础校验规则定义

先说说校验规则。我个人习惯把规则定义成一个数据类,每个字段对应一个验证结果。这样结构清晰,也方便扩展。

data class FieldValidation(
    val errorMessage: String = "",
    val isValid: Boolean = true
)

data class LoginFormState(
    val username: FieldValidation = FieldValidation(),
    val password: FieldValidation = FieldValidation(),
    val isFormValid: Boolean = false
)

你可能会问:为什么不直接用String表示错误信息?嗯,这里有个坑。如果只用String,空字符串表示无错误,那你就没法区分「还没验证」和「验证通过」这两种状态。用FieldValidation包装一下,语义更明确。

接下来,在ViewModel里定义验证方法:

class LoginViewModel : ViewModel() {
    val formState = ObservableField(LoginFormState())
    
    val username = ObservableField("")
    val password = ObservableField("")

    fun validateUsername(): FieldValidation {
        val value = username.get() ?: ""
        return when {
            value.isBlank() -> FieldValidation("用户名不能为空", false)
            value.length < 3 -> FieldValidation("用户名至少3个字符", false)
            else -> FieldValidation("", true)
        }
    }

    fun validatePassword(): FieldValidation {
        val value = password.get() ?: ""
        return when {
            value.isBlank() -> FieldValidation("密码不能为空", false)
            value.length < 6 -> FieldValidation("密码至少6个字符", false)
            else -> FieldValidation("", true)
        }
    }
}

小技巧:我在项目中遇到过一种情况——多个字段共用同一个验证规则。比如邮箱和手机号都需要「非空+格式校验」。这时候我会抽一个通用的Validator工具类,避免重复代码。

22.2 错误信息显示:BindingAdapter的妙用

错误信息怎么显示到界面上?最直接的方式是用TextView展示。但我不想在XML里写一堆visibility判断。用BindingAdapter就舒服多了。

@BindingAdapter("app:errorText")
fun TextView.setErrorText(validation: FieldValidation?) {
    validation?.let {
        text = it.errorMessage
        visibility = if (it.isValid) View.GONE else View.VISIBLE
    }
}

XML里这样用:

<TextView
    android:id="@+id/tvUsernameError"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:textColor="@color/red"
    app:errorText="@{viewModel.formState.username}" />

你看,一行绑定就搞定了错误信息的显示和隐藏。不用写if-else,不用操作findViewById。这就是DataBinding的魅力。

注意:BindingAdapter方法名不能随便起。我建议统一用"app:errorText"这样的命名规范,团队成员一看就知道是干什么的。曾经有个同事起名叫"app:showError",结果项目里好几个同名方法,编译报错查了半天……

22.3 实时验证:TextWatcher的替代方案

实时验证,说白了就是用户一边输入一边检查。传统做法是给EditText加TextWatcher,但代码量不小。用DataBinding的话,我们可以借助ObservableField的监听机制。

在ViewModel里,我习惯这样写:

init {
    // 监听用户名变化,实时验证
    username.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() {
        override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
            val currentState = formState.get() ?: return
            val usernameValidation = validateUsername()
            formState.set(currentState.copy(username = usernameValidation))
        }
    })
    
    // 密码同理
    password.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() {
        override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
            val currentState = formState.get() ?: return
            val passwordValidation = validatePassword()
            formState.set(currentState.copy(password = passwordValidation))
        }
    })
}

XML里绑定EditText:

<EditText
    android:id="@+id/etUsername"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="@={viewModel.username}" />

为什么这样就能实时验证?因为@={}是双向绑定,用户输入改变username的值,触发回调,然后更新formState,UI自动刷新错误信息。整个链条是自动的。

核心逻辑流程图:

用户输入 ObservableField 验证方法 formState更新 UI自动刷新 错误信息显示

22.4 提交逻辑:整体校验与防重复提交

提交按钮的逻辑,我一般分三步:

  1. 整体校验:调用所有字段的验证方法,更新formState
  2. 检查结果:如果任何一个字段无效,阻止提交
  3. 防重复提交:提交过程中禁用按钮

代码实现:

fun onSubmit() {
    // 1. 整体校验
    val usernameValidation = validateUsername()
    val passwordValidation = validatePassword()
    val currentState = formState.get() ?: return
    formState.set(currentState.copy(
        username = usernameValidation,
        password = passwordValidation,
        isFormValid = usernameValidation.isValid && passwordValidation.isValid
    ))
    
    // 2. 检查结果
    if (!formState.get()?.isFormValid!!) {
        // 有错误,不提交
        return
    }
    
    // 3. 防重复提交(用LiveData或状态标记)
    _isLoading.value = true
    viewModelScope.launch {
        try {
            // 执行网络请求或数据库操作
            delay(2000) // 模拟耗时
        } finally {
            _isLoading.value = false
        }
    }
}

XML里绑定提交按钮:

<Button
    android:id="@+id/btnSubmit"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="提交"
    android:enabled="@{!viewModel.isLoading}"
    android:onClick="@{() -> viewModel.onSubmit()}" />

避坑指南:我曾经在项目里直接用android:enabled="@{viewModel.formState.isFormValid}"来控制按钮可用状态。结果发现一个问题——用户还没输入任何内容时,按钮就是禁用的,体验很差。后来我改成:初始状态按钮可用,点击提交时才做校验。这样用户可以先填,填完再点提交,更符合直觉。

22.5 完整示例:登录表单

把上面所有知识点串起来,一个完整的登录表单验证就出来了。我直接贴核心代码:

// LoginViewModel.kt
class LoginViewModel : ViewModel() {
    val username = ObservableField("")
    val password = ObservableField("")
    val formState = ObservableField(LoginFormState())
    val isLoading = ObservableBoolean(false)

    init {
        username.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() {
            override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
                validateField("username")
            }
        })
        password.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() {
            override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
                validateField("password")
            }
        })
    }

    private fun validateField(field: String) {
        val currentState = formState.get() ?: return
        val newState = when (field) {
            "username" -> currentState.copy(username = validateUsername())
            "password" -> currentState.copy(password = validatePassword())
            else -> currentState
        }
        formState.set(newState)
    }

    private fun validateUsername(): FieldValidation {
        val value = username.get() ?: ""
        return when {
            value.isBlank() -> FieldValidation("用户名不能为空", false)
            value.length < 3 -> FieldValidation("用户名至少3个字符", false)
            else -> FieldValidation("", true)
        }
    }

    private fun validatePassword(): FieldValidation {
        val value = password.get() ?: ""
        return when {
            value.isBlank() -> FieldValidation("密码不能为空", false)
            value.length < 6 -> FieldValidation("密码至少6个字符", false)
            else -> FieldValidation("", true)
        }
    }

    fun onSubmit() {
        // 整体校验
        val usernameValidation = validateUsername()
        val passwordValidation = validatePassword()
        val currentState = formState.get() ?: return
        formState.set(currentState.copy(
            username = usernameValidation,
            password = passwordValidation
        ))

        if (!usernameValidation.isValid || !passwordValidation.isValid) {
            return
        }

        isLoading.set(true)
        viewModelScope.launch {
            try {
                // 模拟登录请求
                delay(1500)
            } finally {
                isLoading.set(false)
            }
        }
    }
}
<!-- activity_login.xml -->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <data>
        <variable name="viewModel" type="com.example.LoginViewModel" />
    </data>
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:padding="16dp">

        <EditText
            android:id="@+id/etUsername"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="用户名"
            android:text="@={viewModel.username}" />

        <TextView
            android:id="@+id/tvUsernameError"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textColor="@color/red"
            android:textSize="12sp"
            app:errorText="@{viewModel.formState.username}" />

        <EditText
            android:id="@+id/etPassword"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="密码"
            android:inputType="textPassword"
            android:text="@={viewModel.password}" />

        <TextView
            android:id="@+id/tvPasswordError"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textColor="@color/red"
            android:textSize="12sp"
            app:errorText="@{viewModel.formState.password}" />

        <Button
            android:id="@+id/btnSubmit"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="登录"
            android:enabled="@{!viewModel.isLoading}"
            android:onClick="@{() -> viewModel.onSubmit()}" />

    </LinearLayout>
</layout>

总结一下:DataBinding做表单验证的核心就是「数据驱动」。你把验证逻辑放在ViewModel里,UI只负责展示和触发。错误信息用BindingAdapter自动显示隐藏,实时验证靠ObservableField的回调,提交逻辑做好整体校验和防重复。这套模式我用了好几年,在多个项目里验证过,稳定且易维护。

嗯,这一章的内容就到这里。表单验证看起来简单,但细节不少。尤其是实时验证和提交逻辑的配合,稍不注意就会出bug。希望你能在实际项目中多练练,找到最适合自己团队的写法。


公众号:蓝海资料掘金营,微信deep3321