65.9K
CodeProject 正在变化。 阅读更多。
Home

Catel - 第 3 部分 (共 n 部分):MVVM 框架

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.58/5 (20投票s)

2010年11月22日

CPOL

37分钟阅读

viewsIcon

134180

downloadIcon

2055

本文介绍 Catel 自带的 MVVM 框架。

Catel 是一个全新的框架(或企业库,随便你怎么称呼它),提供数据处理、诊断、日志记录、WPF 控件和 MVVM 框架。所以,Catel 不仅仅是另一个 MVVM 框架或一些好用的扩展方法。它更像是一个你希望在未来不久开发的所有(WPF)应用程序中都包含的库。

本文介绍 MVVM 框架。

文章浏览器

目录

  1. 引言
  2. 模型
  3. 视图模型
  4. 4. 视图与控件
  5. 5. 附加信息
  6. 历史

1. 引言

欢迎阅读本系列文章关于 Catel 的第三部分。本文介绍 Catel 自带的 MVVM 框架。

如果您还没有阅读 Catel 的前一篇文章,强烈建议您阅读。它们有编号,所以应该不难找到。

如果您还不熟悉 MVVM,请阅读:使用 Model-View-ViewModel 设计模式的 WPF 应用,作者:Josh Smith

本文将按照我们认为必须构建的顺序,解释 MVVM 的所有部分。首先是模型,它们最接近业务。然后是视图模型,它们定义了模型在特定情况下应该向用户显示哪些部分。这还包括特定于视图模型所代表功能的验证。最后但同样重要的是视图本身,它是视图模型在最终用户面前的最终表现。

在本文的最后,我们还将介绍 Catel 提供的特定控件,以使处理视图模型变得更加容易。例如,Catel 提供了解决 MVVM 中“嵌套控件”问题的方案。使用 Catel,您可以为用户控件动态创建子级视图模型。

在本文中,我不会深入探讨框架实际工作原理的技术细节。如果兴趣足够,我可以另写一篇文章来介绍框架背后的技术。请告诉我您是否感兴趣,如果人数足够,我会写另一篇文章。

您会注意到(或者可能不会注意到),这个框架与其他 MVVM 框架有很多共同之处。这当然是正常的,因为所有框架都在尝试实现相同的模式,只要您思考足够长的时间,这种模式并不难理解。在开始编写 MVVM 框架之前,我们首先研究了其他框架,因为已经有足够多(可能太多)的框架了。然而,即使是最好的(或最好的),例如 Cinch,使用起来仍然花费了太多时间,并且我们需要编写太多代码来创建视图模型。正是从这一点开始,我们决定编写自己的框架,其中包含了很多为我这样懒惰的开发人员准备的便利功能。

当您想快速创建视图模型时,代码片段是最佳选择。我怎么强调都不为过,您应该真正使用代码片段;它可以为您节省大量的输入工作。

在本文中,“便利性”一词会经常出现,也许对您来说太多了。如果是这样,我很抱歉,但理解 Catel 的核心就是便利性 确实非常重要。正如 Steve Ballmer 所说:“便利性!便利性!便利性!”

2. 模型

本文并非真正关于模型,但我们正在讨论 MVVM,而模型部分是 MVVM 的三个主要部分之一。因此,我想告诉您我们用什么样的模型来存储数据。基本上,您可以使用所有类型的对象作为模型,只要模型实现了 WPF 所需的最常用接口。

对于 MVVM,以下接口的实现非常重要

  • INotifyPropertyChanged

    如果未实现此接口,更改将不会通过绑定反映到 UI。换句话说,在 MVVM 环境中,您的模型和视图模型将无用。

    最后,强烈建议您的模型也实现以下接口

  • IDataErrorInfo

    如果未实现此接口,则无法向用户显示错误。

  • IEditableObject

    如果未实现此接口,则模型无法处理“状态”。这意味着用户无法开始编辑对象并最终取消它(因为没有存储的“状态”可用于恢复值)。

2.1. 数据库模型

最常用的设置是应用程序与数据库通信。市面上有大量的 ORM 工具,例如以下几种

我个人对 LLBLGen Pro 有非常好的体验,但到此为止,我不再做广告。最重要的是,ORM 映射器支持 WPF 所需的接口。

2.2. 文件模型

文件模型非常简单,假设它们由单个用户使用。尤其是当您使用 Catel 自带的 DataObjectBase(或扩展的 SavableDataObjectBase)类时。一个对象树可以由对象组成。使用这种方法时,可以非常简单地提取一个独立的部分并将其作为模型传递给视图模型。通常,您无需担心像使用数据库时那样处理多用户环境,因此您可以简单地锁定文件,而不必担心并发更新。

3. 视图模型

Catel 中的视图模型非常易于编写,并为最终用户提供了在处理模型方面的极大灵活性。本文的这一部分将介绍使创建视图模型变得容易的类。

3.1. ViewModelBase

ViewModelBase 类是 Catel MVVM 框架中最重要的类。当然,如果没有其他类,它什么也做不了,但所有使用 Catel 创建的视图模型都继承自此类。ViewModelBase 基于 Catel 自带的 DataObjectBase 类。由于该类的存在,MVVM 框架得以非常快速地建立(尽管“非常快速”是相对而言的)。下面是一个展示类树的类图

上面的类图显示了 DataObjectBase 类支持 .NET Framework 的多少默认接口。由于这些接口中的大多数也被 WPF 使用,ViewModelBase 类本身可以极大地受益于 DataObjectBase 的实现。

由于 ViewModelBase 继承自 DataObjectBase,您可以以完全相同的方式声明属性。甚至更好,您只需使用 DataObjectBase(或扩展的 SavableDataObjectBase)来创建(和保存)您的模型,并使用 ViewModelBase 作为所有视图模型的基础。

要声明一个视图模型,请使用以下代码片段

  • vm

    定义一个新的视图模型。

使用 vm 代码片段时,结果如下

/// <summary>
/// BasicViewModel view model.
/// </summary>
public class BasicViewModel : ViewModelBase
{
    #region Constructor & destructor
    /// <summary>
    /// Initializes a new instance of the <see cref="BasicViewModel"/> class.
    /// </summary>
    public BasicViewModel()
        : base()
    {
    }
    #endregion

    #region Properties

    /// <summary>
    /// Gets the title of the view model.
    /// </summary>
    /// <value>The title.</value>

