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

使用 Calcium 进行 UWP 表单验证

starIconstarIconstarIconstarIconstarIcon

5.00/5 (6投票s)

2017年1月22日

CPOL

9分钟阅读

viewsIcon

17757

downloadIcon

227

为基于 XAML 的应用程序实现同步和异步表单验证。

引言

在本文中,您将了解如何在 UWP 应用程序中执行同步和异步表单验证。您将看到如何以声明和编程方式设置用于验证的视图模型属性。您将了解如何在一个方法中定义验证逻辑,或者将验证逻辑驻留在远程服务器上。您还将了解如何使用 UWP 字典索引器将表单验证错误绑定到视图中的控件。然后,您将探索验证系统的内部工作原理,并了解 Calcium 如何将验证与视图模型解耦,以便它可以与任何实现 INotifyPropertyChanged 的对象一起使用。

背景

几年前,我写过一篇关于基于 XAML 的应用程序表单验证的各种方法的文章,它收录在我的两本 Windows Phone Unleased 书籍中。该书章节涵盖了当时可用的各种方法:简单的基于异常的验证和更灵活的基于 INotifyDataErrorInfo 接口的方法,该方法在 Silverlight 4 中引入。在撰写该章节期间,我开发了一个 API,它不仅可以轻松执行同步验证,还可以执行异步验证。此后,我将代码发布在 Calcium MVVM 框架中,该框架适用于 WPF、Windows Phone、UWP、Xamarin Android 和 iOS。只需在 NuGet 上搜索 Calcium,或从包管理器控制台键入 "Install-Package Calcium"。可下载的示例包含了一个简单的示例,说明如何使用 Calcium 对 UWP 应用程序执行同步和异步表单验证。

入门

Calcium 框架依赖于 IoC 容器和一些 IoC 注册,用于日志记录、松耦合消息传递、设置以及所有那些在相当复杂的应用程序中最终需要的东西。如果您知道自己在做什么,您可以“手动”初始化 Calcium 的基础设施。但是,初始化 Calcium 的简单方法是使用 Outcoder.ApplicationModel.CalciumSystem 类。

在可下载的示例项目中,我首先在 App.xaml.cs 文件中初始化 Calcium,如下所示

var calciumSystem = new CalciumSystem();
calciumSystem.Initialize();

对 Initialize 的调用在 App 类的 OnLaunched 方法中执行。如果 rootFrame 为 null,则进行调用,这表示应用程序是从冷启动启动的,并且之前未调用 CalciumSystem.Initialize。需要在创建 rootFrame 后初始化 Calcium,以便它可以执行导航监控;除其他外,这允许 Calcium 自动保存视图模型的状态。

注意:如果 Calcium 找不到您的根 Frame 对象,它将尝试重试五次,然后放弃。重试为您的应用程序提供了初始化时间。

在 Calcium 中,您子类化 Outcoder.ComponentModel.ViewModelBase 类,以提供对您自己的视图模型的一系列服务的访问,例如属性更改通知、状态管理以及当然还有属性验证。

在示例中,MainPageViewModelMainPage 类的视图模型。

public class MainPageViewModel : ViewModelBase
{
…
}

MainPageViewModel 包含几个属性。我们将使用两个属性演示验证:TextField1TextField2ViewModelBase 类包含一个 Assign 方法,该方法自动在 UI 线程上引发属性更改事件,并向验证系统发出值已更改的信号。请参见以下内容

[Validate]

public string TextField1
{
       get
       {
              return textField1;
       }
       set
       {
              Assign(ref textField1, value);
       }
}

顺便说一句,几年前,我选择的方法名称是“Assign”而不是“Set”,因为 Set 是 Visual Basic 关键字。其他一些框架使用 Set,但我不想与非 CLS 合规性纠缠不清。

您可以通过使用 [Validate] 属性修饰属性,或者通过调用 ViewModelBase 类的 AddValidationProperty 方法来提名属性进行验证,如下所示

