使用类型安全 ViewModel (TVM) 增强 MVVM 设计






4.68/5 (8投票s)
通过类型安全的绑定,更快地开发应用程序并使其更加健壮。
引言
近年来,模型-视图-视图模型(MVVM)模式越来越受欢迎,如今已成为 Windows Presentation Foundation (WPF) 编程的实际标准。然而,使用字符串存储的属性名称驱动 MVVM 架构最关键的部分——数据绑定——存在许多缺点:它使开发容易出错、忽略 Intellisense、限制重构,并使调试变得困难。
在本文中,我将介绍一种封装 WPF 数据绑定的方法,该方法使用类型安全的数据绑定层,用于将视图模型绑定到视图,同时保持视图模型独立于视图。这样,您将获得一个类型安全视图模型(TVM)。
在视图模型中:连接器而非属性
TVM 是一个简单的类,不需要继承。它使用连接器与视图进行通信。传统的 MVVM 使用属性。这些属性由视图使用反射发现。
您可以将连接器视为视图模型状态的一部分。连接器值的类型可以是任何 .NET 类型,例如 Byte 或代表数据库表的对象。
因此,一个简单的视图模型可能看起来像这样
using TypesaveViewModel;
…
class SimpleControlsViewModel
{
public readonly Connector<string> TextBoxConnector = new Connector<string>();
public readonly Connector<bool?> CheckBoxConnector = new Connector<bool?>();
public readonly Connector<DateTime?> DatePickerConnector = new Connector<DateTime?>();
}
每个连接器都有一个 Value 属性。您可以使用它将信息传播到视图
internal void ResetAll()
{
this.TextBoxConnector.Value = null;
this.CheckBoxConnector.Value = null;
this.DatePickerConnector.Value = null;
}
当然,它也可以用于从视图读取数据
internal void AdvanceAll()
{
if (string.IsNullOrWhiteSpace(this.TextBoxConnector.Value) ||
this.TextBoxConnector.Value[0] >= 'z')
this.TextBoxConnector.Value = "A";
else
this.TextBoxConnector.Value =
((char)((int)this.TextBoxConnector.Value[0] + 1)).ToString();
if (!this.CheckBoxConnector.Value.HasValue)
this.CheckBoxConnector.Value = false;
else if (this.CheckBoxConnector.Value == false)
this.CheckBoxConnector.Value = true;
else
this.CheckBoxConnector.Value = false;
this.DatePickerConnector.Value =
!this.DatePickerConnector.Value.HasValue
? DateTime.Today
: this.DatePickerConnector.Value.Value.AddDays(1);
}
在视图中:代码中的类型安全绑定
首先,需要在视图的 XAML 中包含视图模型
<Window x:Class="MyTVMApp.SimpleControlsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:MyTVMApp="clr-namespace:MyTVMApp"
Title="Simple Controls"
Height="300"
Width="300">
<Window.DataContext>
<MyTVMApp:SimpleControlsViewModel x:Name="viewModel" />
</Window.DataContext>
<Grid>
…
<TextBox Name="textBox1" Grid.Column="1" />
…
<Button Content="Reset all" Grid.Row="7"
Click="buttonResetAll_Click" />
…
请注意,视图模型和控件都需要命名。这使我们能够在代码中访问这些对象。这样,我们就可以将类型安全绑定放在代码隐藏文件中
using TypesaveViewModel.WpfBinding;
…
public partial class SimpleControlsWindow : Window
{
public SimpleControlsWindow()
{
InitializeComponent();
this.textBox1.BindText(this.viewModel.TextBoxConnector);
this.checkBox1.BindIsChecked(this.viewModel.CheckBoxConnector);
this.datePicker1.BindSelectedDate(this.viewModel.DatePickerConnector);
}
…
}
在键入此代码时,您会看到 Intellisense 提供了帮助:
Intellisense 识别 textBox1 的类型,并向您显示常见的绑定机会,例如 BindText 或 BindIsEnabled。此外,您还可以使用 Bind<> 来为绑定目标指定不太常见的属性。
我通过提供一组扩展方法(如 BindText)为 TVM 启用了 Intellisense。这一组可以轻松扩展。
顺便说一句,代码隐藏也是将按钮连接到视图模型方法的绝佳方式
private void buttonResetAll_Click(object sender, RoutedEventArgs e)
{
this.viewModel.ResetAll();
}
检测更改
视图-视图模型通信中的另一个重要方面是检测更改。一个方向是自动完成的:如果连接器的 Value 属性被设置为一个新值,它会自动传播到视图中的所有绑定目标。另一个方向有时也是需要的。当视图模型需要立即对视图中的某些内容采取行动时,就会出现这种情况。例如,示例代码中的层次结构窗口。
第二个列表中显示的项目取决于第一个列表中选择的项目。因此,如果第一个列表中的选择从“sea”更改为“forest”,则第二个列表应从“fish, submarine, whale”更改为“tree, deer ranger”。
这种情况可以通过将 Action 分配给绑定到选定项目的连接器的 OnValueChanged 属性来覆盖
public class HierarchyViewModel
{
public ListConnector<xelement> List1 = new ListConnector<xelement>();
public Connector<xelement> Selected1 = new Connector<xelement>();
public ListConnector<xelement> List2 = new ListConnector<xelement>();
…
public HierarchyViewModel()
{
…
Selected1.OnValueChanged = () => setDependentList(Selected1.Value, List2);
Selected2.OnValueChanged = () => setDependentList(Selected2.Value, List3);
…
}
private static void setDependentList(XElement v, ListConnector<xelement> dependentList)
{
dependentList.Value = v == null ? null : v.Elements();
}
}
转换类型
数据绑定中的最大挑战之一是处理不同的类型。想象一下,您的模型中有一个整数值,您希望在视图中进行编辑。标准的 WPF 控件提供了一个 TextBox,它只有一个 String 类型的 Text 属性。在大多数情况下(即,在可转换类型之间,包括它们的 nullable 变体),TVM 系统会在后台处理类型转换。但有时情况会超出简单性。考虑一个控件,它应该根据模型中的布尔值更改其颜色。
Numbers 示例显示了一些简单的转换(数字和字符串之间),以及一个更复杂的案例。根据 BindingErrorEventArgs 对象(包含最新的错误信息),视图中的各种属性应会发生更改。
在视图模型中,保持简单
public Connector<BindingErrorEventArgs> LastErrorConnector
= new Connector<BindingErrorEventArgs>();
对于视图,我们首先绑定到 TextBlock 的 Text 属性。该文本应该是
- 如果 BindingErrorEventArgs 对象为 null,则为 null
- 否则,它应该是“This error occurred on Connector:”或“This error is removed from Connector:”,具体取决于 BindingErrorEventArgs.IsRemoved
this.textBlockLastErrorCaption.Bind(
this.viewModel.LastErrorConnector,
TextBlock.TextProperty,
new Binding<string, BindingErrorEventArgs>(
binding =>
binding.Connector.Value == null
? null
: string.Format(
"This error {0} \"{1}\":",
binding.Connector.Value.IsRemoved ? "is removed from" : "occured on",
binding.Connector.Value.Binding.Connector.Name),
null
)
);
其次,ErrorMessage 应显示为 textBoxLastErrorText 的 Text
this.textBoxLastErrorText.Bind(
this.viewModel.LastErrorConnector,
TextBox.TextProperty,
new Binding<string, BindingErrorEventArgs>(
binding =>
binding.Connector.Value == null
? null
: binding.Connector.Value.ErrorMessage,
null
)
);
第三,如果 BindingErrorEventArgs.IsRemoved 为 true,则 textBoxLastErrorText 的文本应为灰色,否则为红色。
this.textBoxLastErrorText.Bind(
this.viewModel.LastErrorConnector,
Control.ForegroundProperty,
new Binding<Brush, BindingErrorEventArgs>(
binding =>
binding.Connector.Value != null &&
binding.Connector.Value.IsRemoved ? Brushes.Gray : Brushes.Red,
null
)
);
请注意,在这三种情况下,我们都实例化了一个新的 Binding
支持单选按钮
类型转换的一个特殊情况经常被使用。为此,我为 RadioButton 提供了特殊支持。这允许您将状态建模为 enum,并通过视图中的一组 RadioButton 控件来表示它。在视图模型中看起来像这样
using TypesaveViewModel;
…
class SimpleControlsViewModel
{
public enum Choice { A=1, B, C }
public readonly Connector<Choice> RadioButtonConnector = new Connector<Choice>();
…
在视图中,RadioButtonConnector 绑定到一组 RadioButton 控件
using TypesaveViewModel.WpfBinding;
…
public partial class SimpleControlsWindow : Window
{
public SimpleControlsWindow()
{
InitializeComponent();
…
this.radioButton1A.BindIsChecked(SimpleControlsViewModel.Choice.A,
this.viewModel.RadioButtonConnector);
this.radioButton1B.BindIsChecked(SimpleControlsViewModel.Choice.B,
this.viewModel.RadioButtonConnector);
this.radioButton1C.BindIsChecked(SimpleControlsViewModel.Choice.C,
this.viewModel.RadioButtonConnector);
…
处理错误
在我看来,WPF 最神奇的功能之一是验证支持。我对 TVM 模式的首次实现比我在此介绍的要短得多。它完全绕过了 WPF 绑定。缺点是我找不到简单的方法将 TVM 融入 WPF 验证。
我目前实现的类型安全绑定允许对视图进行简单的本机 WPF 绑定错误处理。例如,如果您希望在 TextBox 控件的工具提示中看到验证错误,您的 XAML 可能看起来像这样
<Window x:Class="MyTVMApp.NumbersWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:MyTVMApp="clr-namespace:MyTVMApp" Title="Numbers"
Height="338"
Width="498">
在视图模型中,您可以使用 ErrorInfo 属性来访问 TVM 错误处理系统。通常,对多个连接器使用相同的错误处理是一个好主意:想象一个带有“OK”或“Send”按钮的输入表单。当用户按下该按钮时,您可能希望检查输入字段中的错误。如果存在错误,您将告知用户她应该做什么,并拒绝操作,直到错误得到修复。可以通过 ConnectorCollection 类构建这样一组连接器
using TypesaveViewModel;
…
public class NumbersViewModel
{
public Connector<byte> ByteConnector = new Connector<byte> { Name = "Byte" };
public Connector<int> IntConnector = new Connector<int> { Name = "Int" };
public Connector<double> DoubleConnector = new Connector<double> { Name = "Double" };
public Connector<double?> NullableDoubleConnector = new Connector<double?> { Name = "Nullable Double" };
public Connector<string> ResultsConnector = new Connector<string>();
…
private readonly ConnectorCollection connectors;
public NumbersViewModel()
{
connectors = new ConnectorCollection(ByteConnector, IntConnector, DoubleConnector, NullableDoubleConnector);
…
}
…
internal void GetResults()
{
var results = string.Format("Byte: {0}\nInt: {1}\nDouble: {2}\nNullable Double: {3}",
ByteConnector.Value, IntConnector.Value, DoubleConnector.Value, NullableDoubleConnector.Value);
if (connectors.ErrorInfo.HasError)
results += string.Format("\n\n-- Errors --\n{0}", connectors.ErrorInfo);
this.ResultsConnector.Value = results;
}
}
请注意,这允许您轮询错误。但有时您可能希望在发生错误时立即收到通知。这时您应该订阅 ErrorInfo 类成员的 ErrorChanged 事件
public NumbersViewModel()
{
connectors = new ConnectorCollection(ByteConnector, IntConnector,
DoubleConnector, NullableDoubleConnector);
connectors.ErrorInfo.ErrorChanged += ErrorInfo_ErrorChanged;
}
void ErrorInfo_ErrorChanged(object sender, BindingErrorEventArgs e)
{
…
= e; // Use info found in BindingErrorEventArgs
}
ErrorInfo 属性不仅可以在 ConnectionCollection 类中找到,还可以在 Connector 类和类型安全绑定类中找到。
摘要
在一些 MVVM 项目中积累经验后,我设计了本文介绍的 TVM 模式。它涵盖了许多项目中常见的用例。我将在未来的项目中使用它,并在此发布更新。我真心希望您能发现它有用,并在此分享您的经验。任何评论都将不胜感激。