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

WPF MVVM 应用程序中的基于属性的验证

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.88/5 (30投票s)

2010年7月28日

CPOL

6分钟阅读

viewsIcon

233368

downloadIcon

7187

描述了一种使用属性在 WPF MVVM 应用程序中执行用户输入验证的方法

引言

在本文中,我将分享一个简单的 MVVM 应用程序,它使用基于特性的方法进行验证。基本上,这意味着您将能够使用以下语法来描述验证规则

[Required(ErrorMessage = "Field 'FirstName' is required.")]
public string FirstName
{
    get
    {
        return this.firstName;
    }
    set
    {
        this.firstName = value;
        this.OnPropertyChanged("FirstName");
    }
}

这是演示应用程序运行时的截图

演示应用程序的 UI 需求如下

  • 在 3 个不同的选项卡中填写各种信息,并在字段不正确时向用户提供有意义的错误消息和反馈

  • 使用 3 种方法实时反馈填写过程的完成度
    • 当一个选项卡完全填写完毕后,其颜色会从红色变为绿色

    • 使用进度条向用户显示进度

    • 当所有内容都填写完毕后,“保存”按钮将被激活

背景

WPF 提供了几种技术来验证用户在应用程序中的输入。

ValidationRules

摘自 MSDN 关于 ValidationRules 的文章

当您使用 WPF 数据绑定模型时,您可以将 ValidationRules 与您的绑定对象关联。要创建自定义规则,需要创建此类的子类并实现 Validate 方法。每当绑定引擎将输入值(即绑定目标属性值)传输到绑定源属性时,它都会检查与绑定关联的每个 ValidationRule

IDataErrorInfo

摘自 Marianor 关于将特性与 IDataErrorInfo 接口结合使用的原始博客文章

WPF 通过 IDataErrorInfo 接口为绑定场景提供了另一种验证基础结构。基本上,您必须实现 Item[columnName] 属性,为每个需要验证的属性放入验证逻辑。在 XAML 中,您需要将 ValidatesOnDataErrors 设置为 true,并决定何时希望绑定调用验证逻辑(通过 UpdateSourceTrigger)。

结合 IDataErrorInfo 和特性

在本文中,我们使用一种结合了验证特性(对每种验证特性的解释超出了本文的范围)和 IDataErrorInfo 接口的技术。

总体设计

这是应用程序类的类图

这里并没有什么真正的新东西。MainWindow 的 Content 被设置为 MainFormView 视图。其关联的 ViewModel,即 MainFormViewModel ,管理一组 FormViewModel,这些 FormViewModel 是视图中每个选项卡的基 ViewModel 类。

为了指定 WPF 引擎如何渲染 FormViewModelBase,我们所需要做的就是创建一个 DataTemplate 并给出正确的 TargetType。例如,为了将 ProfileFormViewModel ProfileFormView 关联起来,我们创建了这个 DataTemplate

<DataTemplate DataType="{x:Type ViewModel:ProfileFormViewModel}">
<View:ProfileFormView />
</DataTemplate>

然后,DataBinding 会使用这个非常简单的 XAML 来完成填充 TabControl 内容的所有工作

<TabControl ItemsSource="{Binding Forms}" />

这与我几个月前在博客中介绍的一种方法非常相似(更多细节请参见这里)。新内容在于 ValidationViewModelBase 类,这是我为需要支持验证的 ViewModel 类引入的新基类。这也是本文下一节的目标。

基于特性的验证和 IDataError 接口

我在这里使用的结合特性和 IDataError 接口的解决方案是基于 Marianor 的工作(完整文章在此)。我对他的代码做了一些调整,以便得到一个通用的解决方案(这也是 ValidationViewModelBase 类的目标)。其基本思想是能够使用 System.ComponentModel 中的特性,以一种通用的方式实现 IDataErrorInfo 接口(及其两个属性)。该类广泛使用 LINQ 来执行验证。

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Reflection;

using WpfFormsWithValidationDemo.Toolkit.Behavior;

