本文是翻譯大牛Josh Smith的文章,WPF Apps With The Model-View-ViewModel Design Pattern,譯者水平有限,如有什么問題請看原文,或者與譯者討論(非常樂意與你討論)。
本文討論的內容:
WPF與設計模式
MVP模式
對WPF來說為什么MVVM是更好的選擇
用MVVM構建WPF程序
本文涉及的技術:
WPF、 數據綁定
內容列表
有序與混亂
模型-視圖-視圖模型的演變
為什么WPF開發者喜歡MVVM
演示程序
中繼命令邏輯
ViewModel類層級結構
ViewModelBase類
CommandViewModel類
MainWindowViewModel類
View對應ViewModel
數據模型和Repository
新增客戶數據表單
所有客戶視圖
總結
開發UI,對一個專業軟件並不容易。它需要未知數據、交互式設計,可視化設計、聯通性,多線程、國際化、驗證、單元測試以及其他的一些東西才能完成。考慮到UI要展示開發的系統並且必須滿足用戶對系統風格不可預知的變更,因此它是很多應用程序最脆弱的地方。
有很多的設計模式可以幫助解決UI不斷變更這頭難纏的野獸,但是恰當的分離和描述多個關注點可能很困難。模式越復雜,之后用到的捷徑越可能破壞之前正確的努力。
這並不總是設計模式的錯。有時使用要寫很多的代碼復雜設計模式,這是因為我們使用的UI平台並不適合簡單是設計模式。UI平台需要做的是很容易使用簡單的,久經考驗的,開發者認識的設計模式構建UI。慶幸的是,WPF就是這樣一個平台。
隨着是使用WPF開發的比例不斷升高,WPF社區發展了自己的模式與實踐生態圈子。在本文,我將討論一些設計與實現客戶端應用程序的WPF最佳實踐。利用WPF和MVVM設計模式銜接的一些核心功能,我將通過一個例子介紹,用“正確”的方式構建一個WPF程序是多么的簡單。
data templates, commands, data binding, the resource system以及 MVVM 模式怎么揉合到一起創建一個簡單的、可測試的、健壯的框架,並且任何WPF程序都能使用,到文章最后,這一切都很清晰明了。文中的例程可以作為現實中一個WPF應用程序的模版,並且使用MVVM設計模式作為其核心架構。例程解決方案中的單元測試部分,展示了測試ViewModel類的功能是很容易的。在深入本文之前,我們首先看一下我們要使用像MVVM這樣的設計模式。
有序與混亂
沒有必要在一個”Hello,World!”的程序中使用設計模式。任何一個合格的開發者看一眼就指導那幾行代碼是干什么的。然而隨着程序功能點的增加,隨之代碼的數量以及移動部件也會增多。最終系統的復雜度以及不斷出現問題,促使開發者組織他們的代碼,以便它們更容易理解,討論、擴展以及維護。我們通過給代碼中某些實體命以眾所周知的名字,減少復雜系統認知誤區。我們給函數塊命名主要依據系統中的功能角色。
開發者有意識的根據設計模式組織他們的代碼,而不是根據設計模式自動去組織。無論哪一種,都沒有什么問題。但是在本文中,我說明在WPF程序中明確使用MVVM模式的好處。
某些類的名稱,包括MVVM模式中著名的術語,如果類是view的抽象類就以ViewModel結束。這種方式有助於避免之前提到的認知誤區。相反,你也可以讓那種受控的誤區存在,這正是大部分軟件開發項目的自熱狀態。
模型-視圖-視圖模型的演變
自從人們開始構建UI時,就有很多流行的設計模式讓UI構建更容易。比如,MVP模式在各種UI編程平台中都非常流行。MVP是MVC模式的一種變體,MVC模式已經流行了幾十年了。以防你之前從沒用過MVP模式,這里做一個簡單的解釋。你在屏幕上看到的是View,它顯示的數據是Model,Presenter就是把兩者聯系起來。View依賴Presenter並通過Presenter展示Model數據,響應用戶輸入,提供數據驗證(或許委托給Model去完成)以及其他的一些任務。如果你想了解更過關於MVP模式,我建議你去讀Jean-Paul Boodhoo的 August 2006 Design Patterns column。
2004年晚些時候,Martin Fowler發表了一篇叫Presentation Model(PM)的模式。PM模式和MVP類似,MVP是把一個View從行為和狀態分離出來。PM中令人關注的部分是創建view的抽象,叫做Presentation Model。之后,View就僅僅是Presentation Model的展示了。在Fowler的論文中,他展示了Presentation Model經常更新View,以便兩個彼此同步。同步邏輯組作為代碼存在於Presentation Model類中。
2005年,John Gossman,目前是微軟WPF和Silverlight架構師,在他的博客上披露了Model-View-ViewModel (MVVM)模式。MVVM和Fowler的Presentation Model是一致的,兩個模式的特征都是View的抽象,都包含了View的行為和狀態。Fowler引入Presentation Model是作為創建獨立平台的View的抽象,而Gossman引入MVVM是作為標准化的方法,利用WPF的核心特點去簡化UI的創建。從這種意義上來講,我把MVVM作為一般PM模式的一個特例。
在Glenn Block一遍優秀的文章"Prism: Patterns for Building Composite Applications with WPF",於2008年9月微軟大會發布,他解釋了WPF微軟組合程序開發向導。術語ViewModel沒有用到,然而PM卻用來描述View的抽象。這篇文章自始至終,都沒沒有出現我要將MVVM模式,以及View的抽象ViewModel。我發現這個術語在WPF和Silverlight社區中比較流行。
不像MVP中的Presenter,ViewModel不需要引用View。View 綁定ViewModel的屬性,ViewMode向Viewl暴露Model對象的數據以及其他的狀態。View和ViewModel之間的綁定很容易構造,因為ViewModel對象可以設置為View的DataContext。如果ViewModel中的屬性值發生改變,新值將通過綁定自動傳送給View。當用戶點擊View中的按鈕時,ViewMode對於的Command將執行請求的動作。ViewModel,絕不是View,去執行實體對象的修改。
View類並不知道Model類是否存在,同時ViewModel和Model也不知道View。實際上,,Model完全不知道ViewModel和View存在,這是一個非常松耦合的設計,在很多方面都有好處,這不就你就會看到。
為什么WPF開發者喜歡MVVM
一旦開發者適應了WPF和MVVM,就很難區別兩者。因為MVVM非常適合WPF平台,並且WPF被設計使用MVVM模式更容易構建應用程序,MVVM就成了WPF開發者的通用語。事實上,微軟內部正在用MVVM開發WPF應用程序,像Microsoft Expression Blend,然而當時WPF平台的核心功能依然在開發之中。WPF的很多方面,像控制模型以及數據模版,都利用了MVVM推薦的顯示狀態和行為分離技術。
MVVM之所以成為一個偉大設計模式,是因為WPF的一個最重要的特征數據綁定構造。通過把Viewde 屬性綁定到ViewModel,你就可以得到兩者松耦合的設計,並且完全去除ViewModel更新View的那部分代碼。數據綁定系統支持輸入驗證,並且輸入驗證提供了傳遞錯誤給View的標准方法。
另兩個WPF的特點,數據模版和資源系統讓MVVM模式更加可用。數據模版把View應用在ViewModel對象上,以便其能夠在UI上顯示。你可以在Xaml中聲明模版,讓資源系統在系統運行過程中自動定位並應用這些模版。你可以從我2008年7月寫的一篇文章, "Data and WPF: Customize Data Display with Data Binding and WPF.",獲取更多關於綁定和數據模版的信息。
要不是WPF對Command的支持,MVVM模式就不會那么強大。本文中,我會為你展示ViewModel怎樣把Commands暴露給View,並且讓View消費它的功能。如果你對Command不是很熟悉,我推薦你讀一下2008年9月Brian Noyes發布的文章, "Advanced WPF: Understanding Routed Events and Commands in WPF"。
除了WPF(Silverlight2)本身讓MVVM以一種自然的方式去構建程序之外,造成MVVM模式流行還有一個原因,那就是ViewModel類很容易進行單元測試。從某種意義來講,View和單元測試只是ViewModel兩個不同類型的消費者。擁有一套應用程序的單元測試,可以為提供更自由、快速的回歸測試,而回歸測試有助於降低之后應用的維護成本。
除了促進創建自動化回歸測試外,ViewModel類的可測試性也有助於設計更容易分離的UI。當你設計應用時,你可以通過想象某些東西是否要創建單元測試消費ViewModel,來確定它們是放到View里面還是ViewModel里面。如果你可以為ViewModel寫單元測試而不用創建任何UI控件,你也可以把ViewModel剝離出來,因為它不依賴任何具體可視化的組件。
最后,對於要和設計者合作的開發者來說,使用MVVM模式使得創建平滑的開發/設計工作流更加容易。既然View可以是ViewModel的任意一個消費者,就很容易去掉一個View通過新增一個View去渲染ViewModel。這個簡單的步驟允許設計師構建快速原型以及評估UI設計。
這樣開發團隊可以關注創建健壯的ViewModel類,而設計團隊可以關注設計界面友好的View。要融合兩個團隊輸出只需要在View的xaml上進行正確的綁定即可。
演示程序
到此為止,我們回顧了MVVM的歷史以及具體操作理論。我也說明了它在WPF開發者中間如此流行的原因。現在是時候繼續我們的步伐,看一下MVVM模式在實際中的應用。這篇文章中的演示程序以各種方式使用MVVM設計模式,它提供了豐富的例子,幫助在上下文中理解MVVM的概念。我用VS2008 SP1創建的這個演示程序, 框架是Microsoft .NET Framework 3.5 SP1。單元測試是用的Visual Studio unit testing。
應用可以包含任意數量的“Workspace”,每一個都可以由用戶點擊左側導航區的命令鏈接打開。所有的Workspace寄宿在主區域TabControl中,用戶可以通過點擊workspace的 tab item上關閉按鈕關閉workspace。應用程序有兩個可用的workspace:"All Customers" 和 "New Customer"。運行程序,打開一些workspace,UI看起來如圖1所示。
圖1 Workspaces
一次只有一個“All Customers“ Workspace的實例可以打開,但是可以打開多個New Customer Workspace。當用戶決定創建一個新的客戶時,她必須填完圖2所示的數據輸入表單。
圖2 新客戶數據輸入表單
填完數據輸入表單的所有有效值點擊“Save”按鈕,新客戶的名稱將會出現在tab item 上面,同時新客戶也會增加到客戶列表中。應用程序不支持刪除或者編輯客戶,但是這和其它功能類似,很容易在已有的程序架構上去實現。現在你已經對演示程序有了更深層次的理解了,接下來我們研究它是如何設計以及實現的。
中繼命令邏輯(Relaying Command Logic)
除了類構造器里調用初始化組件標准的樣板代碼,應用中的每一View的codebehind文件都是空的。實際上你可以移除View的codebehind文件,程序讓人能夠爭正確的編譯和運行。盡管View中沒有事件處理方法,但是當用戶點擊按鈕時,程序依然能夠響應並滿足用戶的請求。之所以這樣,是因為UI上Hyperlink、 Button以及MenuItem控件的Command屬性被綁定了。綁定機制確保當用戶在控件上點擊時,由ViewModel暴露的ICommand對象能夠執行。你可以把command對象看作一個適配器,這個適配器讓command對象很容易消費在View中聲明的ViewModel功能。
當ViewModel暴露ICommad類型的實例屬性,被暴露的Command對象使用ViewModel中的對象去完成它的工作。其中一個可能的實現模式是在ViewModel內創建一個私有嵌套類,以便command能夠訪問包含在ViewModel中的私有成員,而不至於污染命名空間。嵌套類實現了ICommand接口,包含在ViewModel中對象的引用注入到其構造器中。但是為ViewModel暴露的每個Command創建實現ICommad的嵌套類,會增加ViewModel類的大小。更多的代碼意味着存在BUGS潛力更大。
在演示程序中,RelayCommand類解決了這個問題。RelayCommand允許通過把委托傳給其構造器,以實現對命令邏輯的注入。這種方式允許在ViewMode類中可以簡單明了的實現Command。
RelayCommand是DelegateCommand的一個簡單的變體,DelegateCommand可以在Microsoft Composite Application Library找到。RelayCommand類代碼如圖3所示。
圖3 RelayCommand類
public class RelayCommand : ICommand
{
#region Fields
readonly Action<object> _execute;
readonly Predicate<object> _canExecute;
#endregion // Fields
#region Constructors
public RelayCommand(Action<object> execute)
: this(execute, null)
{
}
public RelayCommand(Action<object> execute, Predicate<object> canExecute)
{
if (execute == null)
throw new ArgumentNullException("execute");
_execute = execute;
_canExecute = canExecute;
}
#endregion // Constructors
#region ICommand Members
[DebuggerStepThrough]
public bool CanExecute(object parameter)
{
return _canExecute == null ? true : _canExecute(parameter);
}
public event EventHandler CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
public void Execute(object parameter)
{
_execute(parameter);
}
#endregion // ICommand Members
}
作為接口ICommad實現一部分,事件CanExecuteChanged有一些值得關注的特征。它委托訂閱CommandManager. RequerySuggested事件。這樣以確保無論何時調用內置命令時,WPF命令架構都能調用所有能夠執行的RelayCommand對象。
RelayCommand _saveCommand;
public ICommand SaveCommand
{
get
{
if (_saveCommand == null)
{
_saveCommand = new RelayCommand(param => this.Save(),
param => this.CanSave );
}
return _saveCommand;
}
}
ViewModel類層級圖
大部分ViewModel類有共同的特征,他們要實現INotifyPropertyChanged接口,需要顯示一個友好的名字,以之前說道Workspace為例,它需要能夠關閉(即從UI上移除)。要解決這個問題,自然就需要創建一個或二個ViewModel基類,以便新的ViewModel類能夠從基類集成通用的功能。所有的ViewModel類形成如圖4的層級圖。
圖4 繼承層級圖
為你的ViewModel創建一個基類並不是必須。如果你喜歡在類中通過組合幾個小一點的類以獲得那些功能,而不是用繼承的方式,這並沒有什么問題。就像任何其他的設計模式一樣,MVVM是一套指導方針,而不是規則。
ViewModelBase 類
ViewModelBase 是層級中的根類,這就是它要實現通用INotifyPropertyChanged接口以及有一個DisplayName屬性的原因。INotifyPropertyChanged接口包含一個叫PropertyChanged的事件。無論何時ViewModel對象的屬性的發生改變時,它都會觸發PropertyChanged事件,把新值通知給WPF綁定系統。根據通知,綁定系統檢索屬性,UI組件上綁定的屬性將接受新值。
為了讓WPF知道是那一個屬性發生了改變,PropertyChangedEventArgs類暴露了一個string類型的屬性PropertyName 。你一定要為事件參數傳遞正確的屬性名,否則WPF將會為新值檢索出一個錯誤的屬性。
ViewModelBase一個值得關注的地方就是它為給定的屬性名提供了驗證,驗證屬性是否存在ViewModel對象上。重構時,這非常有用。因為通過VS 2008重構功能去改變屬性名,不會更新源代碼中字符串,而這些字符串正好包含屬性名(其實不應該包含)。在事件參數中傳遞不正確的屬性名,觸發PropertyChanged事件時,可能會導致微小的BUGs,並且這些BUGs很難追蹤,因此這個細微的特征將會節省大量的時間。ViewModelBase中增加了這個有用的特征,其代碼如下:
圖5 屬性驗證
// In ViewModelBase.cs
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged(string propertyName)
{
this.VerifyPropertyName(propertyName);
PropertyChangedEventHandler handler = this.PropertyChanged;
if (handler != null)
{
var e = new PropertyChangedEventArgs(propertyName);
handler(this, e);
}
}
[Conditional("DEBUG")]
[DebuggerStepThrough]
public void VerifyPropertyName(string propertyName)
{
// Verify that the property name matches a real,
// public, instance property on this object.
if (TypeDescriptor.GetProperties(this)[propertyName] == null)
{
string msg = "Invalid property name: " + propertyName;
if (this.ThrowOnInvalidPropertyName)
throw new Exception(msg);
else
Debug.Fail(msg);
}
}
CommandViewModel 類
CommandViewModel是最簡單的ViewModelBase子類,它暴露了一個類型為ICommad的Command屬性。MainWindowViewModel通過Commands屬性暴露了CommandViewModel對象的一個集合。主窗口左手側的導航區域,顯示了MainWindowViewModel暴露每個CommandViewModel對象鏈接,像“View all customers”和“Create new customer”。當用戶點擊鏈接,將會執行相應的Command,在主窗口的TabControl中打開一個workspace。CommandViewModel類的定義如下所示:
public class CommandViewModel : ViewModelBase
{
public CommandViewModel(string displayName, ICommand command)
{
if (command == null)
throw new ArgumentNullException("command");
base.DisplayName = displayName;
this.Command = command;
}
public ICommand Command { get; private set; }
}
在MainWindowResources.xaml文件中存在一個key為CommandsTemplate的數據模版,主窗口(MainWindow)使用這個模版渲染之前提到的CommandViewModel對象集合。這個模版是簡單在ItemsControl里把每個CommandViewModel對象渲染成一個鏈接,每個鏈接的Command屬性綁定到CommandViewModel對象的Command屬性。數據模版Xaml如圖6所示:
圖6 渲染Command列表
<!-- In MainWindowResources.xaml -->
<!--
This template explains how to render the list of commands on
the left side in the main window (the 'Control Panel' area).
-->
<DataTemplate x:Key="CommandsTemplate">
<ItemsControl ItemsSource="{Binding Path=Commands}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Margin="2,6">
<Hyperlink Command="{Binding Path=Command}">
<TextBlock Text="{Binding Path=DisplayName}" />
</Hyperlink>
</TextBlock>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</DataTemplate>
MainWindowViewModel 類
如前面看到的類圖一樣,WorkspaceViewModel類繼承於ViewModelBase並增加了“關閉”的能力。這個“關閉”,我的意思是在運行的時候能把workspace從UI上移除。有三個類繼承於WorkspaceViewModel,他們分別為MainWindowViewModel,AllCustomersViewModel和CustomerViewModel。MainWindowViewModel的關閉請求是由App類處理的,其中App類創建了MainWindow以及它對應的ViewModel對象。創建代碼如圖7所示.
圖7 創建ViewModel
// In App.xaml.cs
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
MainWindow window = new MainWindow();
// Create the ViewModel to which
// the main window binds.
string path = "Data/customers.xml";
var viewModel = new MainWindowViewModel(path);
// When the ViewModel asks to be closed,
// close the window.
viewModel.RequestClose += delegate
{
window.Close();
};
// Allow all controls in the window to
// bind to the ViewModel by setting the
// DataContext, which propagates down
// the element tree.
window.DataContext = viewModel;
window.Show();
}
MainWindow包含一個菜單項,該菜單項的Command屬性綁定到MainWindowViewModel上的CloseCommand屬性上。當用戶點擊該菜單,App類響應請求,調用窗體的關閉方法。菜單Xaml如下所示:
<!-- In MainWindow.xaml -->
<Menu>
<MenuItem Header="_File">
<MenuItem Header="_Exit" Command="{Binding Path=CloseCommand}" />
</MenuItem>
<MenuItem Header="_Edit" />
<MenuItem Header="_Options" />
<MenuItem Header="_Help" />
</Menu>
MainWindowViewModel包含了WorkspaceViewModel對象一個observable類型的集合,該集合的名稱為Workspaces。主窗體包含了一個TabControl,其ItemsSource綁定到上述的集合。每一個tab item都有一個關閉按鈕,其Command屬性綁定到它對應WorkspaceViewModel實例的CloseCommand上。模版展示了如何渲染一個帶關閉按鈕的tab item。配置tab item模版的簡化版會展示在下面代碼中,這段代碼可以在MainWindowResources.xaml文件中找到。
<DataTemplate x:Key="ClosableTabItemTemplate">
<DockPanel Width="120">
<Button
Command="{Binding Path=CloseCommand}"
Content="X"
DockPanel.Dock="Right"
Width="16" Height="16"
/>
<ContentPresenter Content="{Binding Path=DisplayName}" />
</DockPanel>
</DataTemplate>
當用戶點擊tab item上的關閉按鈕時,會執行WorkspaceViewModel的CloseCommand,觸發它的RequestClose事件。MainWindowViewModel會監控workspace的RequestClose事件,根據請求從Workspaces集合中移除相應的workspace。因為MainWindow的TabControl的ItemsSource綁定到WorkspaceViewModel的observable集合,從集合中移除對象,會引起從TabControl中移除相應的workspace。MainWindowViewModel相應的邏輯如圖8所示
圖8 從UI上移除workspace
// In MainWindowViewModel.cs
ObservableCollection<WorkspaceViewModel> _workspaces;
public ObservableCollection<WorkspaceViewModel> Workspaces
{
get
{
if (_workspaces == null)
{
_workspaces = new ObservableCollection<WorkspaceViewModel>();
_workspaces.CollectionChanged += this.OnWorkspacesChanged;
}
return _workspaces;
}
}
void OnWorkspacesChanged(object sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null && e.NewItems.Count != 0)
foreach (WorkspaceViewModel workspace in e.NewItems)
workspace.RequestClose += this.OnWorkspaceRequestClose;
if (e.OldItems != null && e.OldItems.Count != 0)
foreach (WorkspaceViewModel workspace in e.OldItems)
workspace.RequestClose -= this.OnWorkspaceRequestClose;
}
void OnWorkspaceRequestClose(object sender, EventArgs e)
{
this.Workspaces.Remove(sender as WorkspaceViewModel);
}
在UnitTests項目中,MainWindowViewModelTests.cs文件包含了一個測試方法,該方法驗證上述功能是否正確執行。很容易為ViewModel類創建單元測試是MVVM模式的一個大賣點,因為它只需測試應用程序的功能,而不用寫和UI交互的代碼。上述測試方法圖9所示
圖9 測試方法
// In MainWindowViewModelTests.cs
[TestMethod]
public void TestCloseAllCustomersWorkspace()
{
// Create the MainWindowViewModel, but not the MainWindow.
MainWindowViewModel target =
new MainWindowViewModel(Constants.CUSTOMER_DATA_FILE);
Assert.AreEqual(0, target.Workspaces.Count, "Workspaces isn't empty.");
// Find the command that opens the "All Customers" workspace.
CommandViewModel commandVM =
target.Commands.First(cvm => cvm.DisplayName == "View all customers");
// Open the "All Customers" workspace.
commandVM.Command.Execute(null);
Assert.AreEqual(1, target.Workspaces.Count, "Did not create viewmodel.");
// Ensure the correct type of workspace was created.
var allCustomersVM = target.Workspaces[0] as AllCustomersViewModel;
Assert.IsNotNull(allCustomersVM, "Wrong viewmodel type created.");
// Tell the "All Customers" workspace to close.
allCustomersVM.CloseCommand.Execute(null);
Assert.AreEqual(0, target.Workspaces.Count, "Did not close viewmodel.");
}
把View應用到ViewModel上
MainWindowViewModel間接從主窗體的TabControl控件中增加移除WorkspaceViewModel對象。通過數據綁定,TabItem的Content屬性顯示繼承於ViewModelBase的對象。ViewModelBase並不是一個UI元件,因此他並不支持渲染它自己。默認在TextBlock中,WPF的一個非可視化對象通過調用ToString方法以顯示該對象。很明顯這不是你想要的,除非你的用戶迫切的想知道ViewModel的類型名。
我們通過強類型數據模版很容易告訴WPF如何渲染ViewModel對象。強類型數據模版key屬性名沒有賦值,但是其DataType屬性要賦以類型類的實例。如果WPF要去渲染ViewModel對象,它會檢查在資源系統范圍內是否有一個強類型數據模版的DataType和ViewModel對象(或者其基類)的類型一樣。如果找到一個這樣的模版的話,他會用該模版去渲染被TabItem Content屬性綁定的ViewModel對象。
MainWindowResources.xaml文件中有一個ResourceDictionary(資源字典),該字典被增加到主窗體的資源層級中,這意味着文件包含的資源在正窗體范圍內有效。當一個TabItem的Content屬性設置ViewModel對象時,該字典中的強類型數據模版會提供一個View(即用戶自定義控件)去渲染TabItem Content。具體如圖10所示
圖10 提供View
<!--
This resource dictionary is used by the MainWindow.
-->
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:DemoApp.ViewModel"
xmlns:vw="clr-namespace:DemoApp.View"
>
<!--
This template applies an AllCustomersView to an instance
of the AllCustomersViewModel class shown in the main window.
-->
<DataTemplate DataType="{x:Type vm:AllCustomersViewModel}">
<vw:AllCustomersView />
</DataTemplate>
<!--
This template applies a CustomerView to an instance
of the CustomerViewModel class shown in the main window.
-->
<DataTemplate DataType="{x:Type vm:CustomerViewModel}">
<vw:CustomerView />
</DataTemplate>
<!-- Other resources omitted for clarity... -->
</ResourceDictionary>
你不需要寫任何代碼去決定哪一個View去展示ViewModel對象。WPF資源系統把你從繁重的工作解脫出來,讓你去關注更重要的事情。在復雜的場景中,可能需要通過編程去選擇View,但是在大部分情況下,通過編程選擇View是不必要的。
The Data Model and Repository 數據模型和存儲庫
你已經知道應用程序如何去加載,顯示以及關閉一個ViewModel對象。現在一切已經就位,你可以在整個應用程序范圍內,回顧一下具體實現的細節。在深入理解應用程序的兩個workspace,“All Customers” 和 “New Customer”之前,我們先審視一下數據模型和數據存取類。這些類的設計和MVVM模式並沒有什么關系,因為你可以創建一個ViewModel類,以適應任何對WPF友好的數據對象。
演示程序中唯一的模型類Customer類,該類有些屬性表征一個公司的客戶,像他們的姓名,email等。它通過實現IDataErrorInfo接口提供屬性驗證信息,該接口在WPF大行其道之前已存在多年。Customer類里面並沒有什么,並不是建議其用於MVVM架構,或者甚至應用和WPF應用程序。這個類很容易從遺留的業務庫中獲取。
數據必須存到某個地方,在這個應用程序中,CustomerRepository類的實例加載並存儲所有的Customer對象。該CustomerRepository從xml文件加載所有的客戶數據,這與外部的數據源無關。數據可能來自數據庫、Web服務、命名管道、磁盤上的文件甚至信鴿,這並沒有什么關系。只要你有一個有數據.net對象,不管它來自何方,MVVM模式都能在屏幕上獲取其包含數據。
CustomerRepository類暴露了一些方法,這些方法允許你獲取所有的Customer對象,增加一個Customer對象到存儲室並檢查其子啊存儲室是否存在。既然應用程序不允許刪除客戶,存儲室也不允許你去刪除客戶。當一個新Customer通過AddCustomer方法增加到CustomerRepository時,會觸發CustomerAdded事件。
很明顯,和真實業務程序所需的相比,該程序的數據模型是輕量級的,但是這並沒有關系。重要的是,要理解ViewModel類如何利用Customer和CustomerRepository類。知道CustomerViewModel是對Customer對象的封裝,其通過一系列的屬性暴露了Customer的狀態,以及被CustomerView使用的狀態。CustomerViewModel並不是復制Customer對象的狀態,而是通過委托暴露這狀態,具體如下:
public string FirstName
{
get { return _customer.FirstName; }
set
{
if (value == _customer.FirstName)
return;
_customer.FirstName = value;
base.OnPropertyChanged("FirstName");
}
}
當用戶在CustomerView控件中創建新客戶點擊保存按鈕時,與該視圖關聯的CustomerViewModel會增加一個Customer對象到CustomerRepository 。這會觸發存儲庫的CustomerAdded事件,該事件讓AllCustomersViewModel知道他應該增加一個CustomerViewModel對象到AllCustomers集合。從某種意義說,CustomerRepository在各自ViewModel和他們要處理的Customer對象間扮演數據同步的角色,或許有人會把這當成中介者模式。我會在接下來的內容中介紹其實現機理,但是為了更進一步了解這些類如何連接在一起,我們現在先看一下圖11所示的類圖
圖11 Customer關系圖
New Customer Data Entry Form新客戶數據輸入表單
當用戶點擊“Create new customer”鏈接,MainWindowViewModel會增加一個CustomerViewModel到workspaces集合,相應的CustomerView回去顯示。用戶在輸入框輸入有效值之后,Save按鈕變為可用狀態,以便用戶能存儲增加客戶信息。這並沒有超出常規的地方,只是一個帶有驗證信息和Save按鈕的常規輸入表單而已。
Customer類內置輸入驗證支持,這是通過實現IDataErrorInfo接口獲得。輸入驗證確保客戶有一個名字,合法的email地址,如果客戶是個人客戶,還需要姓氏。如果Customer對象的IsCompany屬性為真,則其LastName屬性不能有值。該驗證邏輯從Customer對象的角度看是有意義的,但是它並不能滿足UI的需要,UI要求用戶選擇客戶類別是個人還是公司。Customer類別選擇器初始值是: (Not Specified),如果Customer對象的IsCompany屬性只允許是true和false,客戶類別是unspecified時,UI如何告訴用戶?
假定你對整個軟件系統擁有控制權限,你可以把IsCompany屬性類型改變為Nullable<bool>,該類型允許“未選擇”值(即空值-譯者注)。然而在現實世界中,不是這么簡單。假設你不能改變Customer類,因為它來自公司其他團隊所開發系統。要是因為數據庫的原因,沒有簡單方法存儲未選擇的值,怎么辦?要是其它程序已經使用Customer類,並且其依賴正常Boolean類型的IsCompany屬性,怎么辦?諸如此類,可以使用ViewModel去解決。
圖12所示的測試方法展示了該功能如何在CustomerViewModel中工作,CustomerViewModel暴露了一個CustomerTypeOptions屬性,以便UI上的客戶類型選擇器有三個字符串顯示。同時它也暴露了一個CustomerType屬性,該屬性存放選擇器選中的字符串。當CustomerType被賦值時,它會潛在的Customer對象IsCompany屬性,把字符類型轉化為Boolean類型。圖13展示了這兩個屬性。
圖12 測試方法
// In CustomerViewModelTests.cs
[TestMethod]
public void TestCustomerType()
{
Customer cust = Customer.CreateNewCustomer();
CustomerRepository repos = new CustomerRepository(
Constants.CUSTOMER_DATA_FILE);
CustomerViewModel target = new CustomerViewModel(cust, repos);
target.CustomerType = "Company"
Assert.IsTrue(cust.IsCompany, "Should be a company");
target.CustomerType = "Person";
Assert.IsFalse(cust.IsCompany, "Should be a person");
target.CustomerType = "(Not Specified)";
string error = (target as IDataErrorInfo)["CustomerType"];
Assert.IsFalse(String.IsNullOrEmpty(error), "Error message should
be returned");
}
圖13 CustomerTypeOptions和CustomerType
// In CustomerViewModel.cs
public string[] CustomerTypeOptions
{
get
{
if (_customerTypeOptions == null)
{
_customerTypeOptions = new string[]
{
"(Not Specified)",
"Person",
"Company"
};
}
return _customerTypeOptions;
}
}
public string CustomerType
{
get { return _customerType; }
set
{
if (value == _customerType ||
String.IsNullOrEmpty(value))
return;
_customerType = value;
if (_customerType == "Company")
{
_customer.IsCompany = true;
}
else if (_customerType == "Person")
{
_customer.IsCompany = false;
}
base.OnPropertyChanged("CustomerType");
base.OnPropertyChanged("LastName");
}
}
CustomerView用戶控件中有一個ComboBox綁定這兩個屬性,如下所示:
<ComboBox
ItemsSource="{Binding CustomerTypeOptions}"
SelectedItem="{Binding CustomerType, ValidatesOnDataErrors=True}"
/>
當ComboBox中的選擇項發生改變時,其數據源的將會掃描IDataErroInfo接口查看新值是否有效。之所以這樣,是因為綁定的SelectedItem屬性有一個ValidateOnDataErrors設置為true。既然數據源是一個CustomerViewModel對象,綁定系統會會向CustomerViewModel對象要求一個對CustomerType屬性的驗證信息。大多情況下,CustomerViewModel會把所有的驗證請求委托給其包含的Customer對象。然而,因為Customer的IsCompany屬性沒有未選中狀態的概念,所以CustomerViewModel必須對ComboBox中新選擇項進行處理。具體代碼如圖14所示。
圖14 驗證CustomerViewModel對象
// In CustomerViewModel.cs
string IDataErrorInfo.this[string propertyName]
{
get
{
string error = null;
if (propertyName == "CustomerType")
{
// The IsCompany property of the Customer class
// is Boolean, so it has no concept of being in
// an "unselected" state. The CustomerViewModel
// class handles this mapping and validation.
error = this.ValidateCustomerType();
}
else
{
error = (_customer as IDataErrorInfo)[propertyName];
}
// Dirty the commands registered with CommandManager,
// such as our Save command, so that they are queried
// to see if they can execute now.
CommandManager.InvalidateRequerySuggested();
return error;
}
}
string ValidateCustomerType()
{
if (this.CustomerType == "Company" ||
this.CustomerType == "Person")
return null;
return "Customer type must be selected";
}
該部分代碼重點在於CustomerViewModel實現了IDataErrorsInfo接口,可以處理對CustomerViewModel具體屬性的驗證請求,同時把其它請求委托給Customer對象處理。這樣允許我們使用Model類的驗證邏輯,其它屬性驗證在ViewModel類中才有意義。
通過SaveCommand屬性去保存CustomerViewModel對象,該命令使用了之前陳述的RelayCommand,允許CustomerViewModel決定其是否能保存自己以及被告知保存其狀態時做什么。在該程序中,保存一個新客戶只是把其增加到CustomerRepository。決定一個新客戶是否能夠保存,需要兩方面的許可,一是Customer對象是否有效,二是CustomerViewModel必須是有效的。這兩方面是必要條件,由於前面陳述的ViewModel其特定屬性以及Customer對象驗證信息。CustomerViewModel的保存邏輯如圖15所示
圖15 CustomerViewModel的保存邏輯
// In CustomerViewModel.cs
public ICommand SaveCommand
{
get
{
if (_saveCommand == null)
{
_saveCommand = new RelayCommand(
param => this.Save(),
param => this.CanSave
);
}
return _saveCommand;
}
}
public void Save()
{
if (!_customer.IsValid)
throw new InvalidOperationException("...");
if (this.IsNewCustomer)
_customerRepository.AddCustomer(_customer);
base.OnPropertyChanged("DisplayName");
}
bool IsNewCustomer
{
get
{
return !_customerRepository.ContainsCustomer(_customer);
}
}
bool CanSave
{
get
{
return
String.IsNullOrEmpty(this.ValidateCustomerType()) &&
_customer.IsValid;
}
}
這里ViewModel的使用使得創建顯示Customer對象的View更加容易,並且允許像Boolean類型未選中這樣事情存在。同時它很容易告訴客戶保存其狀態。如果View直接綁定到Customer對象,View將會需要很多代碼才能恰當的工作。在一個設計良好的MVVM架構中,大部分View的背后代碼應該為空,或者最多只包含操縱View內的控件以及資源的代碼。有時在View后面寫一些代碼也是必須的,因為要和ViewModel對象進行交互,像傳遞事件或者調用方法否則從ViewModel做些事情很難。
All Customers View
演示程序也包含了一個在ListView中顯示所有客戶列表的workspace。這些客戶通過根據其是個人客戶還是公司客戶進行分組。用戶一次可以選擇一個或者多個客戶,在右下方查看其總銷售額。
該UI是AllCustomersView控件,用以渲染AllCustomersViewModel對象。每個ListViewItem代表一個CustomerViewModel對象,該對象存在於AllCustomerViewModel對象暴露的AllCustomers集合中。在前一部分,你看到CustomerViewModel如何渲染成數據輸入表單,而現在一模一樣的CustomerViewModel對象卻被渲染成ListView中的一個Item。CustomerViewModel並不知道那一個可視化的組件去顯示它,這使得其重用成為可能。
AllCustomersView創建了在ListView中看到的分組,這是通過把ListView的ItemsSource綁定到配置如圖16所示的CollectionViewSource中實現的。
圖16 CollectionViewSource
<!-- In AllCustomersView.xaml -->
<CollectionViewSource
x:Key="CustomerGroups"
Source="{Binding Path=AllCustomers}"
>
<CollectionViewSource.GroupDescriptions>
<PropertyGroupDescription PropertyName="IsCompany" />
</CollectionViewSource.GroupDescriptions>
<CollectionViewSource.SortDescriptions>
<!--
Sort descending by IsCompany so that the ' True' values appear first,
which means that companies will always be listed before people.
-->
<scm:SortDescription PropertyName="IsCompany" Direction="Descending" />
<scm:SortDescription PropertyName="DisplayName" Direction="Ascending" />
</CollectionViewSource.SortDescriptions>
</CollectionViewSource>
ListViewItem和CustomerViewModel之間的關聯是通過ListView的ItemContainerStyle屬性建立的。指定給該屬性的Style應用於每個ListViewItem,這使得ListViewItem的屬性可以綁定到CustomerViewModel對象的屬性上。這個Style一個重要的綁定就是在ListViewItem的IsSelected屬性和CustomerViewModel的IsSelected屬性之間建立的聯系,如下所示:
<Style x:Key="CustomerItemStyle" TargetType="{x:Type ListViewItem}">
<!-- Stretch the content of each cell so that we can
right-align text in the Total Sales column. -->
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<!--
Bind the IsSelected property of a ListViewItem to the
IsSelected property of a CustomerViewModel object.
-->
<Setter Property="IsSelected" Value="{Binding Path=IsSelected,
Mode=TwoWay}" />
</Style>
當CustomerViewModel對象被選中還是未選中,會引起所有選中客戶銷售總額發生改變。AllCustomersViewModel負責維護總銷售額,以便ListView下部的ContentPresenter顯示正確的數字。圖17顯示AllCustomersViewModel如何監控被選中或未選中的每個客戶,並通知View更新要顯示的值。
圖17 監控選中或未選中的客戶
// In AllCustomersViewModel.cs public double TotalSelectedSales { get { return this.AllCustomers.Sum( custVM => custVM.IsSelected ? custVM.TotalSales : 0.0); } } void OnCustomerViewModelPropertyChanged(object sender, PropertyChangedEventArgs e) { string IsSelected = "IsSelected"; // Make sure that the property name we're // referencing is valid. This is a debugging // technique, and does not execute in a Release build. (sender as CustomerViewModel).VerifyPropertyName(IsSelected); // When a customer is selected or unselected, we must let the // world know that the TotalSelectedSales property has changed, // so that it will be queried again for a new value. if (e.PropertyName == IsSelected) this.OnPropertyChanged("TotalSelectedSales"); }
UI綁定了TotalSelectedSales屬性,並把該值置為貨幣格式。ViewModel對象,而不是View,通過返回TotalSelectedSales屬性Double類型值的字符串形式,設置其貨幣格式。.NET Framework 3.5 SP1 為ContentPresenter增加了ContentStringFormat屬性,如果你使用更老版本的WPF,你需要在代碼中設置貨幣格式。
<!-- In AllCustomersView.xaml -->
<StackPanel Orientation="Horizontal">
<TextBlock Text="Total selected sales: " />
<ContentPresenter
Content="{Binding Path=TotalSelectedSales}"
ContentStringFormat="c"
/>
</StackPanel>
Wrapping Up 總結
WPF為應用程序開發者提供了很多,學習利用WPF賦予的力量,但需要轉變思維模式。MVVM模式是設計和開發WPF程序的一種簡單而又有效的一套指導方針。它允許你創建數據、行為和展示強分離的程序,這更容易控制軟件開發中的混亂因素。