61

警告:非常长而详细的帖子。

好的,使用 MVVM 时在 WPF 中进行验证。我现在已经阅读了很多东西,查看了许多 SO 问题,并尝试了许多方法,但是在某些时候一切都感觉有些 hacky,我真的不知道如何以正确的方式做到这一点™。

IDataErrorInfo理想情况下,我希望使用;在视图模型中进行所有验证。所以这就是我所做的。然而,有不同的方面使该解决方案不是整个验证主题的完整解决方案。

情况

让我们采用以下简单的形式。如您所见,这没什么花哨的。我们只有两个文本框,它们分别绑定到视图模型中的一个stringint属性。此外,我们有一个绑定到ICommand.

只有字符串和整数输入的简单形式

因此,对于验证,我们现在有两个选择:

  1. 只要文本框的值发生变化,我们就可以自动运行验证。因此,当用户输入无效内容时,他会立即得到响应。
    • 当出现任何错误时,我们可以进一步禁用按钮。
  2. 或者我们可以仅在按下按钮时显式运行验证,然后显示所有错误(如果适用)。显然我们不能在这里禁用错误按钮。

理想情况下,我想实现选择 1。对于激活的普通数据绑定,ValidatesOnDataErrors这是默认行为。因此,当文本更改时,绑定会更新源并触发IDataErrorInfo对该属性的验证;错误被报告回视图。到目前为止,一切都很好。

视图模型中的验证状态

有趣的是让视图模型或本例中的按钮知道是否有任何错误。这种方式IDataErrorInfo有效,主要是将错误报告给视图。因此视图可以很容易地查看是否有任何错误,显示它们,甚至使用Validation.Errors. 此外,验证总是在查看单个属性时发生。

所以让视图模型知道什么时候有任何错误,或者验证是否成功,是很棘手的。一个常见的解决方案是简单地触发IDataErrorInfo视图模型本身中所有属性的验证。这通常使用单独的IsValid属性来完成。好处是这也可以很容易地用于禁用命令。缺点是这可能会过于频繁地对所有属性进行验证,但大多数验证应该足够简单,不会损害性能。另一种解决方案是使用验证来记住哪些属性产生了错误并只检查那些,但这在大多数情况下似乎有点过于复杂和不必要。

最重要的是,这可以正常工作。IDataErrorInfo为所有属性提供验证,我们可以简单地在视图模型本身中使用该接口来为整个对象运行验证。问题介绍:

绑定异常

视图模型对其属性使用实际类型。所以在我们的例子中,整数属性是一个实际的int. 然而,视图中使用的文本框本身只支持text。所以当绑定到int视图模型中时,数据绑定引擎会自动执行类型转换——或者至少它会尝试。如果您可以在用于数字的文本框中输入文本,则内部并不总是有效数字的可能性很高:因此数据绑定引擎将无法转换并抛出FormatException.

数据绑定引擎抛出异常并显示在视图中

在视图方面,我们可以很容易地看到这一点。来自绑定引擎的异常会被 WPF 自动捕获并显示为错误——甚至不需要启用Binding.ValidatesOnExceptionssetter 中抛出的异常所需要的。错误消息确实有一个通用文本,所以这可能是一个问题。我已经通过使用Binding.UpdateSourceExceptionFilter处理程序为自己解决了这个问题,检查抛出的异常并查看源属性,然后生成一个不太通用的错误消息。所有这些都封装到我自己的 Binding 标记扩展中,所以我可以拥有我需要的所有默认值。

所以景色还不错。用户犯了一个错误,看到一些错误反馈并可以纠正它。然而,视图模型丢失了。由于绑定引擎抛出异常,源从未更新。所以视图模型仍然是旧值,这不是向用户显示的内容,并且IDataErrorInfo验证显然不适用。

更糟糕的是,视图模型没有很好的方法知道这一点。至少,我还没有找到一个好的解决方案。可能的做法是让视图向视图模型报告出现错误。这可以通过将Validation.HasError属性数据绑定回视图模型(这不可能直接)来完成,因此视图模型可以首先检查视图的状态。