namespace WpfFormsWithValidationDemo.Toolkit.ViewModel
{
    /// <summary>
    /// A base class for ViewModel classes which supports validation 
    /// using IDataErrorInfo interface. Properties must defines
    /// validation rules by using validation attributes defined in 
    /// System.ComponentModel.DataAnnotations.
    /// </summary>
    public class ValidationViewModelBase : ViewModelBase, 
		IDataErrorInfo, IValidationExceptionHandler
    {
        private readonly Dictionary<string, 
		Func<ValidationViewModelBase, object>> propertyGetters;
        private readonly Dictionary<string, ValidationAttribute[]> validators;

        /// <summary>
        /// Gets the error message for the property with the given name.
        /// </summary>
        /// <param name="propertyName">Name of the property</param>
        public string this[string propertyName]
        {
            get
            {
                if (this.propertyGetters.ContainsKey(propertyName))
                {
                    var propertyValue = this.propertyGetters[propertyName](this);
                    var errorMessages = this.validators[propertyName]
                        .Where(v => !v.IsValid(propertyValue))
                        .Select(v => v.ErrorMessage).ToArray();

                    return string.Join(Environment.NewLine, errorMessages);
                }

                return string.Empty;
            }
        }

        /// <summary>
        /// Gets an error message indicating what is wrong with this object.
        /// </summary>
        public string Error
        {
            get
            {
                var errors = from validator in this.validators
                             from attribute in validator.Value
                             where !attribute.IsValid(this.propertyGetters
				[validator.Key](this))
                             select attribute.ErrorMessage;

                return string.Join(Environment.NewLine, errors.ToArray());
            }
        }

        /// <summary>
        /// Gets the number of properties which have a 
        /// validation attribute and are currently valid
        /// </summary>
        public int ValidPropertiesCount
        {
            get
            {
                var query = from validator in this.validators
                            where validator.Value.All(attribute => 
			  attribute.IsValid(this.propertyGetters[validator.Key](this)))
                            select validator;

                var count = query.Count() - this.validationExceptionCount;
                return count;
            }
        }

        /// <summary>
        /// Gets the number of properties which have a validation attribute
        /// </summary>
        public int TotalPropertiesWithValidationCount
        {
            get
            {
                return this.validators.Count();
            }
        }

        public ValidationViewModelBase()
        {
            this.validators = this.GetType()
                .GetProperties()
                .Where(p => this.GetValidations(p).Length != 0)
                .ToDictionary(p => p.Name, p => this.GetValidations(p));

            this.propertyGetters = this.GetType()
                .GetProperties()
                .Where(p => this.GetValidations(p).Length != 0)
                .ToDictionary(p => p.Name, p => this.GetValueGetter(p));
        }

        private ValidationAttribute[] GetValidations(PropertyInfo property)
        {
            return (ValidationAttribute[])property.GetCustomAttributes
			(typeof(ValidationAttribute), true);
        }

        private Func<ValidationViewModelBase, object> 
			GetValueGetter(PropertyInfo property)
        {
            return new Func<ValidationViewModelBase, object>
			(viewmodel => property.GetValue(viewmodel, null));
        }

        private int validationExceptionCount;

        public void ValidationExceptionsChanged(int count)
        {
            this.validationExceptionCount = count;
            this.OnPropertyChanged("ValidPropertiesCount");
        }
    }
}

请注意,我还公开了有效属性的数量和带验证属性的总数(这用于计算进度条的值)。从开发者的角度来看,在现有的 ViewModel 中使用这个类非常直接:继承自新的 ValidationViewModelBase 类,而不是你传统的 ViewModelBase 类,然后在需要验证的属性上添加验证特性。

可用特性 (来自 System.ComponentModel.DataAnnotations 命名空间)

名称 描述
RequiredAttribute 指定数据字段值为必需项
RangeAttribute 指定数据字段值的数值范围约束
StringLengthAttribute 指定数据字段中允许的最小和最大字符长度
RegularExpressionAttribute 指定数据字段值必须与指定的正则表达式匹配
CustomValidationAttribute 指定在运行时调用的自定义验证方法(您必须实现 IsValid() 方法)

处理验证异常

在使用这种基于特性的新方法时,我面临一个问题:如何处理验证异常。当用户输入不正确时,就会发生验证异常,例如,如果一个 TextBox Text 属性绑定到一个 int 属性,那么像“abc”这样的输入(当然不能转换为 int)就会引发异常。

我提出的方法是基于一种行为(behavior)。关于行为的完整描述超出了本文的范围。有关精彩的描述,您可以查看这篇文章

我提出的行为必须附加到包含可能引发异常的输入控件的父 UI 元素上。这在 XAML 中只需几行代码

 <Grid>
  <i:Interaction.Behaviors>
    <Behavior:ValidationExceptionBehavior />
  </i:Interaction.Behaviors>

  <!-- rest of the code... -->

当此行为加载时,它会为 ValidationError.ErrorEvent RoutedEvent 添加一个处理程序,以便在每次发生验证错误时得到通知。发生这种情况时,该行为会调用 ViewModel 上的一个方法(通过一个简单的接口来限制耦合),这样 ViewModel 就可以跟踪验证错误的数量。以下是该行为的代码