    public override string Title { get { return "View model title"; } }
    #region Models
    // TODO: Register models with the vmpropmodel codesnippet
    #endregion

    #region View model

    // TODO: Register view model properties with
    // the vmprop or vmpropviewmodeltomodel codesnippets
    #endregion

    #endregion

    #region Commands

    // TODO: Register commands with the vmcommand
    // or vmcommandwithcanexecute codesnippets

    #endregion

    #region Methods
    /// <summary>
    /// Initializes the object by setting default values.
    /// </summary>     
    protected override void Initialize()
    {
        // TODO: Implement logic to initialize the view model
    }
    /// <summary>
    /// Validates the fields.
    /// </summary>
    protected override void ValidateFields()
    {
        // TODO: Implement any field validation of this object.
        // Simply set any error by using the SetFieldError method
    }
    /// <summary>
    /// Validates the business rules.
    /// </summary>
    protected override void ValidateBusinessRules()
    {
        // TODO: Implement any business rules of this object.
        // Simply set any error by using the SetBusinessRuleError method
    }
    /// <summary>
    /// Saves the data.
    /// </summary>
    /// <returns>
    ///    <c>true</c> if successful; otherwise <c>false</c>.
    /// </returns>     
    protected override bool Save()
    {
        // TODO: Implement logic when the view model is saved
        return true;
    }
    #endregion
}

看起来代码量不少,但这都是代码片段为您生成的。现在您已经拥有了一个完整的骨架,可以通过使用正确的代码片段轻松扩展它,就像注释中提示的那样。

3.2. 定义属性

有几种代码片段可用于创建视图模型属性

  • vmprop

    定义一个简单的视图模型属性。

  • vmpropmodel

    定义一个带有 ModelAttribute 的视图模型属性。该属性默认也设为私有。

  • vmpropviewmodeltomodel

    定义一个带有 ViewModelToModelAttribute 的视图模型属性。

使用 vmprop 代码片段时,结果如下

/// <summary>
/// Gets or sets the name.
/// </summary>
public string Name
{
    get { return GetValue<string>(NameProperty); }
    set { SetValue(NameProperty, value); }
}
/// <summary>
/// Register the Name property so it is known in the class.
/// </summary>
public static readonly PropertyData NameProperty = 
                       RegisterProperty("Name", typeof(string));

在视图中,现在可以绑定到视图模型的 Name 属性,只要 DataContext 设置为视图模型的一个实例。

3.3. 定义命令

有几种代码片段可用于创建视图模型命令

  • vmcommand

    定义一个始终可执行的命令。

  • vmcommandwithcanexecute

    定义一个实现 CanExecute 方法的命令,以确定命令是否可以在其当前状态下被调用。

使用 vmcommandwithcanexecute 代码片段时,结果如下

/// <summary>
/// Gets the Add command.
/// </summary>
public Command<object, object> Add { get; private set; }
// TODO: Move code below to constructor
Add = new Command<object, object>(Add_Execute, Add_CanExecute);
// TODO: Move code above to constructor
/// <summary>
/// Method to check whether the Add command can be executed.
/// </summary>
/// <param name="parameter">The parameter of the command.</param>
private bool Add_CanExecute(object parameter)
{
    return true;
}
/// <summary>
/// Method to invoke when the Add command is executed.
/// </summary>
/// <param name="parameter">The parameter of the command.</param>
private void Add_Execute(object parameter)
{
    // TODO: Handle command logic here
}

现在唯一要做的就是将命令的创建移到构造函数中(正如注释中已经指示的那样)。

在视图中,现在可以将任何命令属性(例如 ButtonCommand 属性)绑定到视图模型的 Add 属性,只要 DataContext 设置为视图模型的一个实例。

3.4. 服务

服务在 MVVM 中非常重要。它们定义了一种与用户交互的方式,而无需使用固定的控件,如 MessageBoxSaveFileDialog。Catel 中定义的接口仅定义了特定服务的通用功能。然后,实际实现决定了当服务使用以下调用时将返回哪个接口实现

var messageService = GetService<IMessageService>();

ViewModelBase 实现中的此调用返回注册为 IMessageService 实现的实际实现类。例如,在 Windows 应用程序中,底层实现将使用 MessageBox 类。但是,在单元测试期间,您不想被 MessageBox 干扰,因为没有人会确认它。因此,您可以在单元测试期间模拟 IMessageService 接口。

3.4.1. IMessageService

IMessageService 可用于显示消息框。它支持 .NET Framework 中已知的所有标准消息框功能,还提供了一些便利方法(例如 ShowWarning,开发者只需传递警告消息)。

可以使用如下所示的服务

var messageService = GetService<IMessageService>();
messageService.ShowError("An error has occurred!");

3.4.2. IOpenFileService

IOpenFileService 可用于让用户选择一个文件以打开它。

可以使用如下所示的服务

var openFileService = GetService<IOpenFileService>();
openFileService.Filter = "*.dob|Data Object Base files (*.dob)";



if (openFileService.DetermineFile())
{
    // Selected file is in openFileService.FileName
}

3.4.3. ISaveFileService

ISaveFileService 可用于让用户选择一个文件以保存它。

可以使用如下所示的服务

var saveFileService = GetService<ISaveFileService>();

saveFileService.Filter = "*.dob|Data Object Base files (*.dob)";
if (saveFileService.DetermineFile())
{
    // Selected file is in saveFileService.FileName
}

3.4.4. IPleaseWaitService

IPleaseWaitService 可用于控制 Catel 自带的 PleaseWaitWindowPleaseWaitWindow(及其辅助类 PleaseWaitHelper)在用户必须等待操作完成时向用户提供反馈非常有帮助。

可以使用如下所示的服务

var pleaseWaitService = GetService<IPleaseWaitService>();
pleaseWaitService.Show(() => Thread.Sleep(3000), "Saving data");

3.4.5. IProcessService

IProcessService 是一项服务,允许开发人员从视图模型启动进程。大多数 MVVM 开发人员只使用 Process.Start 来启动应用程序,但这破坏了 MVVM,因为您将无法再通过简单的单元测试来测试您的视图模型(至少是那部分)。

该服务的使用非常简单,并且与 Process 类非常相似。该服务提供了三个方法重载

  • void StartProcess(string fileName);
  • void StartProcess(string fileName, string arguments);
  • void StartProcess(string fileName, string arguments, ProcessCompletedDelegate processCompletedCallback);