public MainPageViewModel()
{
	AddValidationProperty(() => TextField2);

	…
}

当发生 PropertyChanged 事件时,验证系统会启动,并调用您重写的 GetPropertyErrors 方法,或者您重写的 ValidateAsync 方法;这取决于您是否需要异步支持,例如在调用远程服务器进行验证时。

如果您只需要同步验证,即您不需要远程验证数据或使用可能具有高延迟的数据库调用,则重写 GetPropertyErrors。请参见清单 1。

GetPropertyErrors 返回一个 IEnumerable<datavalidationerrors>,它在后台合并到一个字典中,可用于在视图中显示错误。稍后您会看到更多内容。

创建 DataValidationError 时,您会注意到每个错误都关联一个整数值。对于示例中的 TextField1,1 表示错误的 ID 为 1。由于一个字段可能有多个验证错误,ID 允许我们在需要时专门引用特定的验证错误,而无需依赖错误消息文本。

清单 1. MainPageViewModel GetPropertyErrors 方法。

protected override IEnumerable<DataValidationError> GetPropertyErrors(
       string propertyName, object value)
{

       var result = new List<DataValidationError>();

       switch (propertyName)
       {

              case nameof(TextField1):
                     if (textField1.Length < 5)
                     {
                           result.Add(new DataValidationError(
                                  1, "Length must be greater than 5"));
                     }
                     break;
              case nameof(TextField2):
                     if (TextField2 != "Foo")
                     {
                           result.Add(new DataValidationError(
                                  2, "Content should be 'Foo'"));
                     }
                     break;
       }

       return result;
}

 

异步验证以相同的方式执行。但是,您重写的是 ValidateAsync 方法,而不是 ViewModelBaseGetPropertyErrors 方法。请参见清单 2。

注意:重写 ValidateAsync 方法会导致 GetPropertyErrors 方法被忽略。

在示例中,我们通过等待 Task.Delay 来模拟一个可能长时间运行的异步验证。结果使用 ValidationCompleteEventArgs 实例返回,该实例可以包含指定属性的验证列表或错误;表示验证由于某种原因失败。

清单 2. MainViewModel ValidateAsync 方法。

public override async Task<ValidationCompleteEventArgs> ValidateAsync(
                                            string propertyName, object value)
{
       var errorList = new List<DataValidationError>();

       switch (propertyName)
       {
              case nameof(TextField1):
                     if (textField1.Length < 5)
                     {
                           TextField1Busy = true;
                                        
                           errorList.Add(new DataValidationError(
                                  1, "Length must be greater than 5"));
                     }

                     /* Simulate asynchronous validation. */
                     await Task.Delay(1000);

                     TextField1Busy = false;
                     break;

              case nameof(TextField2):
                     …
                     break;
       }

       var result = new ValidationCompleteEventArgs(propertyName, errorList);
       return result;
}

 

有时属性之间存在相互依赖性,您需要一起验证多个属性。为此,重写 ViewModelBase 类的 ValidateAllAsync 方法,并为每个带有错误的属性调用底层 DataErrorNotifier.SetPropertyErrors 方法。

显示验证错误

以不中断用户工作流程的方式显示验证错误非常重要。在示例中,我使用 ListBox 在每个文本字段下方显示验证错误。请参见清单 3。幸运的是,ListBox 会根据是否显示任何错误而愉快地展开和收缩。

ListBox 的 ItemSource 属性绑定到关联属性的验证错误集合。字典索引器用于使用属性名称作为键检索集合。

注意:绑定到字典索引器是 Windows 10 年度更新中的新功能,在早期版本的 Windows 10 中将不起作用。另请注意,它有一些限制。其中一个限制是,在我的测试中,绑定到 IReadOnlyDictionary 的自定义实现失败了。绑定基础设施似乎期望一个具体的 ReadOnlyDictionary 实例。因此,我不得不重新设计 DataErrorNotifier 类以支持绑定到 ValidationErrors 属性。另一个重要的限制是字典索引器只支持字符串文字。

