在 WPF 中使用 IDataErrorInfo 和特性的绑定






4.92/5 (14投票s)
本文介绍了一种在 WPF 中支持属性上的 IDataErrorInfo 和特性的实现方法。它还涵盖了使用 WPF 显示错误信息的其他方面。
概述
在 WPF 中,可以在 ViewModel
类中使用 IDataErrorInfo
接口为用户提供自动错误通知。还可以使用特性来验证用户输入,但这两者在 WPF 中并非开箱即用。结合使用它们可以以最小的努力以高度可维护的方式显示错误。
引言
我最近开始了一个新项目,与我合作的两位开发人员只工作了几个月 WPF;他们还继承了一个需要处理的代码库。我接到的任务是创建一个简单的表单,该表单在网格中的按钮被按下时显示。我利用这个任务来创建基础设施,以使开发和维护更加容易。其中一个基础设施就是 ViewModel
的 abstract
类。最初,我实现了这个抽象类来支持 INotifyPropertyChanged
。在基本功能工作正常后,我开始着手 IDataErrorInfo
。
我以前做过类似的事情,但使用了 Microsoft 的 Enterprise Library。有趣的是,现有的ViewModel
已经与属性相关联了验证特性,只是没有接口允许视图显示错误。
我有一个大致的想法,想要一个处理验证的标准,而 IDataErrorInfo
和验证特性的使用似乎是可行的方法。IDataErrorInfo
的 interface
如下:
public interface IDataErrorInfo
{
// Summary:
// Gets an error message indicating what is wrong with this object.
//
// Returns:
// An error message indicating what is wrong with this object. The default is
// an empty string ("").
string Error { get; }
// Summary:
// Gets the error message for the property with the given name.
//
// Parameters:
// columnName:
// The name of the property whose error message to get.
//
// Returns:
// The error message for the property. The default is an empty string ("").
string this[string columnName] { get; }
}
在 Microsoft 的文档中,实现有一个巨大的 case 语句来查找属性的错误—这是一种糟糕的迹象。我知道我真的不想去维护这个 case 语句。另一个糟糕的迹象是使用 string
来指定属性。
进行了一些搜索,并在网上找到了一些我希望能够或多或少完整使用的东西。它使用了派生自 ValidationAttribute
类和 IDataErrorInfo
接口的验证特性。我开始进行一些清理,并发现我对这个解决方案非常不满意。我最大的问题是性能—我绑定到 Error
属性,而实现有一个针对 Error
属性的 LINQ
语句,每次都会处理所有验证。INotifyPropertyChange
除非有明显的变化,否则不会触发刷新,比如地址的变化。因为 getter 是动态的,由于 LINQ
语句,即使在不需要的情况下,也可能导致多次重新计算。我想要的是维护一个先前计算的内存,比较两者,如果不同,才重新计算 Error
属性。
设计开始时使用了一个字典,键是属性名,值基本上是验证器的数组。我用一个类替换了这个数组,该类包含前一个值和验证器数组,并将大部分处理移到了这个类中。然后我用一个类替换了字典,并将确定属性验证的方括号运算符的主体减少到一行。
实现
DataErrorInfoAttributeValidator
类在构造函数中进行初始化,传入类本身和一个函数,该函数在验证发生变化时执行,并返回一个由换行符分隔的所有错误的字符串。构造函数负责使用反射获取验证项类属性,并将此类实例放入一个以属性名为键的字典中。
public DataErrorInfoAttributeValidator(IDataErrorInfo classInstance,
Action<string> updateErrorFunction)
{
_classInstance = classInstance;
_updateErrorFucntion = updateErrorFunction;
var validators = classInstance.GetType()
.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.SetProperty)
.Select(i => new PropertyValidator(i, classInstance)).Where(j => j.Validators.Any());
_propertyValidations = validators.ToDictionary(p => p.PropertyName, p => p);
}
类实例对于查找属性的过程是必需的,并且在 PropertyValidator
实例中用于查找属性的值。
PropertyValidator
类维护了该属性的 PropertyValidator
类型验证器属性,并且该类还维护了包含最后一个错误通知字符串数组的属性。这些字符串用于与验证器属性重新计算时找到的字符串进行比较。该 class
的构造函数实际上提取了验证器,并维护了一个属性的验证器集合。
public PropertyValidator(PropertyInfo propertyInfo, object classInstance)
{
_propertyInfo = propertyInfo;
_classInstance = classInstance;
Validators = propertyInfo.GetCustomAttributes().Where(i => i is ValidationAttribute)
.Cast<ValidationAttribute>().ToArray();
Validators.Where(j => j is MethodValidator).Cast<MethodValidator>().ToList()
.ForEach(i => i.SetClassInstance(_classInstance));
ErrorMessages = new string[0];
}
在找到验证器后,然后检查每个找到的验证器是否为 MethodValidator
类型。这是因为 MethodValidator
类需要该 class
的一个实例,以便它可以使用反射找到由 string
指定的 Method
(由于这是作为 Attribute
指定的,因此无法指定实例)。
可以看出,类实例的 ErrorMessages
被初始化为一个空集合,这样就不需要处理 null 条件了。
要验证一个属性,需要调用 DataErrorInfoAttributeValidator
类的 Validate
方法,传入属性名:
public string Validate(string propertyName) { if (_propertyValidations.ContainsKey(propertyName)) { var propertyValidation = _propertyValidations[propertyName]; if (propertyValidation.CheckValidations()) { Errors = _propertyValidations.SelectMany(i => i.Value.ErrorMessages).ToArray(); _updateErrorFucntion?.Invoke(string.Join(Environment.NewLine, Errors)); } return string.Join(Environment.NewLine, propertyValidation.ErrorMessages); } return string.Empty; }
此方法在字典中查找验证实例,并调用该实例的 CheckValidations
方法。此方法的返回值指示验证问题是否与上次调用方法时相同。如果存在差异,则重新计算错误数组,并使用已初始化的类时传递的函数,传入一个表示类所有错误的字符串。然后返回该属性的错误(如果没有错误则返回空字符串)。
public bool CheckValidations()
{
var newValue = _propertyInfo.GetValue(_classInstance);
var newErrorMessages = Validators.Where(i => !i.IsValid(newValue))
.Select(j => GetErrorMessage(_propertyInfo.Name, j))
.OrderBy(i => i).ToArray();
var isChanged = !StringArraysEqual(newErrorMessages, ErrorMessages);
ErrorMessages = newErrorMessages;
return isChanged;
}
在 CheckValidations
方法中,使用反射来获取属性的值。验证器的 IsValid
方法指示值的有效性,而 Name
属性提供与验证器相关的错误消息。 CheckValidation
方法仅在自上次调用以来验证发生变化时才返回 true,这样,错误消息仅在验证发生变化时重新生成。错误消息字符串的集合保存在 DataErrorInfoAttributeValidator.Validate
方法用于计算返回值的 ErrorMessages
属性中。此集合中有针对每个失败验证的错误消息。然后 Validate
方法将这些消息用换行符连接起来,使每个错误位于不同行。然后合并所有失败属性的所有错误消息,并将 ViewModel
的 Error 属性更新为一个包含用换行符连接的错误消息的新字符串。
你会注意到使用了 GetErrorMessage
方法来获取错误消息,原因是对于 ValidationAttribute
,在为属性指定属性时,错误消息 string
不是必需的。看起来与其要求提供消息,不如使用 String.Format
和字段名来创建消息。
private static string GetErrorMessage(string name, ValidationAttribute validator)
{
if (!string.IsNullOrWhiteSpace(validator.ErrorMessage)) return validator.ErrorMessage;
if (validator is RequiredAttribute) return
$"{FromCamelCase(name)} is required";
if (validator is RangeAttribute) return
$"{FromCamelCase(name)} is not between the range of {((RangeAttribute)validator).Minimum} and {((RangeAttribute)validator).Maximum}";
if (validator is MaxLengthAttribute) return
$"{name} text cannot be longer than {((MaxLengthAttribute) validator).Length} characters";
throw new NotImplementedException(
$"Validator {validator.GetType().Name} does have default error message or specific error message for property {name}");
}
只有 Required
属性有一个默认消息,而这正是我创建的初始表单的初始版本所需要的;随着我完善设计,我将添加对其他属性的支持。
方法验证器
创建 MethodValidationAttribute
是因为需要对值进行一些动态测试。它接受一个字符串,指定用于验证的方法。目前没有单独的方法来指定要生成的错误消息,因为我认为每种情况都会有不同的验证方法,但我可以看到包含此功能可能是一个好主意。
public class MethodValidationAttribute
{
public MethodValidationAttribute(string methodName)
{
MethodName = methodName;
}
private MethodInfo _validationMethod;
private object _classInstance;
public void SetClassInstance(object instance)
{
_classInstance = instance;
if (_validationMethod == null)
{
_validationMethod = instance.GetType()
.GetMethod(MethodName, BindingFlags.Public | BindingFlags.NonPublic
| BindingFlags.Instance);
Debug.Assert(_validationMethod != null,
$"The method {MethodName} could not be found in class {_classInstance.GetType().Name}");
Debug.Assert(_validationMethod.ReturnParameter?.ParameterType == typeof(string),
$"The return type of method {MethodName} is of type
{_validationMethod.ReturnParameter.ParameterType.Name},
not type string in class {_classInstance.GetType().Name}");
}
}
public string MethodName { get; }
public override bool IsValid(object value)
{
if (_validationMethod == null) return true;
ErrorMessage = (string)_validationMethod.Invoke(_classInstance, null);
return (string.IsNullOrWhiteSpace(ErrorMessage));
}
}
MethodValidationAttribute
需要方法名用于验证,以及 class
的一个实例。方法名是在要检查的属性的装饰器中作为参数传递的。class
实例在属性验证时提供。为了节省处理,维护了一个验证方法的指针,因此该方法只需要在首次验证属性时查找一次。当调用 IsValid
时,使用 Reflection 执行验证方法。该方法需要编写成返回一个非空 string
或 null
(如果没有错误),否则返回一个与错误通知关联的错误消息。为此,IsValid
方法将 ErrorMessage
属性设置为使用 Reflection
找到的方法的返回值。
我发现 MethodValidationAttribute
有一个问题,这在示例中很明显。如果正在验证的两个属性相互依赖,那么就可能出现这种情况:一个字段显示错误,而另一个字段已更正,但第一个字段的错误仍然显示,而且如果一个属性的值更改为错误条件,它也不会显示在依赖的属性中。
必需枚举属性验证器
解决方案中还包含另一个自定义验证器,即 RequiredEnumAttribute
。之所以需要创建它,是因为在某些情况下,枚举绑定到 ComboBox,并且初始值为空,并且没有定义“0”枚举。框架的 RequiredAttribute
无法捕获此问题,因此我创建了这个类来处理此情况。如果您遇到 RequiredAttribute
对枚举绑定不起作用的问题,请尝试此装饰器。请参阅 Required Enumeration Validation Attribute。
与 DataErrorInfoAttributeValidator 的接口
有两个区域需要编程才能使用 DataErrorInfoAttributeValidator
类:ViewModel
和 View
。
ViewModel
类实现了 IDataErrorInfo
接口的属性。需要实例化 DataErrorInfoAttributeValidator
类,这可能应该在类初始化时完成,因为可能存在需要告知用户的错误,这些错误是由于模型中的初始错误(包括创建新实例时)引起的。Error
属性只有在 Error
值改变时才需要实现 INotifyPropertyChanged
,以便 View
知道 Error
值已更改。有多种方法可以实现和实例化此类,但我是在第一次使用时初始化此类,而不是在初始化时,因为这意味着只有在尝试使用 IDataErrorInfo
接口时才会产生开销。
private DataErrorInfoAttributeValidator _propertyValidations; private string _error; private DataErrorInfoAttributeValidator GetValidators() { return _propertyValidations ?? (_propertyValidations = new DataErrorInfoAttributeValidator(this)); } #region IDataErrorInfo public string this[string propertyName] { get { return GetValidators().Validate(propertyName); } } public string Error { get { GetValidators(); return _error; } set { Set(ref _error, value); } } endregion
使用 DataErrorInfoAttributeValidator
类使 IDataErrorInfo
的实现真正地清理了 ViewModel
—我实际上将此代码放在了一个 abstract
类中,我在项目中为许多 ViewModel
使用它—该 class
实现 INotifyPropertyChanged
和 IDataErrorInfo
。在我正在处理的项目中,没有使用任何框架,所以我没有任何支持 WPF 开发的基类,而大多数重要项目都会使用这些基类。我知道代码仍然存在一些问题,但如果您不使用框架来帮助处理 INotifyPropertyChanged
,那么您可能想借用此代码。
XAML
下一部分是 View
中需要执行的操作,以便绑定到错误信息。
<TextBox Text="{Binding Value,
UpdateSourceTrigger=PropertyChanged,
ValidatesOnDataErrors=True}" />
我只展示了 TextBox
上 Text
的绑定。将 UpdateSourceTrigger
设置为 PropertyChanged
是必需的,以确保每次按键都进行验证。这显然会降低性能,但只会在用户更改值时发生,因此性能影响很小。ValidatesOnDataErrors=True
是触发验证检查所必需的。
我在之前工作的应用程序中实现了这一点,并且在 TextBox
上获得了红色边框和错误提示框,以及带有错误描述的工具提示,但当我尝试在我的示例中做同样的事情时,我只得到了红色框,而没有工具提示。这是因为纯粹的 WPF,没有任何主题,会在控件周围显示一个红色框,但不会提供任何错误指示。要在 ToolTip
中显示错误,需要为 Validation.ErrorTemplate
属性提供一个 ControlTemplate
。我在示例中为此创建了一个 Style
。
<Style x:Key="Version1" TargetType="{x:Type Control}">
<Setter Property="Validation.ErrorTemplate">
<Setter.Value>
<ControlTemplate x:Name="TextErrorTemplate">
<Border BorderBrush="Red" BorderThickness="2">
<AdornedElementPlaceholder/>
</Brder>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="Validation.HasError" Value="True">
<Setter Property="ToolTip"
Value="{Binding RelativeSource={x:Static RelativeSource.Self},
Path=(Validation.Errors).CurrentItem}"/>
</Trigger>
</Style.Triggers>
</Style>
请注意,最初我使用的是 "Path=(Validation.Errors)[0].ErrorContent
" 并发现这会导致问题,然后更改为 "Path=(Validation.Errors).CurrentItem
",这解决了问题。我在 Binding to (Validation.Errors)[0] without Creating Debug Spew 找到了这个解决方案。
第二组 TextBox
应用了这个样式,而第一组没有。如果正在使用 Theme
,那么很可能您不需要 ErrorTemplate
来显示带有 ToolTip
中错误的 ErrorAdorner
。您仍然可能想更改错误的显示方式。以下是我在 Theme
中找到的代码,这可能是定义 ErrorTemplate
的好方法。
<ControlTemplate x:Key="ValidationTooltipTemplate"> <Grid SnapsToDevicePixels="True" VerticalAlignment="Top"> <Border Background="Transparent" HorizontalAlignment="Right" VerticalAlignment="Top" Width="3" Height="3"/> <AdornedElementPlaceholder x:Name="Holder"/> <Border BorderBrush="{StaticResource ValidationTooltipOuterBorder}" BorderThickness="1" CornerRadius="{StaticResource ValidationTooltip_CornerRadius}"/> <Path Data="M2,1 L6,1 6,5 Z" Fill="{StaticResource ValidationInnerTick}" Width="7" Height="7" HorizontalAlignment="Right" VerticalAlignment="Top"/> <Path Data="M0,0 L2,0 7,5 7,7 Z" Fill="{StaticResource ValidationOuterTick}" Width="7" Height="7" HorizontalAlignment="Right" VerticalAlignment="Top"/> <ToolTipService.ToolTip> <ToolTip x:Name="PART_ToolTip" DataContext="{Binding RelativeSource={RelativeSource Mode=Self}, Path=PlacementTarget.DataContext}" Template="{StaticResource ErrorTooltipTemplate}" Placement="Right"/> </ToolTipService.ToolTip> </Grid> </ControlTemplate>
这个模板现在可以用于定义多个控件的 ErrorTemplate
。我在 app.config 文件中有代码,并在最后一组 TextBox
es 中使用。然后,它在 Style
中使用方式如下:
<Style x:Key="Version4"
TargetType="{x:Type Control}">
<Setter Property="Validation.ErrorTemplate"
Value="{StaticResource ValidationTooltipTemplate}" />
</Style>
您还会注意到,还有一个用于 ToolTIp
的 Template
。在此 Template
中,ToolTip
的 Background
被定义为红色,Foreground
被定义为白色。
BaseViewModel
为了让代码很容易集成到项目中,我创建了一个 BaseViewModel
,它实现了 INotifyPropertyChanged
和 IDataErrorInfo
。
public abstract class BaseViewModel : INotifyPropertyChanged, IDataErrorInfo
{
#region INotifyPropertyChanged
...
...
...
#endregion
#region validation (IDataErrorInfo)
private DataErrorInfoAttributeValidator _propertyValidations;
private string _error;
private DataErrorInfoAttributeValidator GetValidators()
{
return _propertyValidations ?? (_propertyValidations
= new DataErrorInfoAttributeValidator(this, str => Error = str));
}
#region IDataErrorInfo
public string this[string propertyName] => GetValidators().Validate(propertyName);
public string Error
{
get { GetValidators(); return _error; }
set { Set(ref _error, value); }
}
#endregion
#endregion
}
使用这个 class
,我能够非常快速地将 DataErrorInfoAttributeValidator
包含到我的代码中。
从驼峰式命名转换为带空格的名称的代码
这是我用来将驼峰式名称转换为在每个大写字母前加空格的名称的代码。
private static string FromCamelCase(string value) => Regex.Replace(value, @"(?<a>(?<!^)((?:[A-Z][a-z])|(?:(?<!^[A-Z]+)[A-Z0-9]+(?:(?=[A-Z][a-z])|$))|(?:[0-9]+)))", @" ${a}");
示例
我提供了五种不同的 ErrorTemplate
示例供演示,包括开箱即用的 WPF 示例。它们都绑定到 ViewModel
中的两个相同属性(属性 Key
和 Value
)。还有一个按钮,只有在没有错误时才启用。为了做到这一点,我对 IDataErrorInfo
的 Error 属性使用了一个转换器,该转换器仅在 Error
属性为 null、空或空白字符时启用按钮。我还在按钮右侧添加了一个错误装饰器,只有在 Error
属性为 null、空或空白字符时才可见。我对它使用了相同的转换器,一个评估值是否为 null、空或空白字符,并从 ConverterParameter
返回相应值的转换器。错误装饰器的 ToolTip
绑定到 Error
属性。
此表单的 ViewModel
中的代码如下:
public class ViewModel : BaseViewModel
{
public ViewModel() { }
[Required]
[MethodValidator("KeyAndValueDifferent")]
public string Key{get { return _key; }set { Set(ref _key, value); }}
private string _key;
[Required]
[MethodValidator("KeyAndValueDifferent")]
public string Value{get { return _value; }set { Set(ref _value, value); }}
private string _value;
private string KeyAndValueDifferent()
{
return (Key != null && Value != null && Key == Value ) ? "Key and value must be different" : null;
}
}
而每个 TextBox
对的 XAML 与此类似:
<Border Margin="2"
BorderBrush="Gray"
BorderThickness="1 ">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="300" />
<ColumnDefinition Width="50" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Label Grid.ColumnSpan="3"
Margin="5 5 0 5"
Content="Default WPF Error Template" />
<Label Grid.Row="1"
Grid.Column="0"
Margin="5"
Content="Key" />
<TextBox Grid.Row="1"
Grid.Column="1"
Margin="5"
Text="{Binding Key,
UpdateSourceTrigger=PropertyChanged,
ValidatesOnDataErrors=True}" />
<Label Grid.Row="2"
Grid.Column="0"
Margin="5"
Content="Value" />
<TextBox Grid.Row="2"
Grid.Column="1"
Margin="5"
Text="{Binding Value,
UpdateSourceTrigger=PropertyChanged,
ValidatesOnDataErrors=True}" />
</Grid>
</Border>
结论
此示例中的代码提供了一种非常易于使用的方式来在 WPF 应用程序中显示错误,并且提供了大量信息来允许自定义错误显示。希望这对您有所帮助。
历史
- 2015/09/12:初始版本
- 2016/03/03:更新了源代码
- 2016/03/04:在示例中添加了
MethodValidator
的用法 - 2016/05/27:驼峰式转换为字段名中的常用名称,用于自动错误消息,并为 Range Validation 添加了自动消息生成。
- 2016/07/11:将
Path=(Validation.Errors)[0].ErrorContent
替换为Path=(Validation.Errors).CurrentItem
,消除了调试输出。 - 2016/08/02:向该代码添加了一个专门的
RangeValidator
(RangeUnitValidator
),该验证器带有一个 Unit 参数。