使用 Calcium 进行 UWP 表单验证





5.00/5 (6投票s)
为基于 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
类,以提供对您自己的视图模型的一系列服务的访问,例如属性更改通知、状态管理以及当然还有属性验证。
在示例中,MainPageViewModel
是 MainPage
类的视图模型。
public class MainPageViewModel : ViewModelBase
{
…
}
MainPageViewModel
包含几个属性。我们将使用两个属性演示验证:TextField1
和 TextField2
。ViewModelBase
类包含一个 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
方法,而不是 ViewModelBase
的 GetPropertyErrors
方法。请参见清单 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
附加属性,该属性在 TextBox
的 Text
属性更改时将更新推送到源属性。
UpdateSourceTriggerExtender
附加属性适用于 TextBox
和 PasswordBox
控件。
注意: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
的启用状态。DelegateCommand
的 RaiseCanExecuteChanged
方法调用 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 日
- 首次发布