ProgressRing 控件用于指示验证正在进行中。每个 ProgressRing 控件的 IsActive 属性都绑定到相应的视图模型属性。

TextBox 控件仅在失去输入焦点时更新其绑定源属性。与 WPF 和其他 XAML 实现不同,TextBox 不提供 UpdateSourceTrigger 绑定属性。因此,我使用 Calcium 的 UpdateSourceTriggerExtender 附加属性,该属性在 TextBoxText 属性更改时将更新推送到源属性。

UpdateSourceTriggerExtender 附加属性适用于 TextBoxPasswordBox 控件。

注意:Calcium 的 UpdateSourceTriggerExtender 附加属性不适用于 x:Bind 表达式。您必须使用传统的 Binding 标记扩展。

ViewModelBase 类公开了一个 ValidationErrors 属性。

清单 3. MainPage.xaml 摘录

<TextBlock Text="TextField1" Style="{StaticResource LabelStyle}" />
<StackPanel Orientation="Horizontal">
       <TextBox Text="{Binding TextField1, Mode=TwoWay}"
                xaml:UpdateSourceTriggerExtender.UpdateSourceOnTextChanged="True"
                Style="{StaticResource TextFieldStyle}" />

       <ProgressRing IsActive="{x:Bind ViewModel.TextField1Busy, Mode=OneWay}"
                     Style="{StaticResource ProgressRingStyle}" />
</StackPanel>
<ListBox
       ItemsSource="{x:Bind ViewModel.ValidationErrors['TextField1']}"
       Style="{StaticResource ErrorListStyle}" />

<TextBlock Text="TextField2" Style="{StaticResource LabelStyle}" />
<StackPanel Orientation="Horizontal">
       <TextBox Text="{Binding TextField2, Mode=TwoWay}"
                xaml:UpdateSourceTriggerExtender.UpdateSourceOnTextChanged="True"
                Style="{StaticResource TextFieldStyle}" />

       <ProgressRing IsActive="{x:Bind ViewModel.TextField2Busy, Mode=OneWay}"
                     Style="{StaticResource ProgressRingStyle}" />
</StackPanel>

<ListBox
       ItemsSource="{x:Bind ViewModel.ValidationErrors['TextField2']}"
       Style="{StaticResource ErrorListStyle}" />
           
<Button Command="{x:Bind ViewModel.SubmitCommand}"
        Content="Submit"
        Margin="0,12,0,0"/>

 

表单包含一个“提交”按钮,它模拟将数据发送到某个远程服务。该按钮绑定到 MainViewModel 类的 SubmitCommand

SubmitCommand 是一个 Calcium DelegateCommand。它在 MainPageViewModel 的构造函数中实例化,如下所示

public MainPageViewModel()
{
       AddValidationProperty(() => TextField2);

       submitCommand = new DelegateCommand(Submit, IsSubmitEnabled);

       ErrorsChanged += delegate { submitCommand.RaiseCanExecuteChanged(); };
}

提供给 submitCommand 构造函数的两个参数是一个动作 IsSubmitEnabled,它决定按钮是否应该启用;以及一个 Submit 动作,它执行按钮的主要动作。

IsSubmitEnabled 只是检查表单是否有任何错误,如下所示

bool IsSubmitEnabled(object arg)
{
       return !HasErrors;
}

Submit 方法验证属性并使用 Calcium 的 DialogService 显示一条简单消息,如下所示

async void Submit(object arg)
{
       await ValidateAllAsync(false);

       if (HasErrors)
       {
              return;
       }

       await DialogService.ShowMessageAsync("Form submitted.");
}

注意:Calcium 框架还支持异步命令的概念,但这超出了本文的范围。

ViewModelBase 类的 ErrorsChanged 事件使我们有机会刷新 submitCommand 的启用状态。DelegateCommandRaiseCanExecuteChanged 方法调用 IsSubmitEnabled 方法,并且按钮的 IsEnabled 属性会更新。

幕后

