WPF 中的异步验证






4.77/5 (8投票s)
MVVM (WPF) 的异步验证。
源代码
引言
通常,验证需要网络请求、数据库调用或其他需要大量时间的操作。在这种情况下,UI 应该在验证期间保持响应,但应禁用保存/提交数据,直到验证完成。
本文提供了一个解决此问题的方案。
辅助函数
PropertyHelper
用于获取属性名称。
public class PropertyHelper
{
public static string GetPropertyName<T>(Expression<Func<T>> propertyLambda)
{
var me = propertyLambda.Body as MemberExpression;
if (me == null)
{
throw new ArgumentException("You must pass a lambda of the form: '() => Class.Property' or '() => object.Property'");
}
return me.Member.Name;
}
}
可验证的视图模型
ValidatableViewModel
实现 INotifyDataErrorInfo
接口,以便能够在绑定中使用 ValidatesOnNotifyDataErrors
并显示错误。
IsValidating
属性显示验证仍在进行中。
只有当所有属性都有效且没有其他后台验证正在进行时,IsValid
属性才会被设置为 true。
RegisterValidator
注册属性的验证函数。函数如果没有任何错误,则应返回一个空列表,否则返回一个错误列表。属性只能有一个验证器。如果添加另一个验证器,先前添加的验证器将被移除。
public abstract class ValidatableViewModel : INotifyDataErrorInfo, INotifyPropertyChanged
{
private bool _isValidating;
public bool IsValidating
{
get { return _isValidating; }
protected set { Set(ref _isValidating, value); }
}
private bool _isValid = true;
public bool IsValid
{
get { return _isValid; }
protected set { Set(ref _isValid, value); }
}
private readonly Dictionary<string, List<string>> _validationErrors = new Dictionary<string, List<string>>();
private readonly Dictionary<string, Guid> _lastValidationProcesses = new Dictionary<string, Guid>();
private readonly Dictionary<string, Func<Task<List<string>>>> _validators = new Dictionary<string, Func<Task<List<string>>>>();
protected ValidatableViewModel()
{
PropertyChanged += (sender, args) => Validate(args.PropertyName);
}
#region INotifyDataErrorInfo
public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
public IEnumerable GetErrors(string propertyName)
{
if (string.IsNullOrEmpty(propertyName) || !_validationErrors.ContainsKey(propertyName))
{
return new List<string>();
}
return _validationErrors[propertyName];
}
public bool HasErrors => _validationErrors.Count > 0;
#endregion
#region INotifyPropertyChanged
public event PropertyChangedEventHandler PropertyChanged;
#endregion
public List<string> GetErrors()
{
return _validationErrors.SelectMany(p => p.Value).ToList();
}
protected void Set<T>(ref T storage, T value, [CallerMemberName] string property = null)
{
if (Equals(storage, value))
{
return;
}
storage = value;
RaisePropertyChanged(property);
}
protected void RaisePropertyChanged(string property)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(property));
}
protected void RegisterValidator<TProperty>(Expression<Func<TProperty>> propertyExpression, Func<Task<List<string>>> validatorFunc)
{
RegisterValidator(PropertyHelper.GetPropertyName(propertyExpression), validatorFunc);
}
protected void RegisterValidator(string propertyName, Func<Task<List<string>>> validatorFunc)
{
if (_validators.ContainsKey(propertyName))
{
_validators.Remove(propertyName);
}
_validators[propertyName] = validatorFunc;
}
protected async Task Validate(string property)
{
if (string.IsNullOrWhiteSpace(property))
{
throw new ArgumentException();
}
Func<Task<List<string>>> validator;
if (!_validators.TryGetValue(property, out validator))
{
return;
}
var validationProcessKey = Guid.NewGuid();
_lastValidationProcesses[property] = validationProcessKey;
IsValidating = true;
try
{
var errors = await validator();
if (_lastValidationProcesses.ContainsKey(property) &&
_lastValidationProcesses[property] == validationProcessKey)
{
if (errors != null && errors.Any())
{
_validationErrors[property] = errors;
}
else if (_validationErrors.ContainsKey(property))
{
_validationErrors.Remove(property);
}
}
}
catch (Exception ex)
{
_validationErrors[property] = new List<string>(new[] { ex.Message });
}
finally
{
if (_lastValidationProcesses.ContainsKey(property) &&
_lastValidationProcesses[property] == validationProcessKey)
{
_lastValidationProcesses.Remove(property);
}
IsValidating = _lastValidationProcesses.Any();
IsValid = !_lastValidationProcesses.Any() && !_validationErrors.Any();
OnErrorsChanged(property);
}
}
protected async Task ValidateAll()
{
var validators = _validators;
foreach (var propertyName in validators.Keys)
{
await Validate(propertyName);
}
}
private void OnErrorsChanged(string propertyName)
{
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
}
}
演示
DemoViewModel 有 3 个字段,并模拟了对这些字段进行长时间验证的过程。
class DemoViewModel : ValidatableViewModel
{
private string _name;
public string Name
{
get
{
return _name;
}
set
{
Set(ref _name, value);
}
}
private string _description;
public string Description
{
get
{
return _description;
}
set
{
Set(ref _description, value);
}
}
private int _number;
public int Number
{
get
{
return _number;
}
set
{
Set(ref _number, value);
}
}
public List<int> AvailableNumbers => new List<int>(new[] { 1, 2, 3, 5, 7, 11 });
public DemoViewModel(ITaskFactory taskFactory, IProgramDispatcher programDispatcher) : base(taskFactory, programDispatcher)
{
RegisterValidator(() => Name, ValidateName);
RegisterValidator(() => Description, ValidateDescription);
RegisterValidator(() => Number, ValidateNumber);
ValidateAll();
}
private List<string> ValidateName()
{
Task.Delay(3000).Wait();
if (string.IsNullOrWhiteSpace(Name))
{
return new List<string> { "Name cannot be empty" };
}
if (Name.Length > 10)
{
return new List<string> { "Name cannot be more than 10 characters" };
}
return new List<string>();
}
private List<string> ValidateDescription()
{
Task.Delay(4000).Wait();
if (string.IsNullOrWhiteSpace(Description))
{
return new List<string> { "Description cannot be empty" };
}
if (Description.Length > 50)
{
return new List<string> { "Name cannot be more than 50 characters" };
}
return new List<string>();
}
private List<string> ValidateNumber()
{
Task.Delay(2000).Wait();
if (Number > 5)
{
return new List<string> { "Name cannot be more than 5" };
}
return new List<string>();
}
}
一些用于显示错误的样式。
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ControlTemplate x:Key="GlobalErrorTemplate">
<DockPanel>
<Border BorderBrush="Red"
BorderThickness="2"
CornerRadius="2">
<AdornedElementPlaceholder />
</Border>
</DockPanel>
</ControlTemplate>
<Style TargetType="{x:Type TextBox}">
<Setter Property="Validation.ErrorTemplate"
Value="{StaticResource GlobalErrorTemplate}" />
<Style.Triggers>
<Trigger Property="Validation.HasError"
Value="True">
<Setter Property="ToolTip"
Value="{Binding RelativeSource={RelativeSource Self}, Path=(Validation.Errors)[0].ErrorContent}" />
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="{x:Type ComboBox}">
<Setter Property="Validation.ErrorTemplate"
Value="{StaticResource GlobalErrorTemplate}" />
<Style.Triggers>
<Trigger Property="Validation.HasError"
Value="True">
<Setter Property="ToolTip"
Value="{Binding RelativeSource={RelativeSource Self}, Path=(Validation.Errors)[0].ErrorContent}" />
</Trigger>
</Style.Triggers>
</Style>
</ResourceDictionary>
以及视图。
<Window x:Class="AsyncValidation.Demo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:demo="clr-namespace:AsyncValidation.Demo"
mc:Ignorable="d"
SizeToContent="WidthAndHeight"
Title="Async Validation Demo"
d:DataContext="{d:DesignInstance IsDesignTimeCreatable=False, d:Type=demo:DemoViewModelViewModel}">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
</Window.Resources>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120" />
<ColumnDefinition Width="200" />
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="35"/>
</Grid.RowDefinitions>
<Label Grid.Row="0"
Grid.Column="0"
Content="Name" />
<TextBox Grid.Row="0"
Grid.Column="1"
Margin="5"
Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=LostFocus, ValidatesOnNotifyDataErrors=True}"/>
<Label Grid.Row="1"
Grid.Column="0"
Content="Description" />
<TextBox Grid.Row="1"
Grid.Column="1"
Margin="5"
Text="{Binding Description, Mode=TwoWay, UpdateSourceTrigger=LostFocus, ValidatesOnNotifyDataErrors=True}"/>
<Label Grid.Row="2"
Grid.Column="0"
Content="Number" />
<ComboBox Grid.Row="2"
Grid.Column="1"
Margin="5"
ItemsSource="{Binding AvailableNumbers, ValidatesOnNotifyDataErrors=True}"
SelectedValue="{Binding Number, Mode=TwoWay}"/>
<StatusBar Grid.Row="4"
Grid.Column="0"
Grid.ColumnSpan="3">
<Label Content="Validating"
Visibility="{Binding IsValidating, Converter={StaticResource BooleanToVisibilityConverter}}" />
<Label Content="Valid"
Visibility="{Binding IsValid, Converter={StaticResource BooleanToVisibilityConverter}}" />
</StatusBar>
</Grid>
</Window>
演示效果如下所示。
测试
NUnit 和 NSubstitute 用于编写单元测试。
[TestFixture]
public class ValidatableViewModelTests
{
private class ValidatableViewModelStub : ValidatableViewModel
{
private string _propertyToValidate1;
public string PropertyToValidate1
{
get { return _propertyToValidate1; }
set { Set(ref _propertyToValidate1, value); }
}
private string _propertyToValidate2;
public string PropertyToValidate2
{
get { return _propertyToValidate2; }
set { Set(ref _propertyToValidate2, value); }
}
public new void RegisterValidator<T>(Expression<Func<T>> propertyExpression,
Func<Task<List<string>>> validatorFunc) => base.RegisterValidator(propertyExpression, validatorFunc);
public new Task ValidateAll() => base.ValidateAll();
public new Task Validate(string property) => base.Validate(property);
}
private readonly string _prop1Error1 = "Property 1 Error 1";
private readonly string _prop2Error1 = "Property 2 Error 1";
private readonly string _prop2Error2 = "Property 2 Error 2";
[Test]
public void GetErrorsWhenNoErrors()
{
var viewModel = CreateTestViewModel(new List<string>(), new List<string>());
viewModel.PropertyToValidate1 = "Test";
viewModel.PropertyToValidate2 = "Test";
var errors = viewModel.GetErrors();
Assert.IsFalse(viewModel.HasErrors);
Assert.IsTrue(viewModel.IsValid);
Assert.IsFalse(viewModel.IsValidating);
Assert.AreEqual(0, errors.Count);
}
[Test]
public void GetErrorsWhenOnePropertyHasError()
{
var viewModel = CreateTestViewModel(new List<string> { _prop1Error1 }, new List<string>());
viewModel.PropertyToValidate1 = "Test";
var errors = viewModel.GetErrors();
var prop1Errors = viewModel.GetErrors("PropertyToValidate1").Cast<string>().ToList();
Assert.IsTrue(viewModel.HasErrors);
Assert.IsFalse(viewModel.IsValid);
Assert.IsFalse(viewModel.IsValidating);
Assert.AreEqual(1, errors.Count);
CollectionAssert.Contains(errors, _prop1Error1);
Assert.AreEqual(1, prop1Errors.Count);
CollectionAssert.Contains(prop1Errors, _prop1Error1);
}
[Test]
public void GetErrorsWhenTwoPropertiesHaveErrors()
{
var viewModel = CreateTestViewModel(
new List<string> { _prop1Error1 },
new List<string> { _prop2Error1 });
viewModel.PropertyToValidate1 = _prop1Error1;
viewModel.PropertyToValidate2 = _prop2Error1;
var errors = viewModel.GetErrors();
var prop1Errors = viewModel.GetErrors("PropertyToValidate1").Cast<string>().ToList();
var prop2Errors = viewModel.GetErrors("PropertyToValidate2").Cast<string>().ToList();
Assert.AreEqual(2, errors.Count);
Assert.AreEqual(1, prop1Errors.Count);
Assert.AreEqual(1, prop2Errors.Count);
}
[Test]
public void GetErrorsWhenTwoPropertiesHaveErrorsButOnlyOneWasValidated()
{
var viewModel = CreateTestViewModel(new List<string> { _prop1Error1 },
new List<string> { _prop2Error1, _prop2Error2 });
viewModel.PropertyToValidate2 = "Test";
var errors = viewModel.GetErrors();
var prop2Errors = viewModel.GetErrors("PropertyToValidate2").Cast<string>().ToList();
Assert.IsTrue(viewModel.HasErrors);
Assert.IsFalse(viewModel.IsValid);
Assert.IsFalse(viewModel.IsValidating);
Assert.AreEqual(2, errors.Count);
CollectionAssert.Contains(errors, _prop2Error1);
CollectionAssert.Contains(errors, _prop2Error2);
Assert.AreEqual(2, prop2Errors.Count);
CollectionAssert.Contains(prop2Errors, _prop2Error1);
CollectionAssert.Contains(prop2Errors, _prop2Error1);
}
[Test]
public void GetErrorsWhenValidatorException()
{
var viewModel = CreateTestViewModel(new List<string> { _prop1Error1 }, new List<string>());
var validatorProp1Mock = Substitute.For<Func<Task<List<string>>>>();
validatorProp1Mock.Invoke().Returns(Task.FromException<List<string>>(new Exception("Exception Message")));
viewModel.RegisterValidator(() => viewModel.PropertyToValidate1, validatorProp1Mock);
viewModel.PropertyToValidate1 = "Test";
var errors = viewModel.GetErrors();
var prop1Errors = viewModel.GetErrors("PropertyToValidate1").Cast<string>().ToList();
Assert.IsTrue(viewModel.HasErrors);
Assert.IsFalse(viewModel.IsValid);
Assert.IsFalse(viewModel.IsValidating);
Assert.AreEqual(1, errors.Count);
Assert.AreEqual("Exception Message", errors[0]);
Assert.AreEqual(1, prop1Errors.Count);
CollectionAssert.DoesNotContain(errors, _prop1Error1);
Assert.AreEqual("Exception Message", prop1Errors[0]);
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
[TestCase("UnexistingProperty")]
public void GetErrorsWhenWrongPropertyName(string propertyName)
{
var viewModel = CreateTestViewModel(new List<string> { _prop1Error1 },
new List<string> { _prop2Error1, _prop2Error2 });
var errors = viewModel.GetErrors(propertyName);
CollectionAssert.IsEmpty(errors);
}
[Test]
public void UseLastRegisteredValidator()
{
var viewModel = CreateTestViewModel(new List<string> { _prop1Error1 }, new List<string>());
viewModel.PropertyToValidate1 = "Test";
viewModel.RegisterValidator(() => viewModel.PropertyToValidate1, () => Task.FromResult(new List<string>()));
viewModel.PropertyToValidate1 = "Test 2";
var errors = viewModel.GetErrors();
var prop1Errors = viewModel.GetErrors("PropertyToValidate1");
Assert.IsFalse(viewModel.HasErrors);
Assert.IsTrue(viewModel.IsValid);
Assert.IsFalse(viewModel.IsValidating);
CollectionAssert.IsEmpty(errors);
CollectionAssert.IsEmpty(prop1Errors);
}
[Test]
public void ValidateAll()
{
var viewModel = CreateTestViewModel(new List<string> { _prop1Error1 },
new List<string> { _prop2Error1, _prop2Error2 });
viewModel.ValidateAll();
var errors = viewModel.GetErrors();
var prop1Errors = viewModel.GetErrors("PropertyToValidate1").Cast<string>().ToList();
var prop2Errors = viewModel.GetErrors("PropertyToValidate2").Cast<string>().ToList();
Assert.IsTrue(viewModel.HasErrors);
Assert.IsFalse(viewModel.IsValid);
Assert.IsFalse(viewModel.IsValidating);
Assert.AreEqual(3, errors.Count);
CollectionAssert.Contains(errors, _prop1Error1);
CollectionAssert.Contains(errors, _prop2Error1);
CollectionAssert.Contains(errors, _prop2Error2);
Assert.AreEqual(1, prop1Errors.Count);
CollectionAssert.Contains(prop1Errors, _prop1Error1);
Assert.AreEqual(2, prop2Errors.Count);
CollectionAssert.Contains(prop2Errors, _prop2Error1);
CollectionAssert.Contains(prop2Errors, _prop2Error1);
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void ValidateWhenEmptyProperty(string propertyName)
{
var viewModel = CreateTestViewModel(new List<string> { _prop1Error1 },
new List<string> { _prop2Error1, _prop2Error2 });
Assert.That(() => viewModel.Validate(propertyName), Throws.TypeOf<ArgumentException>());
}
[Test]
public void IgnorePreviousValidationResult()
{
var viewModel = new ValidatableViewModelStub();
var isFirstCall = true;
var task = Task.Run(async () =>
{
await Task.Delay(1000);
return new List<string> { "First Error!!!!" };
});
viewModel.RegisterValidator(() => viewModel.PropertyToValidate1, () =>
{
if (isFirstCall)
{
isFirstCall = false;
return task;
}
return Task.FromResult(new List<string> { "Second Error!!!!" });
});
viewModel.Validate("PropertyToValidate1");
viewModel.Validate("PropertyToValidate1");
task.Wait();
var errors = viewModel.GetErrors();
Assert.AreEqual("Second Error!!!!", errors[0]);
}
private ValidatableViewModelStub CreateTestViewModel(List<string> property1Errors, List<string> property2Errors)
{
var viewModel = new ValidatableViewModelStub();
viewModel.RegisterValidator(() => viewModel.PropertyToValidate1, () => Task.FromResult(property1Errors));
viewModel.RegisterValidator(() => viewModel.PropertyToValidate2, () => Task.FromResult(property2Errors));
return viewModel;
}
}