5 MVVM
1.概述
MVVM各个部分功能如下:
- Model:定义业务逻辑
- View:定义面向用户接口,UI逻辑,处理用户交互请求
- ViewModel:负责界面导航逻辑和应用状态管理,呈现逻辑。
1.1. 各司其职
view
定义了界面的结构和样式,后台代码不能包含任何其他需要进行单元测试的逻辑。
从面向对象的角度看,view是一个可视化元素,如一个window,page,user control,或者data template。view定义了控件的布局和样式。View通过属性DataContext与ViewModel建立联系。view可以定制view和view model间的数据绑定行为。例如使用数据转换或者额外验证规则。
从UI角度看,View定义和处理了UI的可视化行为,包括动画或者状态转换(如只有在登录以后才能点击某个按钮)。一般UI逻辑均包含在xaml文件中,只有那些难以在xaml文件表达的逻辑才放在xaml后台代码中。
View Model
View Model包含以下关键特征:
- view Model是一个非可视化类,并不继承至任何WPF类。它封装了呈现逻辑,View Model能单独被测试。
- View Model并不直接引用view。它暴露状态,命令和数据集供View绑定。
- View model协调View与Model间的交互。它会添加一些Model不具有的属性。也进行数据验证,IDataErrorInfo和INotifyDataErrorInfo。
Model
Model包含如下关键特征:
- 非可视化类,封装应用数据和商业逻辑
- 不直接引用View或者View Model
- 实现相应通知接口
- 包含数据库访问或者web服务,缓存功能
2. 拧起来又是一股绳-类交互
设计良好的Model,View以及View Model不仅能合理封装相应功能,还包含多种方法实现类与类之间的交互。最重要的类交互当属View与View Model间交互,下面详细阐述不同方法:
2.1. 数据绑定
通过WPF的Data Binding原理,我们可以很容易实现View与View Model间的数据交互,具体在代码上体现是实现不同的接口。依据数据的是单一还是集合可以分为三种接口。
INotifyPropertyChanged
虽然数据绑定能实现数据在类间传递,但是使用该技术有一个前提,就是被 绑定的对象需要实现某种通知功能。Model,View Model的普通 数据不具备该功能。为了实现通知机制,相应的类需要实现INotifyPropertyChanged接口。由于该机制非常普遍,Prism框架提供BindableBase抽象类,相应类只要继承该接口就可实现通知机制。
public abstract class BindableBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
...
protected virtual bool SetProperty<T>(ref T storage, T value,
[CallerMemberName] string propertyName = null)
{...}
protected void OnPropertyChanged<T>(
Expression<Func<T>> propertyExpression)
{...}
}
使用该类时需要调用BindableBase类的SetProperty方法。
public TransactionInfo TransactionInfo
{
get { return this.transactionInfo; }
set
{
SetProperty(ref this.transactionInfo, value);
//TransactionInfo与TickerSymbol耦合,如果要通知TickerSymbol值改变
//,需要主动调用OnPropertyChanged
this.OnPropertyChanged(() => this.TickerSymbol);
}
}
INotifyCollectionChanged
当交互的数据是一个集合时,使用该接口实现通知机制,该接口存在于命名空间System.Collections.ObjectModel。WPF提供ObservableCollection模板,该模板继承至接口INotifyCollectionChanged,View Model类只需使用该类"包装"需要的数据就可以实现通知机制,另外同样还需要继承BindableBase类。
public class OrderViewModel : BindableBase
{
public OrderViewModel( IOrderService orderService )
{
this.LineItems = new ObservableCollection<OrderLineItem>(
orderService.GetLineItemList() );
}
public ObservableCollection<OrderLineItem> LineItems { get; private set; }
}
但是该模板只能提供简单功能,对于复杂的集合操作,还需要使用另一个类包装。
ICollectionView
问题:当你需要对数据集合进行过滤,排序,分组,或者跟踪当前选择的元素时,需要使用接口类ICollectionView封装。WPF提供ListCollectionView类实现该接口。
约束:WPF中任何继承至ItemsControl类的控件均可自动与ICollectionView交互。如下:
using System.ComponentModel;
using System.Windows.Data;
//...
public class MyViewModel : BindableBase
{
public ICollectionView Customers { get; private set; }
public MyViewModel( ObservableCollection<Customer> customers )
{
// Initialize the CollectionView for the underlying model
// and track the current selection.
Customers = new ListCollectionView( customers );
Customers.CurrentChanged +=SelectedItemChanged;
}
private void SelectedItemChanged( object sender, EventArgs e )
{
Customer current = Customers.CurrentItem as Customer;
...
}
}
在xaml文件中,你可以将ListCollectionView绑定到ItemsControl的ItemsSource属性。
<ListBox ItemsSource="{Binding Path=Customers}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Text="{Binding Path=Name}"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
2.2. Commands
有时我们传递的不是一个数据如一个字符串,而是一个命令或者动作,这又如何处理?考虑传统Winform程序,我们在界面后台定义句柄,包含逻辑代码,然后绑定到处理的事件。在WPF中,也可以放在后台,但是这就导致界面逻辑与功能逻辑耦合,为了实现MVVM模式,我们将句柄内容定义在View Model中。但如前所述,View Model不晓得任何有关界面的内容,也就无从得知处理的事件,这时Command机制出现。该机制将处理逻辑封装起来,供View使用。封装方法有两种,一是将处理逻辑封装在方法中,二是封装在对象中,该对象实现ICommand接口。有多种可用的对象,Blend提供ActionCommand对象,Prism提供DelegateCommand对象。这里重点讨论DelegateCommand。
DelegateCommand
注意:CommandParameter类型应为object或者string类型或可空值类型。
类DelegateCommand封装两个委托,一个是ExecuteMethod,另一个是CanExecute,该类继承DelegateCommandBase,实现了ICommand接口两个方法Execute和CanExecute。ExecuteMethod是命令需要执行的方法,CanExecute则是表明是否可以执行命令,返回Bool值,可以缺省,缺省情况代表永远可以执行。一般触发命令是先看是否可以执行,如果可行,执行相应逻辑代码。
using System.Windows.Input;
using Prism.Commands;
//...
public class QuestionnaireViewModel
{
public QuestionnaireViewModel()
{
this.SubmitCommand = new DelegateCommand<object>(
this.OnSubmit, this.CanSubmit );
}
public ICommand SubmitCommand { get; private set; }
private void OnSubmit(object arg) {...}
private bool CanSubmit(object arg) { return true; }
}
在View界面调用命令方法很简单,使用前面提到Data Binding机制。注意继承类ButtonBase控件如Button,RadioButton,Hyperlink,MenuItem等都拥有Command属性,可以将Command对象绑定到该属性,同时可以传入参数CommandParameter,如下:
<Button Command="{Binding Path=SubmitCommand}" CommandParameter="SubmitOrder"/>
上述代码仅适用鼠标左点击事件,对于其他点击事件,需要使用InputBindings,如下:
<Button>
<Button.InputBindings>
<MouseBinding Gesture="LeftDoubleClick" Command="{Binding DeleteCommand}" />
</Button.InputBindings>
</Button>
以上方法只适用于派生至ButtonBase的控件,并且不是所有事件都支持,支持的事件请参考MouseAction。对于其他控件则需要借助Blend提供的API,通过接口将ICommand对象映射到事件上。如下:
<!--如果需要向命令传入参数使用数据绑定机制-->
<ListBox x:Name="list" >
<i:Interaction.Triggers>
<i:EventTrigger EventName="MouseDoubleClick">
<i:InvokeCommandAction Command="{Binding ClickCmd}" CommandParameter="{Binding ElementName=list, Path=SelectedItem.Content}"/>
</i:EventTrigger>
</i:Interaction.Triggers>
<ListViewItem>1</ListViewItem>
<ListViewItem>2</ListViewItem>
<ListViewItem>3</ListViewItem>
</ListBox>
需要在xaml头定义
xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
和添加引用System.Windows.Interactivity.dll。需要说明的是使用此种方法也可以接收点击事件之外的事件,包括父控件事件,如上述例子可使用SelectionChanged事件。
<i:EventTrigger EventName="SelectionChanged">
...
</i:EventTrigger>
2.3. 数据验证和错误报告
只要与数据打交道,任何时候均涉及数据验证问题,有两种类型错误,一是代码错误,如将一个非数据的字符赋值给Int类型变量,二是业务规则错误,这种不符合相应规则,如个人银行存款不能为负,年龄不能小于18等等。数据验证涉及两个问题:
- 检测数据错误
- 准确,友好展示错误
可以在Model或者View Model中实现接口IDataErrorInfo或INotifyDataErrorInfo,这些接口帮助检测数据错误并返回错误消息给View。.NET Framework 4.5及以上支持该接口。
IDataErrorInfo
该接口拥有两个属性,indexer和Error属性,属性Indexer需要传入属性名作为参数,如果返回空字符串或者null值则表明验证通过,否则出现错误。属性Error允许为View Model或者Model提供错误信息。
注意一旦View Model或者Model实现接口IDataErrorInfo,那么属性Indexer会被自动调用,首次展示绑定属性或者属性更改时均会自动调用Indexer,并且Model或者View Model所有属性均被自动检测,所以编写一个高效的Indexer是非常有必要的。
如果需要使用该接口验证数据,实现接口IDataErrorInfo是不够的,还需要在view层开启验证功能,方法是将ValidatesOnDataErrors设定为True,如下:
<TextBox
Text="{Binding Path=CurrentEmployee.Name, Mode=TwoWay, ValidatesOnDataErrors=True,
NotifyOnValidationError=True }" />
INotifyDataErrorInfo
相比于接口IDataErrorInfo,接口INotifyDataErrorInfo更加灵活。它支持单属性多错误,异步数据验证,通知view验证状态改变。接口INotifyDataErrorInfo包含成员:
- 属性HasErrors,表明当前属性集是否有任何错误
- 方法GetErrors,获取任意一个属性的错误消息集,需要传入属性名
- 事件ErrorsChanged,支持异步数据验证
为了支持INotifyDataErrorInfo,你需要为每个属性保留一系列错误信息。
2.4. 构造和集成
使用框架和MVVM设计模式是为了提高产能,这就需要正确构造和集成各个模块。一般View与View Model是一对一关系,有三种方式建立二者联系。
方法一、XAML
我们可以在XAML文件声明关系,如下:
<UserControl.DataContext>
<my:MyViewModel/>
</UserControl.DataContext>
使用这种方法前提是View Model有默认构造函数
方法二、程序
可以在后台代码中建立联系:
public MyView()
{
InitializeComponent();
this.DataContext = new MyViewModel();
}
该方法可以进一步使用DI容器建立联系。
方法三、View Model Locator
Prism框架提供view model locator机制,可以实现自动建立联系。具体机制是提供类ViewModelLocator,当设定其属性AutoWireViewModel为True时View自动找寻需要的View Model。设置如下:
xmlns:prism="http://prismlibrary.com/"
prism:ViewModelLocator.AutoWireViewModel="True"
首先找寻通过类 ViewModelLocationProvider 的方法Register注册的View Model。如果没有找到,则按惯例查找,惯例约定ViewModel与View在同一个程序集,View在子命名空间.Views,ViewModel在子命名空间ViewModels,并且需要的View Model以ViewModel名字结尾,如MyBoxView对应MyBoxViewModel。同样系统支持更改连接约定,按非惯例连接等,如一个MainView可和CustormViewModel连接。
3. 关键决定
这些决定适用整个应用,一旦做出决定以后未来将很难再改动。长期坚持有助于提高开发者和设计的产能。
- 决定如何构造View或者View Model?可以直接构造view 或者View Model。或者使用依赖注入容器,如Unity,MEF。
- 决定你将以何种方式暴露View Model中的Command,方法还是对象?方法简单,通过行为方式暴露给view。Command对象封装了命令 ,可以通过行为方式暴露给View或者直接使用Command属性(存在于ButtonBase类型控件)
- 决定如何将View Model和Model的错误报告给View?实现IDataErrorInfo还是INotifyDataErrorInfo?
- 确定设计时数据支持对你的团队是否很重要?如果重要,那么View或者View Model需要提供无参构造函数,且没有依赖项。
问题
- 如何区分商业逻辑,呈现逻辑以及UI逻辑?
目前可以这样简单区分,所谓商业逻辑就是业务规则,没有这个应用程序也存在的。所谓呈现逻辑就是导航逻辑,哪个页面能被访问,哪个能被点击,导航到哪个界面,UI逻辑则是单个页面的展示,以什么样的样式组织界面,用户点击按钮后,变不变色,变大还是变小等等。