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

WPF/Silverlight:MVVM 分步指南

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.66/5 (26投票s)

2011 年 7 月 6 日

CPOL

8分钟阅读

viewsIcon

114656

downloadIcon

5429

本文旨在提供 MVVM 设计模式的基本概述,该模式在 WPF/Silverlight 应用程序开发人员中非常流行。这是一个非常基础的实践教程,旨在为 MVVM 新手提供分步指南。

引言

MVVM 是 Model-View-ViewModel 模式的缩写,广泛用于 WPF/Silverlight 编程。这种设计模式最初由 John Gossman 提出,主要用于视图 (View)、视图模型 (ViewModel) 和模型 (Model) 的分离和易于测试。

Model - View - ViewModel

首先,我来解释一下 MVVM 的三个部分

模型

我们都知道,模型 (Model) 代表数据层。

视图

视图 (View) 代表用户界面或外观。

ViewModel

视图模型 (ViewModel) 是中间人,其职责是以一种可以被视图 (View) 使用的方式来处理模型 (Model) 中的数据。对一些人来说,MVVM 是一个陡峭的学习曲线。请放心,只要牢记四点,它就非常容易。

你也可以将这四个步骤称为 MVVM 的 GURU MANTRA (大师箴言)

mvvm.png

  1. 尽量减少代码隐藏。也就是你的 View.xaml.cs 文件,它应该几乎没有代码。甚至连事件处理程序都没有。这并不意味着必须完全零代码,我所说的是我们应该尽量减少代码隐藏。一个例外是逻辑 纯粹面向视图 (并且只针对该视图非常具体) 且与 ViewModel Model 同一 ViewModel 的其他视图 无关的情况。例如,鼠标悬停时你想弹出工具提示,你可能会选择在 xaml.cs 中实现 (当然,你也可以通过在 xaml 本身使用触发器来实现,这里只是举个例子)。在这种代码隐藏中没有问题,因为它与 ViewModel Model 无关。
    说了这些,我想提一下上面规则的一些 例外情况
    1. 依赖属性 (Dependency properties) 总是代码隐藏的一部分,因此这并不意味着你应该避免在 MVVM 中使用依赖属性。
    2. 有时你必须使用不符合 MVVM 标准的第三方控件。在这种情况下,你最终也会有一些代码隐藏。
  2. 所有事件或操作都应被视为命令 (Command)。你的问题会是,怎么做到这一点?我的控件有点击事件行为但没有命令。我的回答是,有很多方法可以做到这一点。所有这些将在文章的其余部分详细解释。
  3. ViewModel 是 View 的 DataContext。所以 View 没有 ViewModel 的实例,ViewModel 也没有 View 的实例。将 View.DataContext 强制转换为 ViewModel 是不正确且丑陋的。这会破坏你真正的 MVVM 模型。 
  4. 独立设计 ViewModel 和 View。这意味着在设计视图时必须考虑到,如果有一天视图需要被另一个视图 (另一种外观和感觉) 替换,它不应该需要 ViewModel Model 的改变。因此,不要在 ViewModel 中编写任何针对特定视图的代码,也不要在视图中编写任何针对 ViewModel 的代码。

开始 MVVM 之前的准备工作

  1. 学习并理清数据绑定 (Databinding) 的概念。
  2. 学习如何使用依赖属性 (Dependency Properties)。
  3. 学习转换器 (Converters) 的使用。
  4. 最后但同样重要的一点是,INotifyPropertyChanged

我们先从基础开始

数据绑定 (Data Binding):数据绑定是建立应用程序 UI 和业务逻辑之间连接的过程。如果绑定设置正确,并且数据提供适当的通知,那么当数据更改其值时,与数据绑定的元素会自动反映这些更改。数据绑定也可以意味着,如果元素中数据的外部表示发生变化,那么底层数据会自动更新以反映该变化。例如,如果用户编辑 TextBox 元素中的值,底层数据值会自动更新以反映该更改。

依赖属性 (Dependency Property):依赖属性就像普通的 CLR 属性,但有一个 X 因子。X 因子在于,这些属性内置了对数据绑定、动画、样式、值表达式、属性验证、每类型默认值等的支持。归根结底,我们需要依赖属性来保存 UI 元素的 UI 状态。说依赖属性总是 View 代码隐藏的一部分,而普通 (CLR) 属性更有可能属于 ViewModel Model ,这是没错的。就像定义 CLR 属性有语法 (setter, getter) 一样,依赖属性也有语法。语法是