通过最后一个重载,可以实现异步等待进程完成。ProcessCompletedDelegate 提供了应用程序的退出代码。

感谢对启动进程的这种抽象,现在可以完全控制单元测试期间发生的事情。您现在可以测试各种进程结果,而无需实际模拟或运行实际进程。

3.4.6. IUIVisualizationService

IUIVisualizationService 是所有服务中最复杂的。它处理所有 MVVM 开发人员都面临的问题:显示弹出窗口。窗口基于 Cinch 中找到的实现 Cinch。Catel 实现的优点是它自动做了更多的事情。例如,所有非 MVVM 窗口都必须通过调用 Register 方法进行注册。然而,Catel 会在整个应用程序域中搜索 DataWindow<TViewModel> 的实现。如果您想充分利用 Catel 的强大功能,建议所有窗口都继承自 DataWindow<TViewModel> 类。由于 Catel 会自动注册窗口,因此您无需自己处理此问题。这意味着如果您已经创建了一个基于 DataWindow<TViewModel> 的 MVVM 窗口,您可以像这样简单地使用该服务

// Create view model
var viewModel = new PersonViewModel();
// Get service & show dialog
var uiVisualizerService = GetService<IUIVisualizerService>();
uiVisualizerService.ShowDialog(viewModel);

如您所见,您不再需要真正了解 UI。您只需创建一个视图模型实例并将其传递给 IUIVisualizerService。它将知道显示哪个窗口,并且会自动设置视图模型。由于它知道映射关系,它将尝试将传递给 ShowDialog 方法的视图模型实例注入到窗口中。如果不可能,它将使用窗口的默认(无参数)构造函数并手动设置窗口的数据上下文。

Catel 开箱即用提供了很多强大的功能,但有些人只想使用其中的一部分。因此,也支持使用 Catel 处理非 MVVM 窗口。这种方法的缺点是窗口必须手动注册,如下所示

var uiVisualizerService = GetService<IUIVisualizerService>();
uiVisualizerService.Register("personWindow", typeof(PersonWindow));

注册后,窗口可以使用如下方式

var uiVisualizerService = GetService<IUIVisualizerService>();
uiVisualizerService.ShowDialog("personWindow", dataContext);

上面示例中显示的 dataContext 参数将设置为之前通过调用 Register 方法注册的 PersonWindow 的数据上下文。

3.5. 验证

由于 ViewModelBase 类继承自 DataObjectBase,因此它提供了与 DataObjectBase 类相同的验证能力。DataObjectBase(因此也包括 ViewModelBase)提供以下类型的验证

  • 字段警告;
  • 业务警告;
  • 字段错误;
  • 业务错误。

ViewModelBase 使用智能验证。这意味着如果对象已经过验证,则不会再次验证对象,以确保视图模型不会过多地影响性能。只有当视图模型上的属性发生更改时,才会调用验证。当然,如果需要,仍然可以在视图模型必须被验证时强制执行验证,即使没有属性发生更改。

要实现字段或业务规则验证,您只需重写 ValidateFields 和/或 ValidateBusinessRules 方法

///<summary>
/// Validates the fields.
///</summary>
protected override void ValidateFields()
{
    // Set field warning
    SetFieldWarning(MyProperty, "Warning description");
    // Set field error
    SetFieldError(MyProperty, "Error description");
}
///<summary>
/// Validates the business rules.
///</summary>
protected override void ValidateBusinessRules()
{
    // Set business rule warning
    SetBusinessRuleWarning("Business rule description");
    // Set business rule error
    SetBusinessRuleError("Business error description");
}

有关 DataObjectBase 中验证如何工作的更多信息,请参阅本系列文章的第一篇。

3.6. 与模型交互

创建视图模型最重要的原因之一是它充当视图和模型之间的粘合剂。视图和视图模型之间的通信完全由 WPF 通过绑定来处理。问题在于,大多数时候,视图模型用于显示模型(例如数据库实体)的子集。

大多数 MVVM 框架(实际上,我还没见过不要求手动更新的)都需要手动更新,这让我们回到了石器时代(还记得 WinForms 时代在启动时设置控件,在结束时读取值吗?)。Catel 通过提供便利的属性来解决这个问题,这些属性可以处理视图模型和模型之间这种愚蠢的获取/设置故事。Catel 完全支持从模型获取/设置值,但相信我:您会喜欢接下来要介绍的属性。

3.6.1. ModelAttribute

为了能够从模型获取/设置值,了解实际模型很重要。因此,为了让视图模型知道哪个属性代表模型,可以使用 ModelAttribute,如下所示

/// <summary>
/// Gets or sets the person.
/// </summary>
[Model]
public Person Person
{
    get { return GetValue<Person>(PersonProperty); }
    private set { SetValue(PersonProperty, value); }
}
/// <summary>
/// Register the Person property so it is known in the class.
/// </summary>
public static readonly PropertyData PersonProperty = 
              RegisterProperty("Person", typeof(Person));

模型设置器通常写为 private(您通常不希望 UI 能够更改模型),但获取器是 public 的,因为您可能想从中读取信息。

注意:您应该使用 vmpropmodel 代码片段来创建模型属性。

Catel 中的模型被视为非常、非常特殊的对象。这意味着一旦设置了模型,Catel 就会尝试调用 IEditableObject.BeginEdit 方法。然后,一旦模型在未保存的情况下被更改,或者如果视图模型被取消,模型将通过 IEditableObject.CancelEdit 正确取消。如果模型被保存,活动模型将通过 IEditableObject.EndEdit 提交。我将省略本文中其余的魔术,但如果您对此有任何疑问,请随时与我联系!

3.6.2. ViewModelToModelAttribute

现在我们知道如何声明一个属性具有模型,是时候学习如何与它通信了。通常,您需要监视模型以确保它在模型更新时得到正确同步。使用 Catel,这不再是必需的。只需使用 ViewModelToModelAttribute,您将获得以下优势

  • 模型会自动监视更改,因此如果映射的属性发生更改,视图模型将相应更新;
  • 当视图模型发生更改时,此属性会自动映射到模型;
  • 当模型发生更改时,视图模型会自动使用新模型的值进行初始化;
  • 当模型出现错误或警告(业务或字段)时,警告会映射到视图模型,以便您可以在视图模型中“重用”模型的验证。

