1. Prism.Wpf 和 Prism.Unity
這篇是 Prism 8.0 入門的第二篇文章,上一篇介紹了 Prism.Core,這篇文章主要介紹 Prism.Wpf 和 Prism.Unity。
以前做 WPF 和 Silverlight/Xamarin 項目的時候,我有時會把 ViewModel 和 View 放在不同的項目,ViewModel 使用 可移植類庫項目,這樣 ViewModel 就與 UI 平台無關,實現了代碼復用。這樣做還可以強制 View 和 ViewModel 解耦。
現在,即使在只寫 WPF 項目的情況下,但為了強制 ViewModel 和 View 假裝是陌生人,做到不留后路,我也傾向於把 View 和 ViewModel 放到不同項目,並且 ViewModel 使用 .Net Standard 作為目標框架。我還會假裝下個月 UWP 就要崛起了,我手頭的 WPF 項目中的 ViewModel 要做到平台無關,方便我下個月把項目移植到 UWP 項目中。
但如果要使用 Prism 構建 MVVM 程序的話,上面這些根本不現實。首先,Prism 做不到平台無關,它針對不同的平台提供了不同的包,分別是:
- 針對 WPF 的 Prism.Wpf
- 針對 Xamarin Forms 的 Prism.Forms
- 針對 Uno 平台的 Prism.Uno
其次,根本就沒有針對 UWP 的 Prism.Windows(UWP 還有未來,忍住別哭)。
所以,除非只使用 Prism.Core,否則要將 ViewModel 項目共享給多個平台有點困難,畢竟用在 WPF 項目的 Prism.Wpf 本身就是個 Wpf 類庫。
現在“編寫平台無關的 ViewModel 項目”這個話題就與 Prism 無關了,再把 Prism.Unity 和 Prism.Wpf 選為代表(畢竟這個組合比其它組合下載量多些),這篇文章就只用它們作為 Prism 入門的學習對象。
Prism.Core、Prism.Wpf 和 Prism.Unity 的依賴關系如上所示。其中 Prism.Core 實現了 MVVM 的核心功能,它是一個與平台無關的項目。Prism.Wpf 里包含了 Dialog Service、Region、Module 和導航等幾個模塊,都是些用在 WPF 的功能。Prism.Unity 本身沒幾行代碼,它表示為 Prism.Wpf 選擇了 UnityContainer 作為 IOC 容器。(另外還有 Prism.DryIoc 可以選擇,但從下載量看 Prism.Unity 是主流。)
就算只學習 Prism.Wpf,可它的模塊很多,一篇文章實在塞不下。我選擇了 Dialog Service 作為代表,因為它的實現思想和其它的差不多,而且彈窗還是 WPF 最常見的操作。這篇文章將通過以下內容講解如何使用 Prism.Wpf 構建一個 WPF 程序:
- PrismApplication
- RegisterTypes
- XAML ContainerProvider
- ViewModelLocator
- Dialog Service
Prism 的最新版本是 8.0.0.1909。由於 Prism.Unity 依賴 Prism.Wpf,所以只需安裝 Prism.Unity:
Install-Package Prism.Unity -Version 8.0.0.1909
2. PrismApplication
安裝好 Prism.Wpf 和 Prism.Unity 后,下一步要做的是將 App.xaml 的類型替換為 PrismApplication
。
<prism:PrismApplication x:Class="PrismTest.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:prism="http://prismlibrary.com/">
<Application.Resources>
</Application.Resources>
</prism:PrismApplication>
上面是修改過的 App.xaml,將 Application
改為 prism:PrismApplication
,並且移除了 StartupUri="MainWindow.xaml"
。
接下來不要忘記修改 App.xaml.cs:
public partial class App : PrismApplication
{
public App()
{
}
protected override Window CreateShell()
=> Container.Resolve<ShellWindow>();
}
PrismApplication 不使用 StartupUri
,而是使用 CreateShell
方法創建主窗口。CreateShell
是必須實現的抽象函數。PrismApplication
提供了 Container
屬性,CreateShell
函數里通常使用 Container
創建主窗口。
3. RegisterTypes
其實在使用 CreateShell
函數前,首先必須實現另一個抽象函數 RegisterTypes
。由於 Prism.Wpf
相當依賴於 IOC,所以要現在 PrismApplication
里注冊必須的類型或依賴。PrismApplication
里已經預先注冊了 DialogService
、EventAggregator
、RegionManager
等必須的類型(在 RegisterRequiredTypes
函數里),其它類型可以在 RegisterTypes
里注冊。它看起來像這樣:
protected override void RegisterTypes(IContainerRegistry containerRegistry)
{
// Core Services
// App Services
// Views
containerRegistry.RegisterForNavigation<BlankPage, BlankViewModel>(PageKeys.Blank);
containerRegistry.RegisterForNavigation<MainPage, MainViewModel>(PageKeys.Main);
containerRegistry.RegisterForNavigation<ShellWindow, ShellViewModel>();
// Configuration
var configuration = BuildConfiguration();
// Register configurations to IoC
containerRegistry.RegisterInstance<IConfiguration>(configuration);
}
4. XAML ContainerProvider
在 XAML 中直接實例化 ViewModel 並設置 DataContext 是 View 和 ViewModel 之間建立關聯的最基本的方法:
<UserControl.DataContext>
<viewmodels:MainViewModel/>
</UserControl.DataContext>
但現實中很難這樣做,因為相當一部分 ViewModel 都會在構造函數中注入依賴,而 XAML 只能實例化具有無參數構造函數的類型。為了解決這個問題,Prism 提供了 ContainerProvider 這個工具,通過設置 Type
或 Name
從 Container 中解析請求的類型,它的用法如下:
<TextBlock
Text="{Binding
Path=Foo,
Converter={prism:ContainerProvider {x:Type local:MyConverter}}}" />
<Window>
<Window.DataContext>
<prism:ContainerProvider Type="{x:Type local:MyViewModel}" />
</Window.DataContext>
</Window>
5. ViewModelLocator
Prism 還提供了 ViewModelLocator
,用於將 View 的 DataContext 設置為對應的 ViewModel:
<Window x:Class="Demo.Views.MainWindow"
...
xmlns:prism="http://prismlibrary.com/"
prism:ViewModelLocator.AutoWireViewModel="True">
在將 View 的 ViewModelLocator.AutoWireViewModel
附加屬性設置為 True 的同時,Prism 會為查找這個 View 對應的 ViewModel 類型,然后從 Container 中解析這個類型並設置為 View 的 DataContext。它首先查找 ViewModelLocationProvider
中已經使用 Register
注冊的類型,Register
函數的使用方式如下:
ViewModelLocationProvider.Register<MainWindow, CustomViewModel>();
如果類型未在 ViewModelLocationProvider
中注冊,則根據約定好的命名方式找到 ViewModel 的類型,這是默認的查找邏輯的源碼:
var viewName = viewType.FullName;
viewName = viewName.Replace(".Views.", ".ViewModels.");
var viewAssemblyName = viewType.GetTypeInfo().Assembly.FullName;
var suffix = viewName.EndsWith("View") ? "Model" : "ViewModel";
var viewModelName = String.Format(CultureInfo.InvariantCulture, "{0}{1}, {2}", viewName, suffix, viewAssemblyName);
return Type.GetType(viewModelName);
例如 PrismTest.Views.MainView
這個類,對應的 ViewModel 類型就是 PrismTest.ViewModels.MainViewModel
。
當然很多項目都不符合這個命名規則,那么可以在 App.xaml.cs
中重寫 ConfigureViewModelLocator
並調用 ViewModelLocationProvider.SetDefaultViewTypeToViewModelTypeResolver
改變這個查找規則:
protected override void ConfigureViewModelLocator()
{
base.ConfigureViewModelLocator();
ViewModelLocationProvider.SetDefaultViewTypeToViewModelTypeResolver((viewType) =>
{
var viewName = viewType.FullName.Replace(".ViewModels.", ".CustomNamespace.");
var viewAssemblyName = viewType.GetTypeInfo().Assembly.FullName;
var viewModelName = $"{viewName}ViewModel, {viewAssemblyName}";
return Type.GetType(viewModelName);
});
}
6. Dialog Service
Prism 7 和 8 相對於以往的版本最大的改變在於 View 和 ViewModel 的交互,現在的處理方式變得更加易於使用,這篇文章以其中的 DialogService 作為代表講解 Prism 如何實現 View 和 ViewModel 之間的交互。
DialogService 內部會調用
ViewModelLocator.AutoWireViewModel
,所以使用DialogService
調用的 View 無需添加這個附加屬性。
以往在 WPF 中需要彈出一個窗口,首先新建一個 Window,然后調用 ShowDialog
,ShowDialog
阻塞當前線程,直到彈出的 Window 關閉,這時候還可以拿到一個返回值,具體代碼差不多是這樣:
var window = new CreateUserWindow { Owner = this };
var dialogResult = window.ShowDialog();
if (dialogResult == true)
{
var user = window.User;
//other code;
}
簡單直接有用。但在 MVVM 模式中,開發者要假裝自己不知道要調用的 View,甚至不知道要調用的 ViewModel。開發者只知道要執行的這個操作的名字,要傳什么參數,拿到什么結果,至於具體由誰去執行,開發者要假裝不知道(雖然很可能都是自己寫的)。為了做到這種效果,Prism 提供了 IDialogService
接口。這個接口的具體實現已經在 PrismApplication
里注冊了,用戶通常只需要從構造函數里注入這個服務:
public MainWindowViewModel(IDialogService dialogService)
{
_dialogService = dialogService;
}
IDialogService
提供兩組函數,分別是 Show
和 ShowDialog
,對應非模態和模態窗口。它們的參數都一樣:彈出的對話框的名稱、傳入的參數、對話框關閉時調用的回調函數:
void ShowDialog(string name, IDialogParameters parameters, Action<IDialogResult> callback);
其中 IDialogResult
類型包含 ButtonResult
類型的 Result
屬性和 IDialogParameters
類型的 Parameters
屬性,前者用於標識關閉對話框的動作(Yes、No、Cancel等),后者可以傳入任何類型的參數作為具體的返回結果。下面代碼展示了一個基本的 ShowDialog
函數調用方式:
var parameters = new DialogParameters
{
{ "UserName", "Admin" }
};
_dialogService.ShowDialog("CreateUser", parameters, dialogResult =>
{
if (dialogResult.Result == ButtonResult.OK)
{
var user = dialogResult.Parameters.GetValue<User>("User");
//other code
}
});
為了讓 IDialogService
知道上面代碼中 “CreateUser” 對應的 View,需要在 'App,xaml.cs' 中的 RegisterTypes
函數中注冊它對應的 Dialog:
containerRegistry.RegisterDialog<CreateUserView>("CreateUser");
上面這種注冊方式需要依賴 ViewModelLocator 找到對應的 ViewModel,也可以直接注冊 View 和對應的 ViewModel:
containerRegistry.RegisterDialog<CreateUserView, CreateUserViewModel>("CreateUser");
有沒有發現上面的 CreateUserWindow
變成了 CreateUserView
?因為使用 DialogService 的時候,View 必須是一個 UserControl,DialogService 自己創建一個 Window 將 View 放進去。這樣做的好處是 View 可以不清楚自己是一個彈框或者導航的頁面,或者要用在擁有不同 Window 樣式的其它項目中,反正只要實現邏輯就好了。由於 View 是一個 UserControl,它不能直接控制擁有它的 Window,只能通過在 View 中添加附加屬性定義 Window 的樣式:
<prism:Dialog.WindowStyle>
<Style TargetType="Window">
<Setter Property="prism:Dialog.WindowStartupLocation" Value="CenterScreen" />
<Setter Property="ResizeMode" Value="NoResize"/>
<Setter Property="ShowInTaskbar" Value="False"/>
<Setter Property="SizeToContent" Value="WidthAndHeight"/>
</Style>
</prism:Dialog.WindowStyle>
最后一步是實現 ViewModel。對話框的 ViewModel 必須實現 IDialogAware
接口,它的定義如下:
public interface IDialogAware
{
/// <summary>
/// 確定是否可以關閉對話框。
/// </summary>
bool CanCloseDialog();
/// <summary>
/// 關閉對話框時調用。
/// </summary>
void OnDialogClosed();
/// <summary>
/// 在對話框打開時調用。
/// </summary>
void OnDialogOpened(IDialogParameters parameters);
/// <summary>
/// 將顯示在窗口標題欄中的對話框的標題。
/// </summary>
string Title { get; }
/// <summary>
/// 指示 IDialogWindow 關閉對話框。
/// </summary>
event Action<IDialogResult> RequestClose;
}
一個簡單的實現如下:
public class CreateUserViewModel : BindableBase, IDialogAware
{
public string Title => "Create User";
public event Action<IDialogResult> RequestClose;
private DelegateCommand _createCommand;
public DelegateCommand CreateCommand => _createCommand ??= new DelegateCommand(Create);
private string _userName;
public string UserName
{
get { return _userName; }
set { SetProperty(ref _userName, value); }
}
public virtual void RaiseRequestClose(IDialogResult dialogResult)
{
RequestClose?.Invoke(dialogResult);
}
public virtual bool CanCloseDialog()
{
return true;
}
public virtual void OnDialogClosed()
{
}
public virtual void OnDialogOpened(IDialogParameters parameters)
{
UserName = parameters.GetValue<string>("UserName");
}
protected virtual void Create()
{
var parameters = new DialogParameters
{
{ "User", new User{Name=UserName} }
};
RaiseRequestClose(new DialogResult(ButtonResult.OK, parameters));
}
}
上面的代碼在 OnDialogOpened
中讀取傳入的參數,在 RaiseRequestClose
關閉對話框並傳遞結果。至此就完成了彈出對話框並獲取結果的整個流程。
自定義 Window 樣式在 WPF 程序中很流行,DialogService 也支持自定義 Window 樣式。假設 MyWindow
是一個自定義樣式的 Window,自定義一個繼承它的 MyPrismWindow
類型,並實現接口 IDialogWindow
:
public partial class MyPrismWindow: MyWindow, IDialogWindow
{
public IDialogResult Result { get; set; }
}
然后調用 RegisterDialogWindow
注冊這個 Window 類型。
protected override void RegisterTypes(IContainerRegistry containerRegistry)
{
containerRegistry.RegisterDialogWindow<MyPrismWindow>();
}
這樣 DialogService 將會使用這個自定義的 Window 類型作為 View 的窗口。
7. 結語
這篇文章介紹了如何使用 Prism.Wpf 創建一個 WPF 程序。雖然只介紹了 IDialogService,但其它模塊也大同小異,為了讓這篇文章盡量簡短我舍棄了它們的說明。
如果討厭 Prism.Wpf 的臃腫,或者需要創建面向多個 UI 平台的項目,也可以只使用輕量的 Prism.Core。
如果已經厭倦了 Prism,可以試試即將發布的 MVVM Toolkit,它基本就是個 MVVM Light 的性能加強版,而且也更時髦。