using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interactivity;

namespace WpfFormsWithValidationDemo.Toolkit.Behavior
{
    /// <summary>
    /// A simple behavior that can transfer the number of 
    /// validation error with exceptions
    /// to a ViewModel which supports the INotifyValidationException interface
    /// </summary>
    public class ValidationExceptionBehavior : Behavior<FrameworkElement>
    {
        private int validationExceptionCount;

        protected override void OnAttached()
        {
            this.AssociatedObject.AddHandler(Validation.ErrorEvent, 
		new EventHandler<ValidationErrorEventArgs>(this.OnValidationError));
        }

        private void OnValidationError(object sender, ValidationErrorEventArgs e)
        {
            // we want to count only the validation error with an exception
            // other error are handled by using the attribute on the properties
            if (e.Error.Exception == null)
            {
                return;
            }

            if (e.Action == ValidationErrorEventAction.Added)
            {
                this.validationExceptionCount++;
            }
            else
            {
                this.validationExceptionCount--;
            }

            if (this.AssociatedObject.DataContext is IValidationExceptionHandler)
            {
                // transfer the information back to the viewmodel
                var viewModel = (IValidationExceptionHandler)
				this.AssociatedObject.DataContext;
                viewModel.ValidationExceptionsChanged(this.validationExceptionCount);
            }
        }
    }
}

进度报告

我的一个需求是“当一个选项卡完全填写完毕后,其颜色会从红色变为绿色”。为了实现这个特定功能,我在 FormViewModelBase 类(它是 TabControl 中所有 ViewModel 的基类)中添加了一个“IsValid”属性。每当发生 PropertyChanged 时,此属性就会通过检查 Error 属性(IDataErrorInfo 接口的属性)是否为空来更新

protected override void PropertyChangedCompleted(string propertyName)
{
    // test prevent infinite loop while settings IsValid
    // (which causes an PropertyChanged to be raised)
    if (propertyName != "IsValid")
    {
        // update the isValid status
        if (string.IsNullOrEmpty(this.Error) &&
        	this.ValidPropertiesCount == this.TotalPropertiesWithValidationCount)
        {
            this.IsValid = true;
        }
        else
        {
            this.IsValid = false;
        }
    }
}

然后在 XAML 中使用一个简单的触发器就足以实现我所描述的视觉效果

<Style TargetType="{x:Type TabItem}">
  <Setter Property="HeaderTemplate">
    <Setter.Value>
      <DataTemplate>
        <StackPanel Orientation="Horizontal">
          <TextBlock Text="{Binding FormName}" VerticalAlignment="Center" Margin="2" />
          <Image x:Name="image"
                 Height="16"
                 Width="16"
                 Margin="3"
                 Source="../Images/Ok16.png"/>
        </StackPanel>
        <DataTemplate.Triggers>
          <DataTrigger Binding="{Binding IsValid}" Value="False">
            <Setter TargetName="image" 
		Property="Source" Value="../Images/Warning16.png" />
          </DataTrigger>
        </DataTemplate.Triggers>
      </DataTemplate>
    </Setter.Value>
  </Setter>
</Style>

为了让进度条工作,我在父 ViewModel (拥有各种 FormViewModelBase ViewModel 的那个)中计算总体进度

/// <summary>
/// Gets a value indicating the overall progress (from 0 to 100) 
/// of filling the various properties of the forms.
/// </summary>
public double Progress
{
    get
    {
        double progress = 0.0;
        var formWithValidation = this.Forms.Where(f => 
		f.TotalPropertiesWithValidationCount != 0);
        var propertiesWithValidation = this.Forms.Sum(f => 
		f.TotalPropertiesWithValidationCount);

        foreach (var form in formWithValidation)
        {
            progress += (form.ValidPropertiesCount * 100.0) / propertiesWithValidation;
        }

        return progress;
    }
}

关注点

正如我在引言中所说,这种技术的目标是替代传统的 ValidationRules 方法,后者会使 XAML 变得非常复杂。使用 LINQ 和特性是实现这种新可能性的一种很好的方式。在快要写完这篇文章时,我注意到 Mark Smith 开发的一个著名的 MVVM 框架(MVVM Helpers)中也提供了类似的解决方案。

致谢

我要衷心感谢帮助我审阅这篇文章的人们:我的同事 Charlotte 和 Sacha Barber(CodeProject 和微软 MVP)。

历史

  • 2010年7月28日:初始版本
© . All rights reserved.