所以,您免费获得这一切?不,您需要用 ViewModelToModelAttribute 装饰您的属性,如下所示

/// <summary>
/// Gets or sets the first name.
/// </summary>
[ViewModelToModel("Person")]
public string FirstName
{
    get { return GetValue<string>(FirstNameProperty); }
    set { SetValue(FirstNameProperty, value); }
}
/// <summary>
/// Register the FirstName property so it is known in the class.
/// </summary>
public static readonly PropertyData FirstNameProperty = 
              RegisterProperty("FirstName", typeof(string));

代码示例是该属性最简单的用法。它只提供模型属性的名称。这是必需的,因为可能(但不常见)有多个模型。但是,如果您的模型上的属性名称与视图模型上的属性名称不同怎么办?没问题,使用属性的重载,如下所示

[ViewModelToModel("Person", "RealFirstName")]
public string FirstName
///... (remaining code left out for the sake of simplicity)

上面的代码会将视图模型的 FirstName 属性映射到 Person 模型上的 RealFirstName 属性。

还有一些其他重载可以启用对 LLBLGen Pro 的支持,但在此文中我不想深入探讨。

3.7. 与其他视图模型交互

既然我们已经看到了视图模型和模型之间通信的便捷性,您肯定想要更多,对吧?我知道这种感觉:“你让他们得寸进尺,他们就得寸进尺”。别担心,我可以给你我的右手,只要我能保住我的左手。无论如何,Catel 的开发者对此有所准备。所以,让我们谈谈与其他视图模型的交互。

假设您有一个多文档界面(MDI,正如过去所称呼的那样)。如果您遵循 MVVM 原则,每个文档(或选项卡)都有自己的视图模型。然后,您希望了解某种视图模型的更新。例如,假设有一个代表家庭的视图模型称为 FamilyViewModel。这个视图模型可能对 PersonViewModel 的更改感兴趣。

3.7.1. 视图模型管理器

让我们从基础开始。正如我们在本文前面所学到的,所有使用 Catel 创建的视图模型都继承自 ViewModelBase 类。这个类所做的一件事是,当它被创建时,它会将自己注册到 ViewModelManager 类,当它被关闭时,它会再次注销自己。所以,简单地说,ViewModelManager 是一个在当前时刻持有所有现有视图模型引用的类。

3.7.2. InterestedInAttribute

现在我们知道了 ViewModelManager 类,并且知道有一个存储了所有视图模型类所有活动实例的仓库,那么与其他视图模型通信应该相当容易。实际上也确实如此;您只需用 InterestedInAttribute 装饰一个视图模型,如下所示

[InterestedIn(typeof(FamilyViewModel))]



public class PersonViewModel : ViewModelBase

一个视图模型可以有多个 InterestedInAttribute 实例,因此可以同时订阅多个视图模型类型。一旦视图模型被 InterestedInAttribute 装饰,该视图模型将通过 OnViewModelPropertyChanged 方法接收所有更改(当然,以及导致更改的视图模型),如下所示

/// <summary>
/// Called when a property has changed for a view model
/// type that the current view model is interested in. This can
/// be accomplished by decorating the view model
/// with the <see cref="InterestedInAttribute"/>.
/// </summary>
/// <param name="viewModel">The view model.</param>
/// <param name="propertyName">Name of the property.</param>
protected override void OnViewModelPropertyChanged(IViewModel viewModel, 
                        string propertyName)
{
    // You can now do something with the changed property
}

3.8. 保存

最后一件事(实际上相当重要)是视图模型的保存。您已成功创建了一个视图模型,并且能够在视图中使用它。现在唯一剩下的就是保存视图模型中进行的更改。根据您使用视图模型的方式,这可能几乎不费力,或者相当费力。让我们从“几乎不费力”的选项开始。

3.8.1. 使用 ModelAttribute 装饰

当您使用本文前面介绍的 ModelAttribute 时,您只需要保存模型,因为更改已经反映到模型中了。下面的代码显示了一个示例,假设模型上的 Save 方法返回一个布尔值

protected override bool Save()
{
    // Save model
    return Person.Save();
}

3.8.2. 手动映射和保存

如果您选择不使用便利属性,您将不得不将所有属性映射到模型,然后保存模型

protected override bool Save()
{
    // Map all properties
    Person.FirstName = FirstName;
    // Save model
    return Person.Save();
}

在上面的示例中,我反复告诉您使用 ModelAttribute 结合 ViewModelToModelAttribute 可能会显得有些乏味,但想象一个具有 10 个属性的视图模型。在初始化时获取所有属性,并在保存时将所有属性映射回模型,这比使用属性进行装饰要容易得多。

4. 视图与控件

视图是用户在屏幕上实际看到的东西。视图与视图模型通信。Catel 自带的 MVVM 框架可以独立使用。但是,该框架需要一些约定,您作为 Catel 的最终用户不应该担心。因此,Catel 同时提供了 UserControl 和 Window,它们完全支持 MVVM 框架。这些都可以用作应用程序中开发的所有控件和窗口的基类。

4.1. DataWindow<TViewModel>

使用 MVVM 框架最简单的对象是 DataWindow<TViewModel> 类。这个窗口类继承自 Catel 中著名的 DataWindow,因此它提供了相同的功能。DataWindow<TViewModel> 相对于常规 DataWindow 类提供的额外功能是,它完全负责视图模型的构造和视图模型的验证。

一旦您知道如何使用 DataWindow<TViewModel> 类,它的使用就非常简单。首先,您需要在 XAML 文件中指定基类,如下所示

<Windows:DataWindow 
   x:Class="Catel.Articles._03___MVVM.Examples.DataWindow.PersonWindow"
   xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
   xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
   xmlns:BasicViewModel="clr-namespace:Catel.Articles._03___MVVM.Examples.BasicViewModel"
   xmlns:Windows="clr-namespace:Catel.Windows;assembly=Catel.Windows"
   x:TypeArguments="BasicViewModel:PersonViewModel">
      <!-- Content left out for the sake of simplicity -->
</Windows:DataWindow>

如您所见,与“普通”窗口定义相比,有两个地方发生了变化

  1. 类型定义从 Window 更改为 Windows:DataWindow
  2. 添加了一个名为 x:TypeArguments 的属性,该属性指定了窗口支持的视图模型类型。

代码隐藏甚至更简单

