Prism.Wpf 和 Prism.Unity

对于现在的WPF项目来说,ViewModel更多的是使用.NET Standard作为目标框架;为了强制ViewModel和View假装是陌生人,倾向于把View和ViewModel放到不同的项目中。

使用Prism构建MVVM程序无法做到平台无关,它针对不同的平台提供了不同的包,分别是:

  1. - 针对WPFPrism.Wpf
  2. - 针对Xamarin FormsPrism.Forms
  3. - 针对Uno平台的Prism.Uno

所以,除非只使用Prism.Core,否则要将ViewModel项目共享给平台有点困难,毕竟用在WPF项目的Prism.Wpf本身就是一个WPF类库。

现在”编写平台无关的ViewModel项目”这个话题就与Prism无关了,再把Prism.Unity和Prism.Wpf选为代表(毕竟这两个组合比其他组合下载量多一些),这篇文章就只用它们作为Prism入门的学习对象。

[Windows] Prism 8.0 入门(下):Prism.Wpf 和 Prism.Unity - 图1

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 程序:
- <font style="color:rgb(37, 37, 37);">PrismApplication</font>
- <font style="color:rgb(37, 37, 37);">RegisterTypes</font>
- <font style="color:rgb(37, 37, 37);">XAML ContainerProvider</font>
- <font style="color:rgb(37, 37, 37);">ViewModelLocator</font>
- <font style="color:rgb(37, 37, 37);">Dialog Service</font>

由于Prism.Unity依赖Prism.Wpf,因此只需要安装Prism.Unity。

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创建主窗口。

RegisterTypes

其实在使用CreateShell函数前,首先必须实现另一个抽象函数RegisterTypes。由于Prism.Wpf相当依赖与IOC,所以要先在PrismApplication里注册必须的类型或依赖。PrismApplication里已经预先注册了DialogServiceEventAggregatorRegionManager等必须的类型(在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);
}

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>

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`函数的使用方式如下:

<font style="color:rgb(51, 51, 51);background-color:rgb(242, 244, 245);">ViewModelLocationProvider.Register<MainWindow, CustomViewModel>();</font>

如果类型未在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.MainWindow`这个类,对应的 ViewModel 类型就是`PrismTest.ViewModel.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);
    });
}

Dialog Service

Prism 7 和 8 相对于以往的版本最大的改变在于 View 和 ViewModel 的交互,现在的处理方式变得更加易于使用,这篇文章以其中的 DialogService 作为代表讲解 Prism 如何实现 View 和 ViewModel 之间的交互。
DialogService 内部会调用<font style="color:rgb(37, 37, 37);">ViewModelLocator.AutoWireViewModel</font>,所以使用<font style="color:rgb(37, 37, 37);">DialogService</font>调用的 View 无需添加这个附加属性。
以往在 WPF 中需要弹出一个窗口,首先新建一个 Window,然后调用<font style="color:rgb(37, 37, 37);">ShowDialog</font><font style="color:rgb(37, 37, 37);">ShowDialog</font>阻塞当前线程,直到弹出的 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`,对应非模态和模态窗口。它们的参数都一样:弹出的对话框的名称、传入的参数、对话框关闭时调用的回调函数:

<font style="color:rgb(167, 29, 93);">void</font><font style="color:rgb(121, 93, 163);">ShowDialog</font><font style="color:rgb(51, 51, 51);">(</font><font style="color:rgb(0, 0, 255);">string</font><font style="color:rgb(51, 51, 51);"> name, IDialogParameters parameters, Action<IDialogResult> callback)</font><font style="color:rgb(51, 51, 51);background-color:rgb(242, 244, 245);">;</font>

其中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:

<font style="color:rgb(51, 51, 51);background-color:rgb(242, 244, 245);">containerRegistry.RegisterDialog<CreateUserView>(</font><font style="color:rgb(223, 80, 0);">"CreateUser"</font><font style="color:rgb(51, 51, 51);background-color:rgb(242, 244, 245);">);</font>

上面这种注册方式需要依赖 ViewModelLocator 找到对应的 ViewModel,也可以直接注册 View 和对应的 ViewModel:

<font style="color:rgb(51, 51, 51);background-color:rgb(242, 244, 245);">containerRegistry.RegisterDialog<CreateUserView, CreateUserViewModel>(</font><font style="color:rgb(223, 80, 0);">"CreateUser"</font><font style="color:rgb(51, 51, 51);background-color:rgb(242, 244, 245);">);</font>

有没有发现上面的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 的窗口。

结语

这篇文章介绍了如何使用 Prism.Wpf 创建一个 WPF 程序。虽然只介绍了 IDialogService,但其它模块也大同小异,为了让这篇文章尽量简短我舍弃了它们的说明。 如果讨厌 Prism.Wpf 的臃肿,或者需要创建面向多个 UI 平台的项目,也可以只使用轻量的 Prism.Core。 如果已经厌倦了 Prism,可以试试即将发布的 MVVM Toolkit简单介绍),它基本就是个 MVVM Light 的性能加强版,而且也更时髦。

参考

https://github.com/PrismLibrary/Prism

https://prismlibrary.com/docs/index.html