当 Calcium 的 ViewModelBase 类实例化时,它会创建一个 DataErrorNotifier 实例;将自身传递给 DataErrorNotifier 类的构造函数。请参见清单 4。

DataErrorNotifier 需要一个实现 INotifyPropertyChanged 的对象,以及一个实现 Calcium 的 IValidateData 接口的对象。IValidateData 包含一个方法定义

Task<ValidationCompleteEventArgs> ValidateAsync(string memberName, object value);

ValidateAsync 是我们之前实现的方法,负责验证每个属性。

通过分离 INotifyPropertyChanged 所有者和 IValidateData 对象,我们有效地将验证与视图模型解耦。因此,如果我们愿意,我们可以在应用程序中提供一个完全独立的验证子系统。此外,验证不仅限于视图模型。您可以使用 DataErrorNotifier 为任何实现 INotifyPropertyChanged 的对象提供验证。

清单 4. DataErrorNotifier 构造函数

public DataErrorNotifier(INotifyPropertyChanged owner, IValidateData validator)
{
       this.validator = ArgumentValidator.AssertNotNull(validator, "validator");
       this.owner = ArgumentValidator.AssertNotNull(owner, "owner");

       owner.PropertyChanged += HandleOwnerPropertyChanged;

       ReadValidationAttributes();
}

读取所有者对象的验证属性涉及检索类中每个属性的 PropertyInfo 对象,并使用 GetCustomAttributes 查找 ValidateAttribute 属性是否存在。请参见清单 5。如果属性用 Validate 属性修饰,则将其添加到已验证属性列表中。

 

清单 5. DataErrorNotifier ReadValidationAttributes 方法

void ReadValidationAttributes()
{
       var properties = owner.GetType().GetTypeInfo().DeclaredProperties;

       foreach (PropertyInfo propertyInfo in properties)
       {
              var attributes = propertyInfo.GetCustomAttributes(
                                        typeof(ValidateAttribute), true);

              if (!attributes.Any())
              {
                     continue;
              }

              if (!propertyInfo.CanRead)
              {
                     throw new InvalidOperationException(string.Format(
                           "Property {0} must have a getter to be validated.",
                           propertyInfo.Name));
              }

              /* Prevents access to internal closure warning. */
              PropertyInfo info = propertyInfo;

              AddValidationProperty(
                     propertyInfo.Name, () => info.GetValue(owner, null));
       }
}

 

如果您选择使用 AddValidationProperty 方法添加属性,则使用 MethodInfo 对象的 CreateDelegate 创建一个委托。请参见清单 6。创建委托而不是依赖 PropertyInfo 对象的 GetValue 方法可能会提高性能。这是因为通过反射检索或设置值通常很慢。但是请注意,我尚未为 Validate 属性实现这一点。

清单 6. DataErrorNotifier AddValidationProperty 方法

public void AddValidationProperty(Expression<Func<object>> expression)
{
       PropertyInfo propertyInfo = PropertyUtility.GetPropertyInfo(expression);
       string name = propertyInfo.Name;
       MethodInfo getMethodInfo = propertyInfo.GetMethod;
       Func<object> getter = (Func<object>)getMethodInfo.CreateDelegate(
                                                               typeof(Func<object>),
                                                               this);
       AddValidationProperty(name, getter);
}

当所有者对象的属性更改时。也就是说,当其 PropertyChanged 事件引发时,将调用 DataErrorNotifier 对象的 BeginGetPropertyErrorsFromValidator,如下所示

 

async void HandleOwnerPropertyChanged(object sender, PropertyChangedEventArgs e)
{
       if (e?.PropertyName == null)
       {
              return;
       }

       await BeginGetPropertyErrorsFromValidator(e.PropertyName);
}

 

如果要验证更改的属性,则 BeginGetPropertyErrorsFromValidator 会在 IValidateData 实例上调用 ValidateAsync。请参见清单 7。

清单 7. DataErrorNotifier BeginGetPropertyErrorsFromValidator 方法

