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

WPF 中的异步验证

2017年1月27日

MIT
viewsIcon

16326

downloadIcon

288

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;
    }
}

 

WPF 中的异步验证 - CodeProject - 代码之家
© . All rights reserved.