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不太一样。建议直接用KClass的memberProperties来遍历。
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()
}
}
这段代码的核心逻辑很简单:
- 拿到对象的
KClass,获取所有属性。 - 对每个属性,尝试获取它上面的注解。
- 如果注解存在,就执行对应的校验逻辑。
- 校验失败就把错误信息收集起来。
使用起来也很直观:
val user = User("", "invalid-email", 200)
val errors = Validator.validate(user)
if (errors.isNotEmpty()) {
errors.forEach { println(it) }
// 输出:
// 姓名不能为空
// 邮箱格式不正确
// 年龄不合法
}
避坑指南:反射的性能与空安全
我曾经在线上项目里用过类似的方案,有几个坑想提醒你:
prop.getter.call(obj)比直接访问字段慢一个数量级。如果校验的对象很多(比如列表中有几百个),建议做缓存。把每个类的属性列表和注解信息缓存起来,避免重复反射。
prop.getter.call(obj)返回的是Any?,一定要做空判断。我习惯在@NotEmpty的校验里同时处理null和空字符串。
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集合来防止重复校验。
知识体系总览
下面这张图总结了本章的核心逻辑:从定义注解,到反射读取,再到执行校验,最后收集错误。
总结
自定义校验注解加上运行时反射,这套方案我用了好几年。它最大的好处是让校验逻辑和业务逻辑解耦,代码可读性大幅提升。你可以在项目里先从小范围开始用,比如只对用户输入做校验,慢慢扩展到更复杂的场景。
最后提醒一句:反射虽好,但不要滥用。只在需要通用处理的地方用反射,比如校验、序列化、依赖注入。日常的业务代码里,该写if-else就写,别为了炫技强行上反射。
核心要点回顾:
- 用
annotation class定义注解,设置@Target和@Retention(RUNTIME) - 通过
KClass.memberProperties遍历字段,用findAnnotation获取注解 - 反射调用
prop.getter.call(obj)获取属性值,注意空安全 - 缓存反射结果以提升性能,避免重复遍历
- 用
@Valid注解支持嵌套校验,注意防止循环引用
公众号:蓝海资料掘金营,微信deep3321