用MVVM进行适当的validation

警告:非常详细的post。

好的,在使用MVVM时在WPF中进行validation。 我现在读了很多东西,看了很多这样的问题,并且尝试了很多方法,但是在某个时候,所有的东西都觉得有些不好意思,我真的不知道如何以正确的方式去做。

理想情况下,我想使用IDataErrorInfo在视图模型中进行所有validation; 所以这就是我所做的。 然而,不同的方面使得这个解决scheme不能成为整个validation主题的完整解决scheme。

情况

让我们采取以下简单的forms。 正如你所看到的,这不是什么幻想。 我们只有两个文本框,分别绑定到视图模型中的stringint属性。 此外,我们有一个绑定到ICommand的button。

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

所以为了validation,我们现在有两个select:

  1. 只要文本框的值发生变化,我们就可以自动运行validation。 因此,当用户input无效的内容时,用户会立即得到回应。
    • 当出现任何错误时,我们可以进一步禁用button。
  2. 或者我们只能在按下button的时候明确运行validation,然后在适用的情况下显示所有错误。 显然,我们不能在这里禁用错误的button。

理想情况下,我想实现select1.对于正常的数据绑定与激活ValidatesOnDataErrors这是默认行为。 所以当文本发生变化时,绑定更新源并触发该属性的IDataErrorInfovalidation; 错误报告返回视图。 到现在为止还挺好。

视图模型中的validation状态

有趣的是,让视图模型或者在这种情况下的button知道是否有错误。 IDataErrorInfo工作方式,主要是将错误报告给视图。 因此,该视图可以轻松查看是否有任何错误,显示它们,甚至使用Validation.Errors显示注释。 此外,validation总是发生在一个单一的财产。

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

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

绑定例外

视图模型为其属性使用实际的types。 所以在我们的例子中,integer属性是一个实际的int 。 视图中使用的文本框本身只支持文本 。 所以当绑定到视图模型中的int时,数据绑定引擎会自动执行types转换 – 至less它会尝试。 如果你可以在一个文本框中input数字,那么内部并不总是有有效数字的可能性很高:所以数据绑定引擎将无法转换并抛出一个FormatException

数据绑定引擎引发异常,并显示在视图中

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

所以这个观点很好。 用户发生错误,看到一些错误反馈,并可以纠正错误。 然而,视图模型丢失了 。 当绑定引擎抛出exception时,源码从未更新过。 所以视图模型仍然是旧的价值,而不是显示给用户, IDataErrorInfovalidation显然不适用。

更糟糕的是,视图模型没有什么好的办法来知道这一点。 至less,我还没有find一个好的解决scheme呢。 什么是可能的是让视图向视图模型报告有错误。 这可以通过数据将Validation.HasError属性绑定回视图模型来完成(这是不可能的),所以视图模型可以首先检查视图的状态。

另一个select是将在Binding.UpdateSourceExceptionFilter处理的exceptionBinding.UpdateSourceExceptionFilter给视图模型,所以它也会被通知。 视图模型甚至可以提供一些绑定的接口来报告这些事情,允许自定义错误消息,而不是通用的每types的。 但是,这将创造一个更强大的耦合从视图到视图模型,我通常要避免。

另一个“解决scheme”是摆脱所有types的属性,使用纯string属性,并在视图模型中进行转换。 这显然会把所有的validation移到视图模型上,但是也意味着数据绑定引擎通常需要处理的事情的数量惊人的重复。 此外,它将改变视图模型的语义。 对于我来说,视图是build立在视图模型上的,而不是相反的。当然,视图模型的devise依赖于我们想象的视图,但是视图仍然具有普遍的自由。 所以视图模型定义了一个int属性,因为有一个数字; 该视图现在可以使用文本框(允许所有这些问题),或者使用与数字本身一起工作的东西。 所以不,将属性的types更改为string不是我的select。

最后,这是一个问题。 视图(及其数据绑定引擎)负责为视图模型提供合适的值。 但在这种情况下,似乎没有好的办法来告诉视图模型,它应该使旧属性值失效。

BindingGroups

绑定小组是我尝试解决这个问题的方法之一。 绑定组能够将所有validation分组,包括IDataErrorInfo和抛出的exception。 如果可用于视图模型,他们甚至有一个意思是检查所有这些validation源的validation状态,例如使用CommitEdit

