16、数据校验注解:自定义@NotEmpty、@Email等校验注解,运行时反射校验

数据校验这件事,说实话,每个Android项目都绕不开。用户注册时邮箱不能为空、密码不能太短、手机号格式要对……这些逻辑如果散落在各个Activity或ViewModel里,维护起来简直是一场噩梦。

我个人习惯用注解来做校验。把校验规则写在字段上,然后用一个统一的校验器去处理。这样代码干净,逻辑集中,改起来也方便。今天我们就来手写几个常用的校验注解,比如@NotEmpty@Email,然后用反射在运行时完成校验。

为什么选择注解校验?

你想想看,传统的校验方式长什么样?

fun validate(user: User): String? {
    if (user.name.isNullOrEmpty()) return "姓名不能为空"
    if (!isValidEmail(user.email)) return "邮箱格式不正确"
    if (user.age < 0 || user.age > 150) return "年龄不合法"
    return null
}

这种写法有什么问题?每个校验方法都要写一堆if-else,而且校验逻辑和业务逻辑混在一起。如果字段多了,这个方法会膨胀到没法看。

用注解之后,代码变成这样:

data class User(
    @NotEmpty(message = "姓名不能为空")
    val name: String,
    
    @Email(message = "邮箱格式不正确")
    val email: String,
    
    @Range(min = 0, max = 150, message = "年龄不合法")
    val age: Int
)

清爽多了吧?校验逻辑被抽离到注解里,数据类只负责描述数据结构。这就是注解校验的核心思想——声明式校验

定义校验注解

我们先从最简单的@NotEmpty开始。在Kotlin中定义注解,要用annotation class关键字。

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
annotation class NotEmpty(
    val message: String = "该字段不能为空"
)

这里有两个元注解需要说明一下:

  • @Target(AnnotationTarget.FIELD):表示这个注解只能用在字段上。我个人习惯限制得严格一点,避免用错地方。
  • @Retention(AnnotationRetention.RUNTIME):保留到运行时,这样我们才能在反射中读取它。

接下来是@Email注解:

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
annotation class Email(
    val message: String = "邮箱格式不正确"
)

再补充一个@Range注解,用于校验数字范围:

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
annotation class Range(
    val min: Long = Long.MIN_VALUE,
    val max: Long = Long.MAX_VALUE,
    val message: String = "数值超出范围"
)

嗯,这里要注意:注解的参数只能是基本类型、String、枚举、Class或者它们的数组。不能传Lambda或者对象。

编写校验器:反射登场

注解定义好了,接下来就是核心部分——用反射读取注解,并执行校验逻辑。

我在项目中遇到过类似的需求,当时踩了一个坑:Kotlin的数据类字段,反射拿到的KProperty和Java的Field不太一样。建议直接用KClassmemberProperties来遍历。

object Validator {

    fun validate(obj: Any): List<String> {
        val errors = mutableListOf<String>()
        val kClass = obj::class

        // 遍历所有属性
        for (prop in kClass.memberProperties) {
            // 获取属性值
            val value = prop.getter.call(obj)

            // 检查 @NotEmpty
            prop.findAnnotation<NotEmpty>()?.let { annotation ->
                if (value == null || (value is String && value.isBlank())) {
                    errors.add(annotation.message)
                }
            }

            // 检查 @Email
            prop.findAnnotation<Email>()?.let { annotation ->
                if (value !is String || !isValidEmail(value)) {
                    errors.add(annotation.message)
                }
            }

            // 检查 @Range
            prop.findAnnotation<Range>()?.let { annotation ->
                if (value is Number) {
                    val num = value.toLong()
                    if (num < annotation.min || num > annotation.max) {
                        errors.add(annotation.message)
                    }
                }
            }
        }

        return errors
    }

    private fun isValidEmail(email: String): Boolean {
        return android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()
    }
}

这段代码的核心逻辑很简单:

  1. 拿到对象的KClass,获取所有属性。
  2. 对每个属性,尝试获取它上面的注解。
  3. 如果注解存在,就执行对应的校验逻辑。
  4. 校验失败就把错误信息收集起来。

使用起来也很直观:

val user = User("", "invalid-email", 200)
val errors = Validator.validate(user)
if (errors.isNotEmpty()) {
    errors.forEach { println(it) }
    // 输出:
    // 姓名不能为空
    // 邮箱格式不正确
    // 年龄不合法
}

避坑指南:反射的性能与空安全

我曾经在线上项目里用过类似的方案,有几个坑想提醒你:

性能问题:反射调用prop.getter.call(obj)比直接访问字段慢一个数量级。如果校验的对象很多(比如列表中有几百个),建议做缓存。把每个类的属性列表和注解信息缓存起来,避免重复反射。
空安全处理:Kotlin的属性可能是可空类型。在反射中,prop.getter.call(obj)返回的是Any?,一定要做空判断。我习惯在@NotEmpty的校验里同时处理null和空字符串。
Java与Kotlin混编:如果你的项目里还有Java类,memberProperties只能拿到Kotlin属性。Java字段需要用javaClass.declaredFields来获取。建议统一用Kotlin数据类。

扩展:支持嵌套校验

实际项目中,对象往往不是扁平的。比如用户有地址,地址里又有字段需要校验。我们可以加一个@Valid注解,让校验器递归处理。

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
annotation class Valid

// 在 Validator 中增加递归逻辑
if (prop.findAnnotation<Valid>() != null && value != null) {
    errors.addAll(validate(value))
}

这样就能处理嵌套对象了。不过要注意循环引用的问题——如果两个对象互相引用,递归会死循环。我建议在validate方法里加一个visited集合来防止重复校验。

知识体系总览

下面这张图总结了本章的核心逻辑:从定义注解,到反射读取,再到执行校验,最后收集错误。

数据校验注解核心流程 定义注解 @NotEmpty / @Email / @Range 标注字段 data class User(...) 反射读取 memberProperties 执行校验逻辑 findAnnotation → 类型判断 → 值校验 收集错误信息 返回 List<String>

总结

自定义校验注解加上运行时反射,这套方案我用了好几年。它最大的好处是让校验逻辑和业务逻辑解耦,代码可读性大幅提升。你可以在项目里先从小范围开始用,比如只对用户输入做校验,慢慢扩展到更复杂的场景。

最后提醒一句:反射虽好,但不要滥用。只在需要通用处理的地方用反射,比如校验、序列化、依赖注入。日常的业务代码里,该写if-else就写,别为了炫技强行上反射。

核心要点回顾

  • annotation class定义注解,设置@Target@Retention(RUNTIME)
  • 通过KClass.memberProperties遍历字段,用findAnnotation获取注解
  • 反射调用prop.getter.call(obj)获取属性值,注意空安全
  • 缓存反射结果以提升性能,避免重复遍历
  • @Valid注解支持嵌套校验,注意防止循环引用

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