public class MySampleControl : Control
{
    // Step 1: Register the dependency property 
    public static readonly DependencyProperty SpecialBrushProperty =
            DependencyProperty.Register("SpecialBrush", typeof(Brush),
            typeof(MySampleControl));

    // Step 2: Provide set/get accessors for the property 
    public Brush SpecialBrush
    {
        get { return (Brush)base.GetValue(SpecialBrushProperty); }
        set { base.SetValue(SpecialBrushProperty, value); }
    }
}

请注意,还有其他方法可以声明依赖属性的 setter 和 getter。同时也要注意,有多种方法和组合可以注册依赖属性,例如将其注册为附加属性 (这使得依赖属性的值可以从父控件传递到子控件。例如,如果你改变 Grid 控件的字体,它的所有子控件也会开始显示父 Grid 的字体),等等。

转换器 (Converters):数据转换或值转换通常在你希望视图中的数据与当前数据略有不同时需要。例如,在视图中,你想根据项目状态显示红绿灯信号。即项目评级为 8-10 时为绿色,评级为 5-7 时为黄色,评级低于此为红色。在这种情况下,在 ViewModel 中保留一个对应颜色的变量是没有意义的,看起来很丑陋。这里将项目评级显示为颜色是一个纯粹的 View 需求。明天,如果这个视图被替换成另一个视图,你将项目评级显示为数字,你的颜色变量就完全浪费了。那么如何做到这一点呢?解决方案很简单,使用值转换器 (Value converters)。值转换器完全属于 View ,是视图想要绿色、黄色或红色的颜色,而 ViewModel 不关心颜色。值转换器必须继承自 IValueConverter IMultiValueConverter (如果你需要多个值作为输入。例如,在红绿灯示例中,假设当项目健康且截止日期在可接受范围内时,你显示绿色。那么我们有两个输入:评级和截止日期)。

[ValueConversion(typeof(Project), typeof(Brush))]
public class ColorConverter : IValueConverter
{
    public object Convert(object value, Type targetType,
        object parameter, CultureInfo culture)
    {
        Project project = (Project)value;
        if (project.Rating >= 8)//its healthy if rating is 8 to 10
              return new SolidColorBrush(Colors.Green);
        else if (project.Rating >= 5) )//its ok if rating is 5 to 7
              return new SolidColorBrush(Colors.Yellow);
        else //less its unhealthy
              return new SolidColorBrush(Colors.Red);
    }    

    public object ConvertBack(object value, Type targetType,
        object parameter, CultureInfo culture)
    {
        return null;
    }
}

INotifyPropertyChangedINotifyPropertyChanged 自 Winform 时代就存在了。在 WPF/Silverlight 中它并不是一个新概念。这是一个你可能希望在 ViewModel 中实现的接口,用于通知你的 View 某些内容已更改。Observable Collection 和 Dependency Property 有一些内置的通知机制。对于这些属性,你可能不需要通过代码引发通知。

public class SomeViewModel : ViewModelBase, INotifyPropertyChanged
{
     ….
     ….
    private Module _SelectedModule;
    public Module SelectedModule
    {
            get
            {
                return _SelectedModule;
            }
            set
            {
                _SelectedModule = value;
                RaisePropertyChanged("SelectedModule");
            }
    }
    …
    …
    #region INotifyPropertyChanged 
    public event PropertyChangedEventHandler PropertyChanged;

    protected void RaisePropertyChanged(string propertyName)
    {
            var handler = PropertyChanged;
            if (handler != null)
            {
                handler(this, new PropertyChangedEventArgs(propertyName));
            }
    }
    #endregion 

回到正题,让我们先从模型 (Model) 开始:接下来的内容,我将通过一个显示员工信息的演示应用程序来解释。

Model (模型):顾名思义,它保存数据的模型。有些人喜欢将模型称为数据模型。你所有的数据库查询将首先填充数据模型,然后数据模型将被 ViewModel View 使用。例如,我在此演示一个 Employee 类。该类具有对应我数据字段的属性,如姓名、备注、年龄等。

public class Employee : IDataErrorInfo, INotifyPropertyChanged
{
    #region Constructor
    public Employee(string id = "", string name = "", uint age = 0, string notes = "")
    {
        _id = id;
        _name = name;
        _age = age;
        _notes = notes;
    }
    #endregion