默认情况下,绑定组从上面实现选项2。 他们使绑定更新显式,本质上添加一个额外的未提交状态。 所以当单击button时,命令可以提交这些更改,触发源更新和所有validation,如果成功则获得单个结果。 所以命令的行动可能是这样的:

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

如果所有的validation成功, CommitEdit将只返回true。 它将考虑IDataErrorInfo并检查绑定exception。 这似乎是select2的完美解决scheme。唯一有点麻烦的是用绑定pipe理绑定组,但是我已经build立了自己的一些东西,主要是照顾这个( 相关的 )。

如果绑定组存在绑定,绑定将默认为显式UpdateSourceTrigger 。 要使用绑定组从上面实现select1,我们基本上必须改变触发器。 正如我有一个自定义绑定扩展无论如何,这是相当简单的,我只是把它设置为LostFocus所有。

所以,现在,只要文本字段发生变化,绑定仍然会更新。 如果源可以更新(绑定引擎不会引发exception),那么IDataErrorInfo将像平常一样运行。 如果无法更新,视图仍然可以看到它。 如果我们点击我们的button,底层命令可以调用CommitEdit (尽pipe不需要提交),并获得总体validation结果,看看是否可以继续。

我们可能无法通过这种方式轻松禁用button。 至less不是从视图模型。 重复检查validation并不是一个好主意,只是为了更新命令状态,并且在发生绑定引擎exception时(无论如何应该禁用该button),视图模型不会被通知 – 或者当它离开时再次启用button。 我们仍然可以使用Validation.HasError添加一个触发器来禁用视图中的button,所以这不是不可能的。

解?

总的来说,这似乎是一个完美的解决scheme。 我的问题是什么呢? 说实话,我不完全确定。 绑定组是一个复杂的事情,似乎通常在较小的组中使用,可能在一个视图中有多个绑定组。 通过使用一个大的绑定组来整个视图,以确保我的validation,感觉好像我滥用它。 而我只是一直在想,要有一个更好的办法来解决这个问题,因为当然,我不可能是唯一有这个问题的人。 到目前为止,我还没有真正看到许多人使用绑定组来validationMVVM,所以感觉很奇怪。

那么,在使用MVVM进行WPFvalidation的同时,能够检查绑定引擎exception,到底是什么呢?


我的解决scheme(/ hack)

首先,感谢您的input! 正如我上面写的,我已经使用IDataErrorInfo来做我的数据validation,我个人认为这是做validation工作最舒适的工具。 我使用的工具类似于Sheridan在他的回答中提出的build议,所以维护工作也很好。

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

正如我上面提到的,我通过侦听绑定的UpdateSourceExceptionFilter来检测视图上的绑定exception。 在那里,我可以从绑定expression式的DataItem获得对视图模型的引用。 然后,我有一个接口IReceivesBindingErrorInformation注册视图模型作为绑定错误的信息可能的接收器。 然后,我使用它将绑定path和exception传递给视图模型:

 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 // ... } 

在视图模型中,我会记住每当我被通知一个path的绑定expression式:

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

每当IDataErrorInfo重新validation一个属性,我知道该绑定工作,我可以从哈希集清除属性。

在视图模型中,我可以检查散列集是否包含任何项目,并中止需要完整validation数据的任何操作。 它可能不是最好的解决scheme,因为从视图耦合到视图模型,但使用该界面,至less有点不成问题。

警告:长的答案也

我使用IDataErrorInfo接口进行validation,但是我已经根据需要进行了自定义。 我想你会发现它也解决了你的一些问题。 你的问题的一个区别是,我在我的基础数据types类中实现它。

正如你所指出的那样,这个接口一次只处理一个属性,但是显然在这个时代,这并不好。 所以我只是添加一个集合属性来代替:

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

这使我可以使用自定义的BoolToVisibilityConverter将其绑定到BoolToVisibilityConverter ,例如。 在里面显示一个带有集合控件的Grid ,在有任何错误时显示错误。 它也可以让我改变一个BrushRed突出错误(使用另一个Converter ),但我想你明白了。

然后在每个数据types或模型类中,我重写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方法,并且“在锡上做什么”。 注意如何调用每个我想依次validation的属性,并从中编译一个集合,忽略重复的错误。