/// <summary>
/// Interaction logic for PersonWindow.xaml
/// </summary>
public partial class PersonWindow : DataWindow<PersonViewModel>
{
    /// <summary>
    /// Initializes a new instance of the <see cref="PersonWindow"/> class.
    /// </summary>
    /// <param name="viewModel">The view model.</param>
    public PersonWindow(PersonViewModel viewModel)
        : base(viewModel)
    {
        // Initialize component
        InitializeComponent();
    }
}

使用 Catel 的 MVVM 框架时,以上代码就是您所需要的一切。

4.1.1. 视图模型的构造

有多种方法可以构造带有视图模型的窗口。有三种选项可供您构造视图模型

  • 带视图模型的构造函数

    这是您可以使用最佳选项。这样,就可以将视图模型注入到数据窗口中。

  • 带模型的构造函数

    通过接受模型作为输入,可以为开发人员节省手动创建视图模型的工作。然后,数据窗口必须手动构造视图模型并将其传递给其基构造函数。

  • 空构造函数

    如果您使用空构造函数,开发人员将必须手动设置数据上下文。这是您应该真正避免的事情。但是,一切都取决于您。

4.1.2. 自动验证

DataWindow<TViewModel> 的酷之处在于它会自动将开发人员定义的控件包装在 InfoBarMessageControl 中。这样,错误和警告就会显示在窗口顶部。DataWindow<TViewModel> 的另一个特性是它会自动创建一个 WarningAndErrorValidator 控件,并将视图模型设置为源。这样,视图模型的所有警告也会显示在 InfoBarMessageControl 中。换句话说:除了在视图模型中实际设置警告和错误外,您无需做任何事情来实现验证。而且,如果验证发生在模型中,您可以使用 ViewModelToModelAttribute,因此您也不必担心。

4.2. UserControl<TViewModel>

UserControl<TViewModel> 是 Catel 中一个非常有趣的类,它充分展示了 Catel 自带的 MVVM 框架的强大功能。用户控件能够完全集成 MVVM 在用户控件级别,并解决了“嵌套用户控件”问题,该问题将在本文稍后详细介绍。

4.2.1. 无参数自动构造

最简单的事情是创建一个具有空构造函数(即无参数)的视图模型。如果 UserControl<TViewModel> 被添加到视觉树中,视图模型会立即构造并可供使用。在 UserControl<TViewModel> 实现内部使用的视图模型与 DataWindow<TViewModel> 实现完全相同。这样,开发人员不必担心他们当前是否可以编写一个用于窗口或控件的视图模型。

4.2.2. 带参数自动构造

稍微难一点(仍然非常容易,别担心),但功能更强大的是带参数的构造。这样,控件被强制使用数据上下文来创建视图模型。如果没有有效的数据上下文可用于构造视图模型,则不会构造视图模型。这听起来有点抽象,但让我们看一个更有意义的例子。

假设您想编写一个管理公司树的应用程序。数据顶层由 Company 对象(模型)集合组成。您想在 ItemsControl 中显示公司,这是一个很好的表示公司的方式。但是,如何显示公司详细信息呢?您可以创建一个模板,但我不会推荐它,因为公司表示可能变得非常复杂(和动态),因为它由包含 Person 对象的对象组成,这些对象又可以有子对象(员工),而这些子对象也是 Person 对象,可以有子对象,依此类推。您可能会认为这是一个非常简单的场景,实际上也是如此,以确保所有读者都能正确理解。但是,可能会有很多复杂的树场景。例如,对于一个客户,我不得不写一份完整的患者治疗概述,它由许多不同的对象组成,每个对象都有一个包含其他对象类型的子集合。然后,您可以使用编写一个简单且通用的数据模板来挽救自己。下面是该示例的图形形式

现在 UserControl<TViewModel> 的真正强大之处就体现出来了。例如,要显示公司及其经理,必须编写一个包含公司的 ItemsControl,然后是一个包含公司详细信息的用户控件。为了简单起见,我现在暂时省略员工。用法可能看起来有点复杂,但一旦您掌握了窍门,它实际上非常简单。首先,创建一个带有您想接受的模型(在这种情况下,是我们将显示详细信息的 Company 类)的构造函数的视图模型

/// <summary>
/// Initializes a new instance of the <see cref="CompanyViewModel"/> class.
/// </summary>
/// <param name="company">The company.</param>
public CompanyViewModel(Models.Company company)
    : base()
{
    // Store values
    Company = company;
}

如您所见,视图模型只能通过传递一个公司模型来构造。这很正常,因为我们如何显示一个不存在的(null)公司的详细信息呢?现在我们有了视图模型,我们可以创建我们的用户控件

<Controls:UserControl 
   x:Class="Catel.Articles._03___MVVM.Examples.UserControlWithParameter.Company"
   xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
   xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
   xmlns:Controls="clr-namespace:Catel.Windows.Controls;assembly=Catel.Windows"
   xmlns:UserControlWithParameter=
     "clr-namespace:Catel.Articles._03___MVVM.Examples.UserControlWithParameter"
   x:TypeArguments="UserControlWithParameter:CompanyViewModel">
       <!-- For the sake of simplicity, content is left out -->
</Controls:UserControl>

请注意,类定义现在是 Controls:UserControl 而不是 UserControl,并且添加了一个附加属性 TypeArguments 来定义类型参数。

代码隐藏甚至更简单

/// <summary>
/// Interaction logic for Company.xaml
/// </summary>
public partial class Company : UserControl<CompanyViewModel>
{
    /// <summary>
    /// Initializes a new instance of the <see cref="Company"/> class.
    /// </summary>
    public Company()
    {
        // Initialize component
        InitializeComponent();
    }
}

现在控件已创建(我不想在此关注实际控件内容,因为它不重要),我们可以在包含公司集合的主窗口中使用该用户控件。视图模型还有一个 SelectedCompany 属性,代表列表框中选定的公司。然后,我们使用 Company 控件并将数据上下文绑定到 SelectedCompany 属性

<!-- Items control of companies -->
<ListBox Grid.Column="0" ItemsSource="{Binding CompanyCollection}" 
               SelectedItem="{Binding SelectedCompany}">
    <ListBox.ItemTemplate>
        <DataTemplate>
            <StackPanel>
                <Label Content="{Binding Name}" />
                <Label Content="{Binding CEO.FullName}" />
            </StackPanel>
        </DataTemplate>
    </ListBox.ItemTemplate>