另一种选择是将处理的异常中继Binding.UpdateSourceExceptionFilter到视图模型,因此它也会收到通知。视图模型甚至可以为绑定提供一些接口来报告这些事情,允许自定义错误消息而不是通用的每个类型的错误消息。但这会在视图和视图模型之间产生更强的耦合,而我通常希望避免这种耦合。

另一个“解决方案”是摆脱所有类型化的属性,使用普通string属性并在视图模型中进行转换。这显然会将所有验证转移到视图模型,但也意味着数据绑定引擎通常会处理大量重复的事情。此外,它会改变视图模型的语义。对我来说,视图是为视图模型构建的,而不是相反——当然,视图模型的设计取决于我们想象视图要做什么,但是视图如何做到这一点仍然有一般的自由。所以视图模型定义了一个int属性,因为有一个数字;视图现在可以使用文本框(允许所有这些问题),或者使用本机与数字一起使用的东西。所以不,将属性的类型更改为string不是我的选择。

归根结底,这是观点的问题。视图(及其数据绑定引擎)负责为视图模型提供适当的值以供使用。但是在这种情况下,似乎没有好的方法告诉视图模型它应该使旧的属性值无效。

绑定组

绑定组是我试图解决这个问题的一种方法。绑定组能够对所有验证进行分组,包括IDataErrorInfo和抛出的异常。如果视图模型可用,它们甚至可以检查所有这些验证源的验证状态,例如使用CommitEdit.

默认情况下,绑定组实现上面的选项 2。它们使绑定显式更新,本质上添加了一个额外的未提交状态。因此,当单击按钮时,该命令可以提交这些更改,触发源更新和所有验证,并在成功时获得单个结果。所以命令的动作可能是这样的:

 if (bindingGroup.CommitEdit())
     SaveEverything();

CommitEdit只有在所有验证成功时才会返回 true 。它将IDataErrorInfo考虑并检查绑定异常。这似乎是选择 2 的完美解决方案。唯一有点麻烦的是使用绑定管理绑定组,但我已经为自己构建了一些主要处理此问题的东西(相关)。

如果绑定存在绑定组,则绑定将默认为显式UpdateSourceTrigger. 要使用绑定组实现上述选项 1,我们基本上必须更改触发器。因为无论如何我都有一个自定义绑定扩展,这相当简单,我只是将它设置LostFocus为所有。

所以现在,只要文本字段发生变化,绑定仍然会更新。如果源可以更新(绑定引擎不抛出异常),那么IDataErrorInfo将照常运行。如果它无法更新,视图仍然可以看到它。如果我们点击我们的按钮,底层命令可以调用CommitEdit(尽管不需要提交任何东西)并获取总验证结果,看看它是否可以继续。

我们可能无法通过这种方式轻松禁用该按钮。至少不是来自视图模型。一遍又一遍地检查验证并不是一个好主意,只是为了更新命令状态,并且当绑定引擎异常抛出时(这应该禁用按钮)或当它消失时,视图模型不会得到通知再次启用该按钮。我们仍然可以添加一个触发器来禁用视图中的按钮,Validation.HasError所以这不是不可能的。

解决方案?

所以总的来说,这似乎是一个完美的解决方案。不过,我有什么问题?老实说,我并不完全确定。绑定组是一个复杂的东西,似乎通常在较小的组中使用,可能在单个视图中有多个绑定组。通过为整个视图使用一个大的绑定组来确保我的验证,感觉好像我在滥用它。我只是一直在想,必须有更好的方法来解决整个情况,因为我肯定不会是唯一一个遇到这些问题的人。到目前为止,我还没有真正看到很多人使用绑定组来验证 MVVM,所以感觉很奇怪。

那么,在能够检查绑定引擎异常的同时,使用 MVVM 在 WPF 中进行验证的正确方法到底是什么?


我的解决方案(/hack)

首先,感谢您的输入!正如我在上面所写的,我IDataErrorInfo已经在使用它来进行数据验证,我个人认为这是进行验证工作最舒适的实用程序。我正在使用类似于 Sheridan 在下面的回答中建议的实用程序,因此维护也可以正常工作。