使用ExternalErrors集合,我可以validation我无法在数据类中validation的事情:

 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字段中input字母字符的情况,我倾向于为TextBox使用自定义的IsNumeric AttachedProperty ,例如。 我不让他们犯这样的错误。 我总觉得最好停下来,而不是让它发生,然后修复它。

总的来说,我对WPFvalidation能力感到非常满意,而且根本不需要。

为了结束和完整性,我觉得我应该提醒你,现在有一个INotifyDataErrorInfo接口,它包含了这些附加function。 您可以从MSDN上的INotifyDataErrorInfo接口页面find更多信息。


更新>>>

是的, ExternalErrors属性只是让我添加与该对象之外的数据对象相关的错误…对不起,我的例子并不完整…如果我已经告诉你IsGenreNameUnique方法,你会看到它在集合中的所有 Genre数据项上使用LinQ来确定对象的名称是否唯一:

 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 ,并且在Binding中使用了FooObject ,则可以这样做:

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

这样你可以满足你的要求,但是你会有很多额外的代码来添加。

缺点是这可能会对所有属性的validation过于频繁,但大多数validation应该足够简单而不会损害性能。 另一个解决scheme是记住哪些属性使用validation产生错误,只检查这些错误,但在大多数情况下似乎有点过于复杂和不必要。

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


最后,这是一个问题。 视图(及其数据绑定引擎)负责为视图模型提供合适的值。 但在这种情况下,似乎没有好的办法来告诉视图模型,它应该使旧属性值失效。

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

 container.AddHandler(Validation.ErrorEvent, Container_Error); ... void Container_Error(object sender, ValidationErrorEventArgs e) { ... } 

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

但是对这个问题的任何解决scheme总是会是一个诡计,因为视图并没有正确地填充它的angular色,这就给用户一种阅读和更新视图模型结构的手段。 这应该被视为一个临时的解决scheme,直到您正确地向用户提供某种“ 整数框”,而不是一个文本框。

在我看来,问题在于validation发生在太多的地方。 我也希望在ViewModel编写我所有的validationlogin,但所有这些数字绑定使我的ViewModel疯狂。

我通过创build永不失败的绑定来解决这个问题。 显然,如果绑定总是成功的,那么types本身必须优雅地处理错误条件。

失败的价值types

我开始创build一个通用的types,将优雅地支持失败的转换:

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

请注意,即使types由于无效的inputstring(第二个构造函数)而未能初始化,它也会无效地将无效状态与无效文本一起存储。 即使在input错误的情况下,为了支持绑定的往返也是必需的。

通用价值转换器

通用的值转换器可以使用上面的types来编写:

 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中创build和使用generics实例是痛苦的,所以让我们制作一些常见转换器的静态实例:

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

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

用法

用法非常简单,只需要将types从int更改为Failable<int>

视图模型

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

XAML

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

这样,您可以通过检查IsValid属性在ViewModel使用相同的validation机制( IDataErrorInfoINotifyDataErrorInfo或其他)。 如果IsValid为true,则可以直接使用Value

好的,我相信我find了你正在寻找的答案…
这将不容易解释 – 但..
很容易理解一次解释…
我认为这是MVVM最准确的“authentication”,被视为“标准”或至less是企图标准。

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

“此外,它会改变视图模型的语义,对我来说,视图是为视图模型build立的,而不是相反的 – 视图模型的devise当然取决于我们想象的视图,但是仍然是一般的自由如何看待这个“

那段文字是你问题的根源 – 为什么?

因为你说的视图模型没有任何作用,以适应视图..
这在许多方面都是错误的 – 正如我向你certificate的那样。

如果你有一个属性如:

public Visibility MyPresenter { get...

什么是Visibility如果不是服务视图的东西?
这种types本身以及赋予房产的名称绝对是为了这个观点。

根据我的经验,MVVM中有两个可区分的视图模型类别:

  • 演示者视图模型 – 这是要挂钩到button,菜单,选项卡项目等….
  • 实体视图模型(Entity View Model) – 将其用于将实体数据屏幕显示的控件。

这是两个不同的 – 完全不同的担忧。

现在来解决:

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

现在在你的代码中的某个地方 – 你的button命令“CanExecute”方法可以添加到它的实现调用VmEntity.HasErrors。

从现在起就可以和平在你的代码上进行validation:)