C# 使用MVVM进行正确的验证
警告:非常长和详细的职位 好的,使用MVVM时在WPF中进行验证。我现在读了很多东西,看了很多问题,尝试了很多方法,但在某个时候,每件事都感觉有点不对劲,我真的不知道如何正确地做™. 理想情况下,我希望在视图模型中使用;我就是这么做的。但是,有不同的方面使得此解决方案不是整个验证主题的完整解决方案 形势 让我们采用下面的简单形式。正如你所见,这没什么特别的。我们只有两个文本框,分别绑定到视图模型中的C# 使用MVVM进行正确的验证,c#,wpf,validation,mvvm,C#,Wpf,Validation,Mvvm,警告:非常长和详细的职位 好的,使用MVVM时在WPF中进行验证。我现在读了很多东西,看了很多问题,尝试了很多方法,但在某个时候,每件事都感觉有点不对劲,我真的不知道如何正确地做™. 理想情况下,我希望在视图模型中使用;我就是这么做的。但是,有不同的方面使得此解决方案不是整个验证主题的完整解决方案 形势 让我们采用下面的简单形式。正如你所见,这没什么特别的。我们只有两个文本框,分别绑定到视图模型中的string和int属性。此外,我们还有一个绑定到ICommand的按钮 因此,对于验证,我们现
string
和int
属性。此外,我们还有一个绑定到ICommand
的按钮
因此,对于验证,我们现在有两个选择:
- 当出现任何错误时,我们可以进一步禁用按钮
IDataErrorInfo
验证;错误会报告回视图。到目前为止还不错
视图模型中的验证状态
有趣的是让视图模型或本例中的按钮知道是否存在任何错误。按照IDataErrorInfo
的工作方式,它主要是向视图报告错误。因此,视图可以很容易地查看是否存在任何错误,显示错误,甚至使用显示注释。此外,验证总是针对单个属性进行
因此,让视图模型知道何时有错误,或者验证是否成功,是一件棘手的事情。一个常见的解决方案是简单地触发视图模型本身中所有属性的IDataErrorInfo
验证。这通常使用单独的IsValid
属性来完成。这样做的好处是,还可以轻松地用于禁用命令。缺点是,这可能会过于频繁地对所有属性运行验证,但大多数验证应该足够简单,不会影响性能。另一个解决方案是使用验证记住哪些属性产生了错误,并只检查这些属性,但这似乎有点过于复杂,在大多数情况下都是不必要的
底线是,这可以很好地工作IDataErrorInfo
为所有属性提供验证,我们只需在视图模型本身中使用该接口就可以对整个对象运行验证。介绍问题:
有约束力的例外情况
视图模型使用实际类型作为其属性。因此在我们的示例中,integer属性是一个实际的int
。但是,视图中使用的文本框本机仅支持文本。因此,当绑定到视图模型中的int
时,数据绑定引擎将自动执行类型转换,或者至少会尝试。如果可以在用于数字的文本框中输入文本,则很有可能其中并不总是有有效数字:因此数据绑定引擎将无法转换并抛出格式异常
在视图方面,我们可以很容易地看到这一点。来自绑定引擎的异常会被WPF自动捕获并显示为错误,甚至不需要启用,而setter中抛出的异常则需要启用。不过,错误消息确实有一个通用文本,因此这可能是一个问题。我已经通过使用处理程序解决了这个问题,检查抛出的异常并查看源属性,然后生成一个不太通用的错误消息。所有这些都封装到我自己的绑定标记扩展中,因此我可以拥有我需要的所有默认值
因此,景色不错。用户犯了错误,看到了一些错误反馈,可以纠正它。但是,视图模型已丢失。由于绑定引擎引发异常,因此源从未更新。因此,视图模型仍然是旧值,而不是显示给用户的值,并且IDataErrorInfo
验证显然不适用
更糟糕的是,视图模型没有好的方法知道这一点。至少,我还没有找到解决这个问题的好办法。可能的情况是将视图报告返回给视图模型,说明存在错误。这可以通过将属性数据绑定回视图模型来实现(这是不可能直接实现的),因此视图模型可以首先检查视图的状态
另一个选项是将在Binding.UpdateSourceExceptionFilter
中处理的异常中继到视图模型,这样它也会收到通知。视图模型甚至可以为绑定提供一些接口来报告这些事情,允许定制错误消息,而不是泛型错误消息。但这将创建从视图到视图模型的更强耦合,这是我通常希望避免的
另一个“解决方案”是去掉所有类型化属性,使用普通的string
属性,并在视图模型中进行转换。这显然会将所有验证转移到视图模型,但也意味着数据绑定引擎通常会处理大量的重复工作。此外,它还会改变视图模型的语义。对我来说,视图是为视图模型构建的,而不是相反的。当然,视图模型的设计取决于我们想象视图要做什么,但仍然存在一些问题
if (bindingGroup.CommitEdit())
SaveEverything();
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);
}
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; }
}
public virtual bool HasError
{
get { return Errors != null && Errors.Count > 0; }
}
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;
}
}
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);
}
private bool IsGenreNameUnique(Genre genre)
{
return Genres.Where(d => d.Name != string.Empty && d.Name == genre.Name).Count() == 1;
}
public object FooObject { get; set; } // Implement INotifyPropertyChanged
public int Foo
{
get { return FooObject.GetType() == typeof(int) ? int.Parse(FooObject) : -1; }
}
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;
}
}
container.AddHandler(Validation.ErrorEvent, Container_Error);
...
void Container_Error(object sender, ValidationErrorEventArgs e) {
...
}
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;
}
}
}
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);
}
}
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>();
}
}
public Failable<int> NumberValue
{
//Custom logic along with validation
//using IsValid property
}
<TextBox Text="{Binding NumberValue,Converter={x:Static local:Failable.Int32Converter}}"/>