最后,我的问题归结为绑定异常问题,视图模型不知道它何时发生。虽然我可以使用上面详述的绑定组来处理这个问题,但我仍然决定反对它,因为我只是觉得不太舒服。那么我做了什么呢?

正如我上面提到的,我通过监听绑定的UpdateSourceExceptionFilter. 在那里,我可以从绑定表达式的DataItem. 然后我有一个接口IReceivesBindingErrorInformation,它将视图模型注册为可能的接收器,以获取有关绑定错误的信息。然后我使用它将绑定路径和异常传递给视图模型:

object OnUpdateSourceExceptionFilter(object bindExpression, Exception exception)
{
    BindingExpression expr = (bindExpression as BindingExpression);
    if (expr.DataItem is IReceivesBindingErrorInformation)
    {
        ((IReceivesBindingErrorInformation)expr.DataItem).ReceiveBindingErrorInformation(expr.ParentBinding.Path.Path, exception);
    }

    // check for FormatException and produce a nicer error
    // ...
 }

在视图模型中,每当我收到有关路径绑定表达式的通知时,我都会记得:

HashSet<string> bindingErrors = new HashSet<string>();
void IReceivesBindingErrorInformation.ReceiveBindingErrorInformation(string path, Exception exception)
{
    bindingErrors.Add(path);
}

每当IDataErrorInfo重新验证一个属性时,我就知道绑定有效,我可以从哈希集中清除该属性。

然后,在视图模型中,我可以检查哈希集是否包含任何项目并中止任何需要完全验证数据的操作。由于从视图到视图模型的耦合,它可能不是最好的解决方案,但使用该接口至少不是问题。

4

5 回答 5

17

警告:长答案也

我使用该IDataErrorInfo界面进行验证,但我已根据自己的需要对其进行了定制。我想你会发现它也解决了你的一些问题。您的问题的一个区别是我在我的基本数据类型类中实现它。

正如您所指出的,此界面一次只处理一个属性,但显然在当今时代,这并不好。所以我只是添加了一个集合属性来使用:

protected ObservableCollection<string> errors = new ObservableCollection<string>();

public virtual ObservableCollection<string> Errors
{
    get { return errors; }
}

为了解决您无法显示外部错误的问题(在您的情况下来自视图,但在我的情况下来自视图模型),我只是添加了另一个集合属性:

protected ObservableCollection<string> externalErrors = new ObservableCollection<string>();

public ObservableCollection<string> ExternalErrors
{
    get { return externalErrors; }
}

我有一个HasError属性可以查看我的收藏:

public virtual bool HasError
{
    get { return Errors != null && Errors.Count > 0; }
}

这使我能够将其绑定到Grid.Visibility使用自定义BoolToVisibilityConverter,例如。显示一个Grid带有集合控件的内部控件,该控件在有任何错误时显示错误。它还可以让我更改 aBrushRed突出显示错误(使用 another Converter),但我想你明白了。

然后在每个数据类型或模型类中,我重写Errors属性并实现Item索引器(在此示例中进行了简化):

public override ObservableCollection<string> Errors
{
    get
    {
        errors = new ObservableCollection<string>();
        errors.AddUniqueIfNotEmpty(this["Name"]);
        errors.AddUniqueIfNotEmpty(this["EmailAddresses"]);
        errors.AddUniqueIfNotEmpty(this["SomeOtherProperty"]);
        errors.AddRange(ExternalErrors);
        return errors;
    }
}

public override string this[string propertyName]
{
    get
    {
        string error = string.Empty;
        if (propertyName == "Name" && Name.IsNullOrEmpty()) error = "You must enter the Name field.";
        else if (propertyName == "EmailAddresses" && EmailAddresses.Count == 0) error = "You must enter at least one e-mail address into the Email address(es) field.";
        else if (propertyName == "SomeOtherProperty" && SomeOtherProperty.IsNullOrEmpty()) error = "You must enter the SomeOtherProperty field.";
        return error;
    }
}

AddUniqueIfNotEmpty方法是一种自定义extension方法,并且“按照锡上所说的去做”。请注意它将如何依次调用我要验证的每个属性并从中编译一个集合,而忽略重复的错误。

