65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (14投票s)

2015 年 9 月 13 日

CPOL

12分钟阅读

viewsIcon

24953

downloadIcon

537

本文介绍了一种在 WPF 中支持属性上的 IDataErrorInfo 和特性的实现方法。它还涵盖了使用 WPF 显示错误信息的其他方面。

概述

WPF 中,可以在 ViewModel 类中使用 IDataErrorInfo 接口为用户提供自动错误通知。还可以使用特性来验证用户输入,但这两者在 WPF 中并非开箱即用。结合使用它们可以以最小的努力以高度可维护的方式显示错误。

引言

我最近开始了一个新项目,与我合作的两位开发人员只工作了几个月 WPF;他们还继承了一个需要处理的代码库。我接到的任务是创建一个简单的表单,该表单在网格中的按钮被按下时显示。我利用这个任务来创建基础设施,以使开发和维护更加容易。其中一个基础设施就是 ViewModelabstract 类。最初,我实现了这个抽象类来支持 INotifyPropertyChanged。在基本功能工作正常后,我开始着手 IDataErrorInfo

我以前做过类似的事情,但使用了 Microsoft 的 Enterprise Library。有趣的是,现有的ViewModel 已经与属性相关联了验证特性,只是没有接口允许视图显示错误。

我有一个大致的想法,想要一个处理验证的标准,而 IDataErrorInfo 和验证特性的使用似乎是可行的方法。IDataErrorInfointerface 如下:

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 执行验证方法。该方法需要编写成返回一个非空 stringnull(如果没有错误),否则返回一个与错误通知关联的错误消息。为此,IsValid 方法将 ErrorMessage 属性设置为使用 Reflection 找到的方法的返回值。

我发现 MethodValidationAttribute 有一个问题,这在示例中很明显。如果正在验证的两个属性相互依赖,那么就可能出现这种情况:一个字段显示错误,而另一个字段已更正,但第一个字段的错误仍然显示,而且如果一个属性的值更改为错误条件,它也不会显示在依赖的属性中。

必需枚举属性验证器

解决方案中还包含另一个自定义验证器,即 RequiredEnumAttribute。之所以需要创建它,是因为在某些情况下,枚举绑定到 ComboBox,并且初始值为空,并且没有定义“0”枚举。框架的 RequiredAttribute 无法捕获此问题,因此我创建了这个类来处理此情况。如果您遇到 RequiredAttribute 对枚举绑定不起作用的问题,请尝试此装饰器。请参阅 Required Enumeration Validation Attribute

与 DataErrorInfoAttributeValidator 的接口

有两个区域需要编程才能使用 DataErrorInfoAttributeValidator 类:ViewModelView

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 实现 INotifyPropertyChangedIDataErrorInfo。在我正在处理的项目中,没有使用任何框架,所以我没有任何支持 WPF 开发的基类,而大多数重要项目都会使用这些基类。我知道代码仍然存在一些问题,但如果您不使用框架来帮助处理 INotifyPropertyChanged,那么您可能想借用此代码。

XAML

下一部分是 View 中需要执行的操作,以便绑定到错误信息。

<TextBox Text="{Binding Value,
         UpdateSourceTrigger=PropertyChanged,
         ValidatesOnDataErrors=True}" />

我只展示了 TextBoxText 的绑定。将 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 文件中有代码,并在最后一组 TextBoxes 中使用。然后,它在 Style 中使用方式如下:

<Style x:Key="Version4"
       TargetType="{x:Type Control}">
  <Setter Property="Validation.ErrorTemplate"
          Value="{StaticResource ValidationTooltipTemplate}" />
</Style>

您还会注意到,还有一个用于 ToolTIpTemplate。在此 Template 中,ToolTipBackground 被定义为红色,Foreground 被定义为白色。

BaseViewModel

为了让代码很容易集成到项目中,我创建了一个 BaseViewModel,它实现了  INotifyPropertyChangedIDataErrorInfo

 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 中的两个相同属性(属性 KeyValue)。还有一个按钮,只有在没有错误时才启用。为了做到这一点,我对 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:向该代码添加了一个专门的 RangeValidatorRangeUnitValidator),该验证器带有一个 Unit 参数。
© . All rights reserved.