WPF/Silverlight:MVVM 分步指南






4.66/5 (26投票s)
本文旨在提供 MVVM 设计模式的基本概述,该模式在 WPF/Silverlight 应用程序开发人员中非常流行。这是一个非常基础的实践教程,旨在为 MVVM 新手提供分步指南。
引言
MVVM 是 Model-View-ViewModel 模式的缩写,广泛用于 WPF/Silverlight 编程。这种设计模式最初由 John Gossman 提出,主要用于视图 (View)、视图模型 (ViewModel) 和模型 (Model) 的分离和易于测试。
首先,我来解释一下 MVVM 的三个部分。
模型
我们都知道,模型 (Model) 代表数据层。
视图
视图 (View) 代表用户界面或外观。
ViewModel
视图模型 (ViewModel) 是中间人,其职责是以一种可以被视图 (View) 使用的方式来处理模型 (Model) 中的数据。对一些人来说,MVVM 是一个陡峭的学习曲线。请放心,只要牢记四点,它就非常容易。
你也可以将这四个步骤称为 MVVM 的 GURU MANTRA (大师箴言)。
- 尽量减少代码隐藏。也就是你的 View.xaml.cs 文件,它应该几乎没有代码。甚至连事件处理程序都没有。这并不意味着必须完全零代码,我所说的是我们应该尽量减少代码隐藏。一个例外是逻辑 纯粹面向视图 (并且只针对该视图非常具体) 且与
ViewModel
、Model
或 同一 ViewModel 的其他视图 无关的情况。例如,鼠标悬停时你想弹出工具提示,你可能会选择在 xaml.cs 中实现 (当然,你也可以通过在 xaml 本身使用触发器来实现,这里只是举个例子)。在这种代码隐藏中没有问题,因为它与ViewModel
或Model
无关。
说了这些,我想提一下上面规则的一些 例外情况。 - 依赖属性 (Dependency properties) 总是代码隐藏的一部分,因此这并不意味着你应该避免在 MVVM 中使用依赖属性。
- 有时你必须使用不符合 MVVM 标准的第三方控件。在这种情况下,你最终也会有一些代码隐藏。
- 所有事件或操作都应被视为命令 (Command)。你的问题会是,怎么做到这一点?我的控件有点击事件行为但没有命令。我的回答是,有很多方法可以做到这一点。所有这些将在文章的其余部分详细解释。
- ViewModel 是 View 的 DataContext。所以
View
没有ViewModel
的实例,ViewModel
也没有View
的实例。将View.DataContext
强制转换为ViewModel
是不正确且丑陋的。这会破坏你真正的 MVVM 模型。 - 独立设计 ViewModel 和 View。这意味着在设计视图时必须考虑到,如果有一天视图需要被另一个视图 (另一种外观和感觉) 替换,它不应该需要
ViewModel
或Model
的改变。因此,不要在ViewModel
中编写任何针对特定视图的代码,也不要在视图中编写任何针对ViewModel
的代码。
开始 MVVM 之前的准备工作
- 学习并理清数据绑定 (Databinding) 的概念。
- 学习如何使用依赖属性 (Dependency Properties)。
- 学习转换器 (Converters) 的使用。
- 最后但同样重要的一点是,
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;
}
}
INotifyPropertyChanged:INotifyPropertyChanged
自 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
,右侧显示选定员工的详细信息。因此,我们有一个 xaml 和 xaml.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 工具/框架