使用该ExternalErrors集合,我可以验证我无法在数据类中验证的内容:

private void ValidateUniqueName(Genre genre)
{
    string errorMessage = "The genre name must be unique";
    if (!IsGenreNameUnique(genre))
    {
        if (!genre.ExternalErrors.Contains(errorMessage)) genre.ExternalErrors.Add(errorMessage);
    }
    else genre.ExternalErrors.Remove(errorMessage);
}

为了解决您关于用户在字段中输入字母字符的情况的观点int,我倾向于使用自定义IsNumeric AttachedPropertyTextBox例如。我不会让他们犯这些错误。我总觉得阻止它比让它发生然后修复它更好。

总的来说,我对自己在 WPF 中的验证能力感到非常满意,并且一点也不缺。

最后,为了完整起见,我觉得我应该提醒您一个事实,即现在有一个INotifyDataErrorInfo界面包含一些附加功能。您可以从 MSDN 上的INotifyDataErrorInfo接口页面了解更多信息。


更新>>>

是的,该ExternalErrors属性只是让我从该对象外部添加与数据对象相关的错误...抱歉,我的示例不完整...如果我向您展示了该IsGenreNameUnique方法,您会看到它使用LinQ在集合中的所有Genre数据项上确定对象的名称是否唯一:

private bool IsGenreNameUnique(Genre genre)
{
    return Genres.Where(d => d.Name != string.Empty && d.Name == genre.Name).Count() == 1;
}

至于您的int/string问题,我可以看到您在数据类中遇到这些错误的唯一方法是,如果您将所有属性声明为object,那么您将有大量的转换工作要做。也许你可以像这样加倍你的属性:

public object FooObject { get; set; } // Implement INotifyPropertyChanged

public int Foo
{
    get { return FooObject.GetType() == typeof(int) ? int.Parse(FooObject) : -1; }
}

然后如果Foo在代码FooObject中使用并在 中使用Binding,您可以这样做:

public override string this[string propertyName]
{
    get
    {
        string error = string.Empty;
        if (propertyName == "FooObject" && FooObject.GetType() != typeof(int)) 
            error = "Please enter a whole number for the Foo field.";
        ...
        return error;
    }
}

这样您就可以满足您的要求,但是您将需要添加很多额外的代码。

于 2013-10-21T16:22:30.843 回答
3

在我看来,问题在于验证发生在太多地方。我也想把我所有的验证登录都写进去,ViewModel但是所有这些数字绑定都让我ViewModel发疯了。

我通过创建一个永不失败的绑定解决了这个问题。显然,如果绑定总是成功的,那么类型本身必须优雅地处理错误情况。

可失败值类型

我首先创建了一个可以优雅地支持失败转换的泛型类型:

public struct Failable<T>
{
    public T Value { get; private set; }
    public string Text { get; private set; }
    public bool IsValid { get; private set; }

    public Failable(T value)
    {
        Value = value;

        try
        {
            var converter = TypeDescriptor.GetConverter(typeof(T));
            Text = converter.ConvertToString(value);
            IsValid = true;
        }
        catch
        {
            Text = String.Empty;
            IsValid = false;
        }
    }

    public Failable(string text)
    {
        Text = text;

        try
        {
            var converter = TypeDescriptor.GetConverter(typeof(T));
            Value = (T)converter.ConvertFromString(text);
            IsValid = true;
        }
        catch
        {
            Value = default(T);
            IsValid = false;
        }
    }
}

请注意,即使类型由于无效的输入字符串(第二个构造函数)而无法初始化,它也会悄悄地存储无效状态以及无效文本这是必需的,以便即使在输入错误的情况下也能支持绑定的往返。

通用值转换器

可以使用上述类型编写通用值转换器:

public class StringToFailableConverter<T> : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if (value.GetType() != typeof(Failable<T>))
            throw new InvalidOperationException("Invalid value type.");

        if (targetType != typeof(string))
            throw new InvalidOperationException("Invalid target type.");

        var rawValue = (Failable<T>)value;
        return rawValue.Text;
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if (value.GetType() != typeof(string))
            throw new InvalidOperationException("Invalid value type.");

        if (targetType != typeof(Failable<T>))
            throw new InvalidOperationException("Invalid target type.");

        return new Failable<T>(value as string);
    }
}

