一、前言
Windows Presentation Foundation (WPF) 具有一个丰富数据绑定系统。除了作为通过 Model-View-ViewModel (MVVM) 模式从支持逻辑和数据对 UI 定义进行松散耦合的关键推动力之外,数据绑定系统还为业务数据验证方案提供强大而灵活的支持。WPF 中的数据绑定机制包括多个选项,可用于在创建可编辑视图时校验输入数据的有效性。
**验证机制** | **说明** |
异常 | 通过在某个 Binding 对象上设置 ValidatesOnExceptions 属性,如果在尝试对源对象属性设置已修改的值的过程中引发异常,则将为该 Binding 设置验证错误。 |
ValidationRules | Binding 类具有一个用于提供 ValidationRule 派生类实例的集合的属性。这些 ValidationRules 需要覆盖某个 Validate 方法,该方法由 Binding 在每次绑定控件中的数据发生更改时进行调用。如果 Validate 方法返回无效的 ValidationResult 对象,则将为该 Binding 设置验证错误。 |
IDataErrorInfo | 通过在绑定数据源对象上实现 IDataErrorInfo 接口并在 Binding 对象上设置 ValidatesOnDataErrors 属性,Binding 将调用从绑定数据源对象公开的 IDataErrorInfo API。如果从这些属性调用返回非 null 或非空字符串,则将为该 Binding 设置验证错误。 |
在使用 WPF 中的数据绑定来呈现业务数据时,通常应使用 Binding 对象在目标控件的单个属性与数据源对象属性之间提供数据管道。
若要使绑定验证有效,首先需要进行 TwoWay 数据绑定。这意味着,除了从源属性流向目标属性以进行显示的数据之外,编辑过的数据也会从目标流向源。
当 TwoWay 数据绑定中输入或修改数据时,将启动以下工作流:
- 用户通过击键、鼠标、触摸或与各元素间的手写笔交互来输入或修改数据,从而更改元素的属性。
- 如果需要,可将数据转换为数据源属性类型。
- 设置源属性值。
- 触发 Binding.SourceUpdated 附加事件。
- 如果数据源属性上的 setter 引发异常,则异常会由 Binding 捕获,并可用于指示验证错误。
- 如果实现了 IDataErrorInfo 接口,则会对数据源对象调用该接口的方法获得该属性的错误信息。
- 向用户呈现验证错误指示,并触发 Validation.Error 附加事件。
本篇主要介绍MVVM模式下, IDataErrorInfo 的校验以及结合 DataAnnotation 的实现方法。比较来说,在 set 里直接 throw Exception 实现最为简单,但不适合组合校验且Model里需要编写过重的校验代码;ValidationRules 更适合在用户控件或者自定义控件场合使用。IDataErrorInfo 则是比较普遍且灵活的校验实现方式。
二、实例分析
我们先来看看 IDataErrorInfo 的实现方法: Error 属性用于指示整个对象的错误,而索引器用于指示单个属性级别的错误。
两者的工作原理相同:如果返回非 null 或非空字符串,则表示存在验证错误。否则,返回的字符串用于向用户显示错误。
Person.cs 的 Age 属性进行了校验。
public class Person : INotifyPropertyChanged, IDataErrorInfo
{
private string _name;
public string Name
{
get { return _name; }
set
{
if (_name != value)
{
_name = value;
RaisePropertyChanged("Name");
}
}
}
private int _age;
public int Age
{
get { return _age; }
set
{
if (_age != value)
{
_age = value;
RaisePropertyChanged("Age");
}
}
}
public string Error
{
get { return ""; }
}
public string this[string columnName]
{
get
{
if (columnName == "Age")
{
if (_age < 18)
{
return "年龄必须在18岁以上。";
}
}
return string.Empty;
}
}
public event PropertyChangedEventHandler PropertyChanged;
internal virtual void RaisePropertyChanged(string propertyName)
{
if (PropertyChanged != null)
{
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
}
Xmal 绑定:
<TextBox Text="{Binding Age, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnExceptions=True, ValidatesOnDataErrors=True, NotifyOnValidationError=True}" Grid.Row="1" Grid.Column="1" Margin="5"/>
(注意: TextBox 默认的 UpdateSourceTrigger 是 LostFocus,如果你想内容一改变立刻进行校验那么需要修改为 PropertyChanged
)
那么,在输入非法的年龄时提示出错误信息。
利用 IDataErrorInfo 的好处是它可用于轻松地处理交叉耦合属性。但也具有一个很大的弊端:
索引器的实现通常会导致较大的 switch-case 语句(对象中的每个属性名称都对应于一种情况),
必须基于字符串进行切换和匹配,并返回指示错误的字符串。而且,在对象上设置属性值之前,不会调用 IDataErrorInfo 的实现。
为了避免出现大量的 switch-case,并且将校验逻辑进行分离提高代码复用,于是 DataAnnotations 华丽登场。
改造下上面的 Person 类,加上 [Range] ValidationAttribute:(需要添加 System.ComponentModel.DataAnnotations.dll)
[Range(19, 99, ErrorMessage="年龄必须在18岁以上。")]
public int Age
{
get { return _age; }
set
{
if (_age != value)
{
_age = value;
RaisePropertyChanged("Age");
}
}
}
修改 IDataErrorInfo 的索引器,让它通过 Validator 校验属性:
public string this[string columnName]
{
get
{
var vc = new ValidationContext(this, null, null);
vc.MemberName = columnName;
var res = new List<ValidationResult>();
var result = Validator.TryValidateProperty(this.GetType().GetProperty(columnName).GetValue(this, null), vc, res);
if (res.Count > 0)
{
return string.Join(Environment.NewLine, res.Select(r => r.ErrorMessage).ToArray());
}
return string.Empty;
}
}
用 DataAnnotions 后,Model 的更加简洁,校验也更加灵活。还可以利用 CustomerValidation 或者 自定义 ValidationAttribute 来进行校验逻辑的进一步分离,错误消息格式化。并且通过反射等技术,完全可以将 IDataErrorInfo 的实现抽成一个抽象类进行封装,编程更加便利。
(1) 自定义 ValidationAttribute
添加了一个针对上面 Person 的 Name 属性是否存在的校验:
class NameExists : ValidationAttribute
{
public override bool IsValid(object value)
{
var name = value as string;
// 这里可以到数据库等存储容器中检索
if (name != "Felix")
{
return false;
}
return true;
}
public override string FormatErrorMessage(string name)
{
return "请输入存在的用户名。";
}
}
将 NameExistsAttribute 添加到 Person.Name 属性上:
[NameExists]
public string Name
{
get { return _name; }
set
{
if (_name != value)
{
_name = value;
RaisePropertyChanged("Name");
}
}
}
(2) 利用 CustomerValidationAttribute
先实现一个 public static 的校验方法(必须返回 ValidationResult )
public class CustomerValidationUtils
{
public static ValidationResult CheckName(string value)
{
if (value.Length < 8)
{
return new ValidationResult("名字长度必须大于等于8位。");
}
return ValidationResult.Success;
}
}
然后在 Person 的 Name 属性上加上 CustomerValidation 特性:
[CustomValidation(typeof(CustomerValidationUtils), "CheckName")]
public string Name
{
get { return _name; }
set
{
if (_name != value)
{
_name = value;
RaisePropertyChanged("Name");
}
}
}
在实际开发中,我们还经常使用 EF 等 ORM 来做数据访问层,Model 通常会由这个中间件自动生成(利用T4等代码生成工具)。而他们通常是 POCO 数据类型,这时候如何能把属性的校验特性加入其中呢。这时候, TypeDescriptor.AddProviderTransparent + AssociatedMetadataTypeTypeDescriptionProvider 可以派上用场,它可以实现在另一个类中增加对应校验特性来增强对原类型的元数据描述。按照这种思路,将上面的 Person 类分离成两个文件:第一个分离类,可以想象是中间件自动生成的 Model 类。第二个分离类中实现 IDataErrorInfo,并定义一个Metadata 类来增加校验特性。(EF CodeFirst 也可以使用这一思路)
Person.cs (原生的 Person 类,没有校验特性)
public partial class Person : INotifyPropertyChanged
{
private string _name;
public string Name
{
get { return _name; }
set
{
if (_name != value)
{
_name = value;
RaisePropertyChanged("Name");
}
}
}
private int _age;
public int Age
{
get { return _age; }
set
{
if (_age != value)
{
_age = value;
RaisePropertyChanged("Age");
}
}
}
public event PropertyChangedEventHandler PropertyChanged;
internal virtual void RaisePropertyChanged(string propertyName)
{
if (PropertyChanged != null)
{
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
}
PersonMetadata.cs (分离的 Person 类中,实现 IDataErrorInfo 接口,在其内部类中增加了校验特性)
public partial class Person: IDataErrorInfo
{
class PersonMetadata
{
[Required]
[NameExists]
[CustomValidation(typeof(CustomerValidationUtils), "CheckName")]
public string Name { get; set; }
[Range(19, 99, ErrorMessage = "年龄必须在18岁以上。")]
public string Age { get; set; }
}
public string Error
{
get { throw new NotImplementedException(); }
}
public string this[string columnName]
{
get
{
return this.ValidateProperty<PersonMetadata>(columnName);
}
}
}
ValidateProperty 方法,是一个基于 object 类型的扩展方法。通过泛型指定增强信息的类型。
public static class ValidationExtension
{
public static string ValidateProperty
(this object obj, string propertyName)
{
if (string.IsNullOrEmpty(propertyName))
return string.Empty;
var targetType = obj.GetType();
//你也可以利用 MetadataType 在分离类上声明
//var targetMetadataAttr = targetType.GetCustomAttributes(false)
// .FirstOrDefault(a => a.GetType() == typeof(MetadataTypeAttribute)) as MetadataTypeAttribute;
//if (targetMetadataAttr != null && targetType != targetMetadataAttr.MetadataClassType)
//{
// TypeDescriptor.AddProviderTransparent(
// new AssociatedMetadataTypeTypeDescriptionProvider(targetType, targetMetadataAttr.MetadataClassType), targetType);
//}
if (targetType != typeof(MetadataType))
{
TypeDescriptor.AddProviderTransparent(
new AssociatedMetadataTypeTypeDescriptionProvider(targetType, typeof(MetadataType)), targetType);
}
var propertyValue = targetType.GetProperty(propertyName).GetValue(obj, null);
var validationContext = new ValidationContext(obj, null, null);
validationContext.MemberName = propertyName;
var validationResults = new List
();
Validator.TryValidateProperty(propertyValue, validationContext, validationResults);
if (validationResults.Count > 0)
{
return validationResults.First().ErrorMessage;
}
return string.Empty;
}
}
利用这一思路,可以很容易的实现各种 POCO Model 类型的校验逻辑分离,这对于开发分层架构的应用框架时非常有用。