</ListBox>

<!-- Company details -->

<UserControlWithParameter:Company Grid.Column="1" 
              DataContext="{Binding SelectedCompany}" />

如代码所示,有一个包含所有公司的列表框。用户控件的数据上下文绑定到 SelectedCompany。最棒的是,一旦选中公司,用户控件就会创建一个 CompanyViewModel 实例,因为它在构造函数中接受一个 Company 实例。示例应用程序的屏幕截图(希望如此)将提供更多关于哪个更改导致了确切的视图模型创建的见解

在上图中,您可以看到两个控件。第一个是绑定到 CompaniesViewModelItemsControl,因为窗口代表一组窗口。第二个是 CompanyControl,它在左侧选择公司后动态构造 CompanyViewModel。这意味着对于每次公司选择,都会构造一个新的视图模型。这样,您就可以处理视图模型的保存、取消和关闭,然后再构造下一个视图模型。

这一切中最棒的是,您可以真正开始在整个应用程序中重用用户控件。主视图模型不再需要定义(子)控件的所有属性,现在每个控件都有自己的视图模型,您不必担心控件父级的实现。只需将用户控件的数据上下文设置为正确的类型实例,用户控件就会处理其余的事情。

现在您已经了解了创建即时构造视图模型的用户控件有多么容易,让我们来看看可以使用您刚刚学到的技术解决的“嵌套用户控件”问题。

4.2.3. 嵌套用户控件

大多数 MVVM 用户面临的问题之一是“嵌套用户控件”问题。问题在于,大多数(实际上,我们见过的所有)MVVM 框架只支持一个窗口的视图模型(或者,如果您幸运的话,一个用户控件)。然而,“嵌套用户控件”问题引发了很多问题

  • 如果要求构建一个动态 UI,其中嵌套用户控件在需要时动态加载怎么办?
  • 嵌套用户控件中的验证怎么办?
  • 嵌套用户控件视图模型何时保存?

大多数 MVVM 开发人员的回答是:“将嵌套用户控件的所有属性放在主视图模型上”。再说一遍?你在开玩笑吗?这对于现实世界的问题不是一个现实的解决方案。所以,我们作为 Catel 的开发者,为您提供了解决“嵌套用户控件”问题的现实解决方案,即 UserControl<TViewModel>

UserControl<TViewModel> 类的真正强大之处在于它能够根据数据上下文动态构造视图模型。所以,开发人员唯一需要做的是设置正确的数据上下文。下面是“嵌套用户控件”问题的图形表示

常规 MVVM 框架

Catel MVVM 框架

如上图所示,Catel 解决该问题的方法更加专业。以下是几个原因

  • 关注点分离(每个控件都有一个视图模型,只包含自身的信息,不包含子级的信息);
  • 用户控件被构建成可重用的。如果用户控件不能有自己的视图模型,那么如何实际将用户控件与 MVVM 一起使用?

用户控件背后的想法相当复杂,特别是因为 WPF 在运行时数据上下文类型更改方面并不擅长。然而,通过一些变通方法(在 UserControl<TViewModel> 的源代码中描述得很好),可以动态地构造视图模型。用户控件根据前面所述,带有或不带构造函数来构造视图模型。当视图模型被构造后,用户控件会尝试查找一个实现 IViewModelContainer 接口的(逻辑或视觉)父级。通过这个接口,视图模型可以订阅到父视图模型,并创建验证链,如下所示

如上图所示,链中的所有子级都会被验证,当最后一个子级被验证后,视图模型会将其子级及其自身的验证结果报告给父级。这样,当其中一个嵌套用户控件视图模型出现错误时,仍然可以禁用命令。

保存嵌套视图模型链的工作方式与验证完全相同。首先,视图模型保存所有子级,然后保存自身,最后将结果报告给父级。

现在,让我们来看一个“真实生活”的例子。我不想让它过于复杂,但也不想过于简单,而且不想把重点放在数据内容上,而是放在用户控件和视图模型创建上。因此,我选择了以下数据模型

图像显示我们有一个房子。在这个房子里,我们有多个房间。在每个房间里,可以有几张桌子、椅子和床。这显示了一个“复杂”的 UI 树,有很多不同的用户控件(每个对象都有自己的表示,因此有用户控件)。现在,我们的目标是创建可以在显示整个房子的窗口中使用的用户控件,也可以在“子部分”中使用,并且我们希望完全独立于 HouseWindowViewModel(这将是常规 MVVM 框架中创建的唯一视图模型)。

下面的示例仅显示 Room 控件和相应的视图模型。本文的完整源代码在 Catel 的源代码存储库中提供,因此如果您感兴趣或需要更完整的示例,可以获得整个示例。

首先,我们从一个简单的模型开始。对于模型,我们使用 Catel 第一篇文章中解释的 DataObjectBase 类。通过使用提供的代码片段,可以在一分钟内设置好这个模型

/// <summary>
/// Bed Data object class which fully supports serialization,
/// property changed notifications,
/// backwards compatibility and error checking.
/// </summary>

[Serializable]
public class Room : DataObjectBase<Room>
{
    #region Constructor & destructor
    /// <summary>
    /// Initializes a new object from scratch.
    /// </summary>
    public Room()
        : this(NameProperty.GetDefaultValue<string>()) { }
    /// <summary>
    /// Initializes a new instance of the <see cref="Room"/> class.
    /// </summary>
    /// <param name="name">The name.</param>

    public Room(string name)
    {
        // Create collections
        Tables = new ObservableCollection<Table>();
        Beds = new ObservableCollection<Bed>();
        // Store values
        Name = name;
    }
    /// <summary>
    /// Initializes a new object based on <see cref="SerializationInfo"/>.
    /// </summary>
    /// <param name="info"><see cref="SerializationInfo"/>
    /// that contains the information.</param>
    /// <param name="context"><see cref="StreamingContext"/>.</param>
    protected Room(SerializationInfo info, StreamingContext context)
        : base(info, context) { }

    #endregion
    #region Properties