XAML 便捷转换器

由于在 XAML 中创建和使用泛型实例很痛苦,所以让我们创建通用转换器的静态实例:

public static class Failable
{
    public static StringToFailableConverter<Int32> Int32Converter { get; private set; }
    public static StringToFailableConverter<double> DoubleConverter { get; private set; }

    static Failable()
    {
        Int32Converter = new StringToFailableConverter<Int32>();
        DoubleConverter = new StringToFailableConverter<Double>();
    }
}

其他值类型可以很容易地扩展。

用法

用法很简单,只需要将类型从 更改intFailable<int>

视图模型

public Failable<int> NumberValue
{
    //Custom logic along with validation
    //using IsValid property
}

XAML

<TextBox Text="{Binding NumberValue,Converter={x:Static local:Failable.Int32Converter}}"/>

这样,您可以通过检查属性来使用相同的验证机制(IDataErrorInfoINotifyDataErrorInfo其他任何东西) 。如果为真,则可以直接使用.ViewModelIsValidIsValidValue

于 2016-12-03T00:39:28.777 回答
2

好的,我相信我已经找到了您正在寻找的答案......
这并不容易解释 - 但是......
一旦解释就很容易理解......
我认为它是最准确/“认证”的 MVVM 查看作为“标准”或至少尝试的标准。

但在我们开始之前......你需要改变一个你习惯于 MVVM 的概念:

“此外,它会改变视图模型的语义。对我来说,视图是为视图模型构建的,而不是相反——当然,视图模型的设计取决于我们想象视图要做的事情,但仍有一般性自由视图如何做到这一点”

那段是你问题的根源.. - 为什么?

因为您说 View-Model 没有任何作用来调整自己以适应 View ..
这在很多方面都是错误的 - 我会很简单地向您证明..

如果您有以下财产:

public Visibility MyPresenter { get...

如果Visibility不是服务于视图的东西是什么?
类型本身和将赋予属性的名称肯定是由视图组成的。

根据我的经验,MVVM 中有两个可区分的 View-Models 类别:

  • 演示者视图模型 - 连接到按钮、菜单、选项卡项等......
  • 实体视图模型 - 将绑定到将实体数据显示在屏幕上的控件。

这是两个不同的——完全不同的关注点。

现在到解决方案:

public abstract class ViewModelBase : INotifyPropertyChanged
{
   public event PropertyChangedEventHandler PropertyChanged;

   public void RaisePropertyChanged([CallerMemberName] string propertyName = null)
   {
      if (PropertyChanged != null)
         PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
   }
}


public class VmSomeEntity : ViewModelBase, INotifyDataErrorInfo
{
    //This one is part of INotifyDataErrorInfo interface which I will not use,
    //perhaps in more complicated scenarios it could be used to let some other VM know validation changed.
    public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged; 

    //will hold the errors found in validation.
    public Dictionary<string, string> ValidationErrors = new Dictionary<string, string>();

    //the actual value - notice it is 'int' and not 'string'..
    private int storageCapacityInBytes;

    //this is just to keep things sane - otherwise the view will not be able to send whatever the user throw at it.
    //we want to consume what the user throw at us and validate it - right? :)
    private string storageCapacityInBytesWrapper;

    //This is a property to be served by the View.. important to understand the tactic used inside!
    public string StorageCapacityInBytes
    {
       get { return storageCapacityInBytesWrapper ?? storageCapacityInBytes.ToString(); }
       set
       {
          int result;
          var isValid = int.TryParse(value, out result);
          if (isValid)
          {
             storageCapacityInBytes = result;
             storageCapacityInBytesWrapper = null;
             RaisePropertyChanged();
          }
          else
             storageCapacityInBytesWrapper = value;         

          HandleValidationError(isValid, "StorageCapacityInBytes", "Not a number.");
       }
    }

    //Manager for the dictionary
    private void HandleValidationError(bool isValid, string propertyName, string validationErrorDescription)
    {
        if (!string.IsNullOrEmpty(propertyName))
        {
            if (isValid)
            {
                if (ValidationErrors.ContainsKey(propertyName))
                    ValidationErrors.Remove(propertyName);
            }
            else
            {
                if (!ValidationErrors.ContainsKey(propertyName))
                    ValidationErrors.Add(propertyName, validationErrorDescription);
                else
                    ValidationErrors[propertyName] = validationErrorDescription;
            }
        }
    }

    // this is another part of the interface - will be called automatically
    public IEnumerable GetErrors(string propertyName)
    {
        return ValidationErrors.ContainsKey(propertyName)
            ? ValidationErrors[propertyName]
            : null;
    }

    // same here, another part of the interface - will be called automatically
    public bool HasErrors
    {
        get
        {
            return ValidationErrors.Count > 0;
        }
    }
}

现在在您的代码中的某处 - 您的按钮命令“CanExecute”方法可以在其实现中添加对 VmEntity.HasErrors 的调用。

从现在开始,关于验证的代码可能会和平:)