async Task<ValidationCompleteEventArgs> BeginGetPropertyErrorsFromValidator(string propertyName)
{
       Func<object> propertyFunc;

       lock (propertyDictionaryLock)
       {
              if (!propertyDictionary.TryGetValue(propertyName, out propertyFunc))
              {
                     /* No property registered with that name. */
                     return new ValidationCompleteEventArgs(propertyName);
              }
       }

       var result = await validator.ValidateAsync(propertyName, propertyFunc());
       ProcessValidationComplete(result);

       return result;
}

 

BeginGetPropertyErrorsFromValidator 返回其结果之前,ValidationCompleteEventArgs 对象被传递给 ProcessValidationComplete 方法,该方法通过 SetPropertyErrors 方法将任何产生的验证错误添加到错误集合中。请参见清单 8。

清单 8. DataErrorNotifier ProcessValidationComplete 方法

void ProcessValidationComplete(ValidationCompleteEventArgs e)
{
       try
       {
              if (e.Exception == null)
              {
                     SetPropertyErrors(e.PropertyName, e.Errors);
              }
       }
       catch (Exception ex)
       {
              var log = Dependency.Resolve<ILog>();
              log.Debug("Unable to set property error.", ex);
       }
}

 

SetPropertyErrors 使用验证错误填充 ObservableCollection<DataValidationError>。请参见清单 9。使用 ObservableCollection 是因为它使在 UI 中显示验证更改变得轻而易举。您只需绑定到 ValidationErrors 字典中的属性名称,如清单 3 所示。

清单 9. DataErrorNotifier SetPropertyErrors 方法

public void SetPropertyErrors(
       string propertyName, IEnumerable<DataValidationError> dataErrors)
{
       ArgumentValidator.AssertNotNullOrEmpty(propertyName, "propertyName");

       bool raiseEvent = false;

       lock (errorsLock)
       {
              bool created = false;

              var errorsArray = dataErrors as DataValidationError[] ?? dataErrors?.ToArray();
              int paramErrorCount = errorsArray?.Length ?? 0;

              if ((errorsField == null || errorsField.Count < 1)
                     && paramErrorCount < 1)
              {
                     return;
              }

              if (errorsField == null)
              {
                     errorsField = new Dictionary<string, ObservableCollection<DataValidationError>>();
                     created = true;
              }

              bool listFound = false;
              ObservableCollection<DataValidationError> list;

              if (created || !(listFound = errorsField.TryGetValue(propertyName, out list)))
              {
                     list = new ObservableCollection<DataValidationError>();
              }

              if (paramErrorCount < 1)
              {
                     if (listFound)
                     {
                           list?.Clear();
                           raiseEvent = true;
                     }
              }
              else
              {
                     var tempList = new List<DataValidationError>();

                     if (errorsArray != null)
                     {
                           foreach (var dataError in errorsArray)
                           {
                                  if (created || list.SingleOrDefault(
                                                e => e.Id == dataError.Id) == null)
                                  {
                                         tempList.Add(dataError);
                                         raiseEvent = true;
                                  }
                           }
                     }

                     list.AddRange(tempList);
                     errorsField[propertyName] = list;
              }
       }

       if (raiseEvent)
       {
              OnErrorsChanged(propertyName);
       }
}

 

结论

在本文中,您了解了如何在 UWP 应用程序中执行同步和异步表单验证。您了解了如何以声明和编程方式设置用于验证的视图模型属性。您研究了如何在一个方法中定义验证逻辑或将验证逻辑驻留在远程服务器上。您还了解了如何使用 UWP 字典索引器将表单验证错误绑定到视图中的控件。然后,您探索了验证系统的内部工作原理,并了解了 Calcium 如何将验证与视图模型解耦,以便它可以与任何实现 INotifyPropertyChanged 的对象一起使用。

我希望这个项目对您有用。如果觉得有用,请评分和/或在下方留言。

历史

2017 年 1 月 22 日

  • 首次发布
© . All rights reserved.