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

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.68/5 (8投票s)

2012 年 9 月 3 日

CPOL

6分钟阅读

viewsIcon

41283

downloadIcon

328

通过类型安全的绑定,更快地开发应用程序并使其更加健壮。

引言

近年来,模型-视图-视图模型(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 对象。TView 类型为 string,而前两种情况下的 TModel 类型为 BindingErrorEventArgs,第三种情况下的 TModel 类型为 Brush。每个构造函数都接受一个 Func 参数,该参数指定如何从模型中检索视图所需的值。在所有这三种情况下,第二个构造函数参数均为 null,表示这三个视图属性都是“只读”的,即数据单向从模型流向视图。

支持单选按钮

类型转换的一个特殊情况经常被使用。为此,我为 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 模式。它涵盖了许多项目中常见的用例。我将在未来的项目中使用它,并在此发布更新。我真心希望您能发现它有用,并在此分享您的经验。任何评论都将不胜感激。

单击此处在新窗口中查看类图

© . All rights reserved.