于 2015-01-08T13:23:46.803 回答
1

缺点是这可能会过于频繁地对所有属性进行验证,但大多数验证应该足够简单,不会损害性能。另一种解决方案是使用验证来记住哪些属性产生了错误并只检查那些,但这在大多数情况下似乎有点过于复杂和不必要。

您不需要跟踪哪些属性有错误;您只需要知道存在错误。视图模型可以维护一个错误列表(对于显示错误摘要也很有用),并且该IsValid属性可以简单地反映该列表是否有任何内容。您无需在每次IsValid调用时检查所有内容,只要您确保错误摘要是最新的并且IsValid每次更改时都会刷新。


归根结底,这是观点的问题。视图(及其数据绑定引擎)负责为视图模型提供适当的值以供使用。但是在这种情况下,似乎没有好的方法告诉视图模型它应该使旧的属性值无效。

您可以在绑定到视图模型的容器中侦听错误:

container.AddHandler(Validation.ErrorEvent, Container_Error);

...

void Container_Error(object sender, ValidationErrorEventArgs e) {
    ...
}

这会在添加或删除错误时通知您,并且您可以通过是否e.Error.Exception存在来识别绑定异常,因此您的视图可以维护绑定异常列表并通知视图模型。

但是这个问题的任何解决方案都将永远是一个 hack,因为视图没有正确地扮演它的角色,这给了用户一种阅读和更新视图模型结构的方法。在您正确地向用户呈现某种“整数框”而不是文本框之前,这应该被视为一种临时解决方案。

于 2013-10-21T16:43:00.017 回答
1

如果您不想实现大量额外的代码,这里可以简化一些事情......

这种情况是您的视图模型中有一个 int 属性(可以是十进制或其他非字符串类型),并且您在视图中将一个文本框绑定到它。

您在视图模型中进行了验证,该验证在属性的设置器中触发。

在视图中,用户输入 123abc,视图逻辑突出显示视图中的错误,但无法设置属性,因为值类型错误。setter 永远不会被调用。

最简单的解决方案是将视图模型中的 int 属性更改为字符串属性,并将值从模型中输入和输出。这允许错误的文本到达您的属性的设置器,然后您的验证代码可以检查数据并酌情拒绝它。

WPF 中的恕我直言验证被破坏了,从人们试图解决之前给出的问题的复杂(和巧妙)方式可以看出。对我来说,我不想添加大量额外代码或实现我自己的类型类来启用文本框进行验证,因此基于字符串的这些属性是我可以忍受的,即使它确实感觉有点像杂物。

Microsoft 应该考虑解决此问题,以便绑定到 int 或 decimal 属性的文本框中的无效用户输入的情况可以以某种方式优雅地将这一事实传达给 viewmodel。例如,他们应该可以为 XAML 控件创建新的绑定属性,以便将视图逻辑验证错误传达给视图模型中的属性。

感谢并尊重为该主题提供详细答案的其他人。

于 2018-12-11T21:17:38.103 回答