    #region Properties
    private string _id = string.Empty;

    public string ID
    {
        get { return _id; }
        set
        {
            _id = value;
            RaisePropertyChanged("ID");
        }
    }

    private string _name = string.Empty;

    public string Name
    {
        get { return _name; }
        set
        {
            _name = value;
            RaisePropertyChanged("Name");
        }
    }

    private uint _age = 0;

    public uint Age
    {
        get { return _age; }
        set
        {
            _age = value;
            RaisePropertyChanged("Age");
        }
    }

    private string _notes = string.Empty;

    public string Notes
    {
        get { return _notes; }
        set
        {
            _notes = value;
            RaisePropertyChanged("Notes");
        }
    }
    #endregion

    #region IDataErrorInfo
    public string Error
    {
        get
        {
            return this[string.Empty];
        }
    }

    public string this[string propertyName]
    {
        get
        {
            string result = string.Empty;
            propertyName = propertyName ?? string.Empty;

            if (propertyName == string.Empty || propertyName == "ID")
            {
                if (string.IsNullOrEmpty(this.ID))
                {
                    result = "Employee ID is invalid. ID cannot be null or blank";
                }
                else if (System.Text.RegularExpressions.Regex.IsMatch
					(this.ID, "[/!@#?/}[}{ ]"))
                {
                    result = "Employee ID is invalid. 
				ID cannot have special characters";
                }
            }
            else if (propertyName == "Name")
            {
                if (string.IsNullOrEmpty(this.Name))
                {
                    result = "Name is invalid. ID cannot be null or blank";
                }
            }
            else if (propertyName == "Age")
            {
                if (Age > 150 || Age < 0)
                {
                    result = "Age is invalid. Age should be between 0 and 150";
                }
            }

            return result;
        }
    }
    #endregion

    #region INotifyPropertyChanged
    [field: NonSerialized]
    public event PropertyChangedEventHandler PropertyChanged;

    protected void RaisePropertyChanged(string propertyName)
    {
        var handler = PropertyChanged;
        if (handler != null)
        {
            handler(this, new PropertyChangedEventArgs(propertyName));
        }
    }
    #endregion
}

我的模型继承自 IDataErrorInfo INotifyPropertyChanged但设计模型时并非强制要求。这只是为了实现数据验证,而不是为了 MVVM。模型没有太多需要解释的,让我们继续下一级 ViewModel

ViewModel (视图模型):在博客的开头,我提到 ViewModel 就是 View DataContext。由于 ViewModel 的职责是在 View Model 之间充当中间人,它将始终拥有 Model 类的一个实例,并且为了反映模型中的变化,它将通过通知 (INotifyPropertyChanged 等) 或数据绑定来通知 View View 的所有命令都在这里处理。请记住,一个 ViewModel 可能拥有多个视图,所以永远不要在这里编写任何特定于某个视图的代码。以我们的 Employee 示例为例,在我们的视图中,左侧是员工列表,右侧是选定员工的详细信息,因此我们的 viewModel 作为 View 的 DataContext ,必须提供一个员工列表和一个用于表示选定的属性。因此,我们在 viewModel 中最终有两个属性:Employees SelectedEmployee

public class EmployeeListViewModel : INotifyPropertyChanged
{
    public ObservableCollection<Employee> Employees { get; private set; }

    public EmployeeListViewModel()
    {
        Employees = MVVMDemo.DataHelper.EmployeeDataHelper.CookEmployeesData();
    }

    private Employee _SelectedEmployee;
    public Employee SelectedEmployee
    {
        get
        {
            return _SelectedEmployee;
        }
        set
        {
            _SelectedEmployee = value;
            RaisePropertyChanged("SelectedEmployee");
        }
    }

    #region INotifyPropertyChanged
    [field: NonSerialized]
    public event PropertyChangedEventHandler PropertyChanged;