    /// <summary>
    /// Gets or sets the name.
    /// </summary>
    public string Name
    {
        get { return GetValue<string>(NameProperty); }
        set { SetValue(NameProperty, value); }
    }
    /// <summary>
    /// Register the Name property so it is known in the class.
    /// </summary>
    public static readonly PropertyData NameProperty = 
           RegisterProperty("Name", typeof(string), "Room");
    /// <summary>
    /// Gets or sets the table collection.
    /// </summary>
    public ObservableCollection<Table> Tables
    {
        get { return GetValue<ObservableCollection<Table>>(TablesProperty); }
        set { SetValue(TablesProperty, value); }
    }
    /// <summary>
    /// Register the Tables property so it is known in the class.
    /// </summary>
    public static readonly PropertyData TablesProperty = 
           RegisterProperty("Tables", typeof(ObservableCollection<Table>));
    /// <summary>
    /// Gets or sets the bed collection.
    /// </summary>
    public ObservableCollection<Bed> Beds
    {
        get { return GetValue<ObservableCollection<Bed>>(BedsProperty); }
        set { SetValue(BedsProperty, value); }
    }
    /// <summary>
    /// Register the Beds property so it is known in the class.
    /// </summary>
    public static readonly PropertyData BedsProperty = 
           RegisterProperty("Beds", typeof(ObservableCollection<Bed>));
    #endregion
}

接下来,我们将创建视图模型。同样,通过使用本文前面解释的代码片段,可以在几分钟内设置好视图模型

/// <summary>
/// Room view model.
/// </summary>
public class RoomViewModel : ViewModelBase
{
    #region Variables
    private int _bedIndex = 1;
    private int _tableIndex = 1;
    #endregion
    #region Constructor & destructor
    /// <summary>
    /// Initializes a new instance of the <see cref="RoomViewModel"/> class.
    /// </summary>
    public RoomViewModel(Models.Room room)
    {
        // Store values
        Room = room;
        // Create commands
        AddTable = new Command<object>(AddTable_Execute);
        AddBed = new Command<object>(AddBed_Execute);
    }
    #endregion
    #region Properties

    /// <summary>
    /// Gets the title of the view model.
    /// </summary>
    /// <value>The title.</value>

    public override string Title { get { return "Room"; } }
    #region Models

    /// <summary>
    /// Gets or sets the room.
    /// </summary>
    [Model]
    public Models.Room Room
    {
        get { return GetValue<Models.Room>(RoomProperty); }
        private set { SetValue(RoomProperty, value); }
    }
    /// <summary>
    /// Register the Room property so it is known in the class.
    /// </summary>
    public static readonly PropertyData RoomProperty = 
                  RegisterProperty("Room", typeof(Models.Room));

    #endregion
    #region View model

    /// <summary>
    /// Gets or sets the name.
    /// </summary>
    [ViewModelToModel("Room")]
    public string Name
    {
        get { return GetValue<string>(NameProperty); }
        set { SetValue(NameProperty, value); }
    }
    /// <summary>
    /// Register the Name property so it is known in the class.
    /// </summary>
    public static readonly PropertyData NameProperty = 
                  RegisterProperty("Name", typeof(string));
    /// <summary>
    /// Gets or sets the table collection.
    /// </summary>
    [ViewModelToModel("Room")]
    public ObservableCollection<Models.Table> Tables
    {
        get { return GetValue<ObservableCollection<Models.Table>>(TablesProperty); }
        set { SetValue(TablesProperty, value); }
    }
    /// <summary>
    /// Register the Tables property so it is known in the class.
    /// </summary>
    public static readonly PropertyData TablesProperty = 
      RegisterProperty("Tables", typeof(ObservableCollection<Models.Table>));
    /// <summary>
    /// Gets or sets the bed collection.
    /// </summary>
    [ViewModelToModel("Room")]
    public ObservableCollection<Models.Bed> Beds
    {
        get { return GetValue<ObservableCollection<Models.Bed>>(BedsProperty); }
        set { SetValue(BedsProperty, value); }
    }
    /// <summary>
    /// Register the Beds property so it is known in the class.
    /// </summary>
    public static readonly PropertyData BedsProperty = 
      RegisterProperty("Beds", typeof(ObservableCollection<Models.Bed>));

    #endregion

    #endregion
    #region Commands
    /// <summary>
    /// Gets the AddTable command.
    /// </summary>
    public Command<object> AddTable { get; private set; }
    /// <summary>
    /// Method to invoke when the AddTable command is executed.
    /// </summary>
    /// <param name="parameter">The parameter of the command.</param>
    private void AddTable_Execute(object parameter)
    {
        Tables.Add(new Models.Table(string.Format("Table {0}", _tableIndex++)));
    }
    /// <summary>
    /// Gets the AddBed command.
    /// </summary>
    public Command<object> AddBed { get; private set; }
    /// <summary>
    /// Method to invoke when the AddBed command is executed.
    /// </summary>
    /// <param name="parameter">The parameter of the command.</param>
    private void AddBed_Execute(object parameter)
    {
        Beds.Add(new Models.Bed(string.Format("Bed {0}", _bedIndex++)));
    }
    #endregion
}

如您所见,视图模型只能通过传递 Room 模型对象来构造。了解这种构造方式非常重要。没有空构造函数的原因是,不支持不代表 Room 模型的视图。

在视图模型中,通过使用 Model 属性和 ViewModelToModel 属性来映射 Room 模型属性。最后但同样重要的是,定义了命令,以便能够向 Room 模型添加新的表和床。

现在模型和视图模型都已完全设置好;最后要做的是创建实际的视图。为此,请向项目中添加一个新的 WPF 用户控件。首先要做的是实现代码隐藏,因为这是最容易做的事情

/// <summary>
/// Interaction logic for Room.xaml
/// </summary>
public partial class Room : UserControl<RoomViewModel>
{
    /// <summary>
    /// Initializes a new instance of the <see cref="Room"/> class.
    /// </summary>
    public Room()
    {
        // Initialize component
        InitializeComponent();
    }
}

我们从默认用户控件模板中唯一改变的是,现在用户控件继承自泛型 Catel.Windows.Controls.UserControl<TViewModel> 控件,而不是默认的 System.Windows.Controls.UserControl 控件。然后,用户控件应使用的视图模型作为泛型参数提供。代码隐藏部分就是这样,让我们继续看视图。

最后要做的就是实际的 XAML 视图。为简单起见,实际内容被省略了(它只是一个带有 TextBox 和子项的 ItemsControl 的网格)

<Controls:UserControl 
  x:Class="Catel.Articles._03___MVVM.Examples.NestedUserControls.Room"
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:Controls="clr-namespace:Catel.Windows.Controls;assembly=Catel.Windows"
  xmlns:NestedUserControls=
     "clr-namespace:Catel.Articles._03___MVVM.Examples.NestedUserControls"
  x:TypeArguments="NestedUserControls:RoomViewModel">

    <!-- For the sake of simplicity, the content is left out -->

</Controls:UserControl>

上面 XAML 代码中有几个非常重要的注意事项。第一点是(与代码隐藏一样),基类现在是 Controls:UserControl 而不是 UserControl。然后,还需要设置基类的类型参数。这可以通过使用 x:TypeArguments 属性来完成。类型参数与代码隐藏中使用的视图模型相同。

这就是关于解决“嵌套用户控件”问题所能学到的全部内容。我们已经设置好了模型、视图模型,最后是视图。现在,让我们看看它在屏幕截图中的样子(并注意视图模型的构造时间,它们确实是按需构造的)

红框是刚创建的控件。它显示房间名称、视图模型构造时间以及子对象(在展开器内)。

4.2.4. 视图模型属性的映射

开发自定义用户控件时,您仍然希望使用 MVVM 的强大功能,对吧?使用 Catel,所有这些都是可能的。所有其他框架都要求开发人员手动设置用户控件的数据上下文。或者,关于从视图模型映射用户控件属性,或者反之亦然?

要将自定义用户控件的属性映射到视图模型并返回,开发人员唯一需要做的就是用 ControlToViewModelAttribute 装饰控件的依赖属性。通常,开发人员必须构建逻辑来订阅视图模型和控件的属性更改,然后同步所有差异。由于 ControlToViewModelAttribute,Catel 自带的 UserControl<TViewModel> 会处理这个问题。该属性的使用方式如下

[ControlToViewModel]
public bool MyDependencyProperty
{
    get { return (bool)GetValue(MyDependencyPropertyProperty); }
    set { SetValue(MyDependencyPropertyProperty, value); }
}
// Using a DependencyProperty as the backing store
// for MyDependencyProperty. This enables animation, styling, binding, etc...
public static readonly DependencyProperty MyDependencyPropertyProperty =
    DependencyProperty.Register("MyDependencyProperty", 
    typeof(bool), typeof(MyControl), new UIPropertyMetadata(true));

默认情况下,该属性假定视图模型上的属性名称与用户控件上的属性名称相同。要指定不同的名称,请使用属性构造函数的重载,如下例所示

[ControlToViewModel("MyViewModelProperty")]
public bool MyDependencyProperty

//... (remaining code left out for the sake of simplicity)

首先,这一切看起来都足够好。但是,当控件的当前视图模型被另一个实例替换时会发生什么?或者,如果开发人员只想将值从控件映射到视图模型,但不映射回来怎么办?默认情况下,使用此属性时,视图模型将起主导作用。这意味着一旦视图模型更改,控件的值将被视图模型的值覆盖。如果需要其他行为,则应使用属性的 MappingType 属性

[ControlToViewModel("MyViewModelProperty", 
    MappingType = ControlViewModelModelMappingType.TwoWayControlWins)]
public bool MyDependencyProperty
//... (remaining code left out for the sake of simplicity)

下表详细解释了这些选项

枚举值

描述

TwoWayDoNothing

双向,这意味着当控件或视图模型更新时,它们将更新另一方的值。

使用此值时,当用户控件的视图模型更改时,不会发生任何事情。这样,控件和视图模型的值可能不同。下一个更新的值将更新另一个。

TwoWayControlWins

双向,这意味着当控件或视图模型更新时,它们将更新另一方的值。

使用此值时,当用户控件的视图模型更改时,将使用控件的值,并直接传输到视图模型值。

TwoWayViewModelWins

双向,这意味着当控件或视图模型更新时,它们将更新另一方的值。

使用此值时,当用户控件的视图模型更改时,将使用视图模型的值,并直接传输到控件值。

ControlToViewModel

映射仅从控件到视图模型。

ViewModelToControl

映射仅从视图模型到控件。

5. 附加信息

5.1. 模型或视图模型中的验证

我一直在讨论验证应该发生在模型还是视图模型中。有些人认为验证应该始终在模型中进行,因为您不希望将无效模型持久化到持久化存储中。另一些人则认为模型本身不需要验证,但视图模型所处的状态需要验证。我认为两者都是正确的,我将告诉你原因。

首先,您不希望在持久化存储中有无效的模型。因此,最基本的检查,如类型、范围和必需字段,应在模型中进行验证。但有时,需要比模型提供的更多的限制用户,这时视图模型中的验证就派上用场了。您希望在视图模型中实现(一部分)验证的另一个原因是模型在工作流中的状态。如果您有一个逐步更新模型的工作流,那么在工作流的第一步之后,模型可能无效。但是,您可能想持久化模型,因为用户可能会决定稍后执行后续步骤。您不希望在模型中实现工作流的状态逻辑(如果您这样做了,请尽快摆脱它)。这是视图模型验证再次派上用场的功能。

好消息是,使用 Catel,无论您想要什么,都可以实现。如果您希望模型执行所有验证,那么可以使用 ModelViewModelToModel 属性来实现,这些属性将属性的值和错误直接映射到模型,使视图模型充当视图和模型之间的代理。如果您想在视图模型中完成所有验证,那么您可以实现视图模型中的 ValidateFieldsValidateBusinessRules 方法。而且,如果您想要两者兼顾,就像我一样,那么您可以使用上述技术的组合。

5.2. 对 LLBLGen Pro 的支持

ViewModelBase 完全支持由 LLBLGen Pro 生成的实体。这意味着实体在视图模型中的字段将在初始化时(或在实体定义为模型的情况下,在模型更改时)保存。当视图模型被取消时,实体字段会被回滚;否则,更改将被提交。

重要的是,我们仅在自服务模式下测试了对 LLBLGen Pro 的支持。

5.3. 未来展望?

我们目前正在考虑创建泛型接口来支持 ViewModelBase 中的其他 ORM 映射器。但是,我们需要比较目前可用的几种 ORM 映射器,以便能够创建泛型接口。

6. 历史记录

  • 2011 年 1 月 28 日:添加 IProcessService 说明
  • 2010 年 11 月 25 日:添加文章浏览器和简要摘要
  • 2010 年 11 月 23 日:一些小的文本更改
  • 2010 年 11 月 22 日:初始版本
© . All rights reserved.