    protected void RaisePropertyChanged(string propertyName)
    {
        var handler = PropertyChanged;
        if (handler != null)
        {
            handler(this, new PropertyChangedEventArgs(propertyName));
        }
    }
    #endregion
}

View (视图):视图是外观和感觉。视图由 xaml 文件及其 xaml.cs 文件表示。你所有的动画、装饰、主题、控件等都放在这里。视图的编写应考虑到,明天如果你想以不同的方式显示你的数据,它不应该需要你触碰 ViewModel Model 。例如,如果你想在 datagrid listbox 、tree 或 even tabs 中显示你的员工列表,这对你的 ViewModel Model 来说不应该是一个问题,改变外观和感觉纯粹是 View 的工作,因为最终要呈现的是相同的数据,只是方式略有不同。在我们 Employee 列表的示例中,View 左侧有一个 ListView ,右侧显示选定员工的详细信息。因此,我们有一个 xamlxaml.cs 文件。但是 xaml.cs 几乎没有重要的内容,因此可以暂时忽略其代码。

EmployeeListView.xaml
 <Window x:Class="MVVMDemo.View.EmployeeListView"
        xmlns=http://schemas.microsoft.com/winfx/2006/xaml/presentation
        xmlns:x=http://schemas.microsoft.com/winfx/2006/xaml
        xmlns:VM="clr-namespace:MVVMDemo.ViewModel"
        xmlns:View="clr-namespace:MVVMDemo.View"
        Title="Employee View" Height="300" Width="720">
    <Window.DataContext>
        <VM:EmployeeListViewModel/>
    </Window.DataContext>
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="152*" />
            <ColumnDefinition Width="568*" />
        </Grid.ColumnDefinitions>
        <ListView x:Name="listEmp" 
	ItemsSource="{Binding Employees, UpdateSourceTrigger=PropertyChanged}" 
	Grid.Column="0" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" 
	SelectedValue="{Binding SelectedEmployee, 
	UpdateSourceTrigger=PropertyChanged}">
            <ListView.View>
                <GridView>
                    <GridViewColumn DisplayMemberBinding="{Binding ID}" 
			Header="ID" Width="40" />
                    <GridViewColumn DisplayMemberBinding="{Binding Name}" 
			Header="Name" Width="100"/>
                </GridView>
            </ListView.View>
        </ListView>
        <View:EmployeeView Grid.Column="1" 
		DataContext="{Binding SelectedEmployee, 
		UpdateSourceTrigger=PropertyChanged}"/>
    </Grid>
</Window> 
EmployeeView.xaml
<UserControl x:Class="MVVMDemo.View.EmployeeView"
             xmlns=http://schemas.microsoft.com/winfx/2006/xaml/presentation
             xmlns:x=http://schemas.microsoft.com/winfx/2006/xaml
             xmlns:mc=http://schemas.openxmlformats.org/markup-compatibility/2006
             xmlns:wc="clr-namespace:System.Windows.Controls;
			assembly=PresentationFramework"
             xmlns:VM="clr-namespace:MVVMDemo.ViewModel"
             MinHeight="245" MinWidth="400"
>
    <Grid>
        <Grid.Resources>
            <Storyboard x:Key="FlashErrorIcon">
                <ObjectAnimationUsingKeyFrames BeginTime="00:00:00"
                                 Storyboard.TargetProperty="(UIElement.Visibility)">
                    <DiscreteObjectKeyFrame KeyTime="00:00:00" 
			Value="{x:Static Visibility.Hidden}"/>
                    <DiscreteObjectKeyFrame KeyTime="00:00:00.4000000" 
			Value="{x:Static Visibility.Visible}"/>
                    <DiscreteObjectKeyFrame KeyTime="00:00:00.8000000" 
			Value="{x:Static Visibility.Hidden}"/>
                    <DiscreteObjectKeyFrame KeyTime="00:00:01.6000000" 
			Value="{x:Static Visibility.Visible}"/>
                    <DiscreteObjectKeyFrame KeyTime="00:00:02.4000000" 
			Value="{x:Static Visibility.Hidden}"/>
                    <DiscreteObjectKeyFrame KeyTime="00:00:03.2000000" 
			Value="{x:Static Visibility.Visible}"/>
                    <DiscreteObjectKeyFrame KeyTime="00:00:01" 
			Value="{x:Static Visibility.Visible}"/>
                </ObjectAnimationUsingKeyFrames>
            </Storyboard>
            <Style TargetType="{x:Type TextBox}">
                <Style.Triggers>
                    <Trigger Property="Validation.HasError" Value="true">
                        <Setter Property="Background" Value="Pink"/>
                        <Setter Property="Foreground" Value="Black"/>
                    </Trigger>
                </Style.Triggers>
                <Setter Property="Validation.ErrorTemplate">
                    <Setter.Value>
                        <ControlTemplate>
                            <DockPanel LastChildFill="True"                   
                                       ToolTip="{Binding ElementName=controlWithError,
					Path=AdornedElement.(Validation.Errors)
						[0].ErrorContent}">
                                <Ellipse DockPanel.Dock="Right"
                                 ToolTip="{Binding ElementName=controlWithError,
                                     Path=AdornedElement.(Validation.Errors)
						[0].ErrorContent}"
                                 Width="15" Height="15"
                                 Margin="-25,0,0,0"
                                 StrokeThickness="1" Fill="IndianRed" >
                                    <Ellipse.Stroke>
                                        <LinearGradientBrush EndPoint="1,0.5" 
						StartPoint="0,0.5">
                                          <GradientStop Color="#FFFA0404" Offset="0"/>
                                          <GradientStop Color="#FFC9C7C7" Offset="1"/>
                                        </LinearGradientBrush>
                                    </Ellipse.Stroke>
                                    <Ellipse.Triggers>
                                        <EventTrigger RoutedEvent=
						"FrameworkElement.Loaded">
                                            <BeginStoryboard Storyboard=
					    "{StaticResource FlashErrorIcon}"/>
                                        </EventTrigger>
                                    </Ellipse.Triggers>
                                </Ellipse>
                                <TextBlock DockPanel.Dock="Right"
                                ToolTip="{Binding ElementName=controlWithError,
                                     Path=AdornedElement.(Validation.Errors)
						[0].ErrorContent}"
                                Foreground="White"
                                FontSize="10"
                                Margin="-15,5,0,0" FontWeight="Bold">!
                            <TextBlock.Triggers>
                                <EventTrigger RoutedEvent="FrameworkElement.Loaded">
                                    <BeginStoryboard Storyboard=
					"{StaticResource FlashErrorIcon}"/>
                                </EventTrigger>
                            </TextBlock.Triggers>
                                </TextBlock>
                                <Border BorderBrush="Red" BorderThickness="1">
                                    <AdornedElementPlaceholder 
					Name="controlWithError"/>
                                </Border>

                            </DockPanel>
                        </ControlTemplate>
                    </Setter.Value>
                </Setter>
            </Style>
        </Grid.Resources>
        <Grid.RowDefinitions>
            <RowDefinition Height="50*" />
            <RowDefinition Height="33*" />
            <RowDefinition Height="33*" />
            <RowDefinition Height="33*" />
            <RowDefinition Height="63*" />
            <RowDefinition Height="33*" />
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="75*" />
            <ColumnDefinition Width="425*" />
        </Grid.ColumnDefinitions>
        <TextBlock Grid.ColumnSpan="2" Text="Employee Details" 
		HorizontalAlignment="Center" VerticalAlignment="Center" 
		FontFamily="Tahoma" FontStyle="Italic" FontWeight="Bold" 
		Foreground="DodgerBlue" />
        <TextBlock Grid.Column="0" Grid.Row="1" Margin="5"  Text="ID :" 
		HorizontalAlignment="Right" VerticalAlignment="Center" />
        <TextBox Grid.Column="1" Grid.Row="1" Margin="5,5,15,5" 
		Text="{Binding ID,ValidatesOnDataErrors=true, 
		NotifyOnValidationError=true}" />
        <TextBlock Grid.Column="0" Grid.Row="2" Margin="5"  
	    	Text="Name :" HorizontalAlignment="Right" 
		VerticalAlignment="Center" />
        <TextBox Grid.Column="1" Grid.Row="2" Margin="5,5,15,5" 
    	    	Text="{Binding Name,ValidatesOnDataErrors=true, 
		NotifyOnValidationError=true}" />
        <TextBlock Grid.Column="0" Grid.Row="3" Margin="5"  
	    	Text="Age :" HorizontalAlignment="Right" 
		VerticalAlignment="Center" />
        <TextBox Grid.Column="1" Grid.Row="3" Margin="5,5,15,5" 
		Text="{Binding Age,ValidatesOnDataErrors=true, 
		NotifyOnValidationError=true}" />
        <TextBlock Grid.Column="0" Grid.Row="4" Margin="5"  
		Text="Notes :" HorizontalAlignment="Right" 
		VerticalAlignment="Center" />
        <TextBox Grid.Column="1" Grid.Row="4" Margin="5,5,15,5" 
		Text="{Binding Notes}" />
    </Grid>
</UserControl>

一些有用的 MVVM 框架

也别忘了查看一些方便的 MVVM 工具/框架

© . All rights reserved.