在本章中,我们将研究应用程序框架以及它们可以给我们带来的好处。 我们找出通过基类和接口提供此功能之间的差异,并发现将功能构建到我们的框架中的其他方法。 然后,我们将使用这些新发现的知识开始构建我们自己的应用程序框架,以简化我们未来的应用程序开发。 本章最后将检查各种技术,以确保我们的应用程序保持 MVVM 提供的基本关注点分离。
什么是应用框架?
用最简单的术语来说,应用程序框架由类库组成,这些类库共同提供应用程序所需的最常见功能。 通过使用应用程序框架,我们可以大大减少创建应用程序各个部分所需的工作量和时间。 简而言之,它们支持应用程序的未来开发。
在典型的三层应用程序中,框架通常会扩展到应用程序的所有层; 表示层、业务层和数据访问层。 因此,在使用 MVVM 模式的 WPF 应用程序中,我们可以在该模式的所有三个组件中看到应用程序框架的各个方面; 模型、视图模型和视图。
除了减少创建应用程序组件的生产时间和工作量的明显好处外,应用程序框架还提供了许多额外的好处。 典型的应用程序框架促进可重用性,这是面向对象编程(OOP)的核心目标之一。 他们通过提供可用于定义各种应用程序组件的通用接口和/或基类来实现这一点。
通过重用这些应用程序框架接口和基类,我们还在整个应用程序中灌输了统一性和一致性的感觉。 此外,由于这些框架通常提供附加功能或服务,因此应用程序的开发人员在需要此特定功能时可以进一步节省时间。
模块化、可维护性、可测试性和可扩展性等概念也可以通过使用应用程序框架来实现。 这些框架通常能够相互独立地运行各个组件,这非常适合 WPF 和 MVVM 模式。 此外,应用程序框架还可以提供实现模式,以进一步简化构建新应用程序组件的过程。
针对不同的技术创建了不同的框架,WPF 已经有一些公开可用的框架。 有些是相对轻量级的,如 MVVM Light Toolkit 和 WPF 应用程序框架 (WAF),而另一些则是重量级的,如 Caliburn.Micro 和现在开源的 Prism。 虽然您可能在工作中使用过这些框架中的一个或多个,但我们将研究如何创建我们自己的轻量级自定义框架,而不是在本章中研究这些框架,该框架将仅实现我们需要的功能。样的框架,但本章不研究这些,我们将看看如何创建我们自己的轻量级自定义框架,它将实现我们需要的特性。
封装通用功能
任何 WPF 应用程序中最常用的接口可能是 INotifyPropertyChanged
接口,因为正确实现数据绑定需要它。 通过在我们的基类中提供此接口的实现,我们可以避免在每个视图模型类中重复实现它。 因此,它是纳入我们基类的绝佳候选者。 根据我们的要求,有多种不同的方法来实现它,所以让我们首先看一下最基本的:
public virtual event PropertyChangedEventHandler PropertyChanged;
protected virtual void NotifyPropertyChanged(string propertyName)
{
if (PropertyChanged != null)
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
在此实现的所有形式中,我们首先需要声明 PropertyChanged
事件。 该事件将用于通知应用程序中数据绑定值的各种绑定源和目标的更改。 请注意,这是 INotifyPropertyChanged
接口的唯一要求。 我们没有必须实现的 NotifyPropertyChanged
方法,因此您很可能会遇到执行相同功能的不同命名的方法。
当然,如果没有这个方法,仅仅实现事件是没有任何作用的。 此方法的基本思想是,像往常一样,我们首先检查 null,然后引发事件,将引发的类实例作为发送者参数以及在 PropertyChangedEventArgs
中更改的属性名称传递。 我们已经看到,C# 6.0 中的 null 条件运算符为此提供了一种简写符号:
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
请注意,此方法上声明的访问修饰符受到保护,以确保从该基类派生的所有视图模型都可以访问它,而非派生类则不能。 此外,该方法还被标记为虚拟的,以便派生类可以在需要时重写此功能。 在视图模型中,将从如下属性调用此方法:
private string name = string.Empty;
public string Name
{
get { return name; }
set
{
if (name != value)
{
name = value;
NotifyPropertyChanged("Name");
}
}
}
然而,.NET 4.5 中添加了一个新属性,为我们提供了使用此实现的快捷方式。 CallerMemberNameAttribute 类使我们能够自动获取方法调用者的名称,或者更具体地说,在我们的例子中,是调用该方法的属性的名称。 我们可以将它与具有默认值的可选输入参数一起使用,如下所示:
protected virtual void NotifyPropertyChanged( [CallerMemberName]string propertyName = "")
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
然后可以将调用属性简化为:
public string Name
{
get { return name; }
set { if (name != value) { name = value; NotifyPropertyChanged(); } }
}
此时值得注意的是,在 .NET 4.5.3 中,引入了调用此方法的最基本实现的另一项改进。 nameof 运算符还使我们能够避免使用字符串来传递属性名称,因为传递字符串可能容易出错。 该运算符基本上在编译时将属性、变量或方法的名称转换为字符串,因此最终结果与传递字符串完全相同,但在重命名定义时不易出错。 以上面的属性为例,我们看看这个运算符是如何使用的:
NotifyPropertyChanged(nameof(Name));
我们还可以使用其他技巧。 例如,我们经常需要通知框架多个属性值同时发生了变化。 想象一个场景,其中我们有两个名为 Price
和 Quantity
的属性,以及第三个名为 Total
的属性。 正如您可以想象的,Total
属性的值将来自于 Price
值乘以 Quantity
值的计算结果:
public decimal Total
{
get { return Price * Quantity; }
}
但是,这个属性没有setter
,那么我们应该从哪里调用NotifyPropertyChanged
方法呢? 答案很简单。 我们需要从两个组成属性设置器中调用它,因为它们都会影响该属性的结果值。
传统上,我们必须为每个组成属性调用一次 NotifyPropertyChanged
方法,为 Total
属性调用一次。 但是,可以重写此方法的实现,使我们能够在一次调用中将多个属性名称传递给它。 为此,我们可以使用 params
关键字来启用任意数量的输入参数:
protected void NotifyPropertyChanged(params string[] propertyNames)
{
if (PropertyChanged != null)
{
foreach (string propertyName in propertyNames)
{
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
}
使用params
关键字时,我们需要声明一个数组类型的输入参数。 然而,这个数组仅仅保存输入参数,我们在调用这个方法时不需要提供数组。 相反,我们提供任意数量的相同类型的输入参数,它们将被隐式添加到数组中。 回到我们的示例,这使我们能够像这样调用该方法:
private decimal price = 0M;
public decimal Price
{
get { return price; }
set
{
if (price != value)
{
price = value;
NotifyPropertyChanged(nameof(Price), nameof(Total));
}
}
}
因此,我们有多种不同的方式来实现此方法,具体取决于什么适合我们的要求。 我们甚至可以添加一些方法的重载,为我们框架的用户提供更多选择。 稍后我们将看到此方法的进一步增强,但现在让我们看看我们的 BaseViewModel
类到目前为止可能是什么样子:
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace CompanyName.ApplicationName.ViewModels
{
public class BaseViewModel : INotifyPropertyChanged
{
#region INotifyPropertyChanged Members
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void NotifyPropertyChanged(params string[] propertyNames)
{
if (PropertyChanged != null)
{
foreach (string propertyName in propertyNames)
{
PropertyChanged(this,new PropertyChangedEventArgs(propertyName));
}
}
}
protected virtual void NotifyPropertyChanged(
[CallerMemberName]string propertyName = "")
{
PropertyChanged?.Invoke(this,
new PropertyChangedEventArgs(propertyName));
}
#endregion
}
}
总而言之,我们从声明单个事件的接口开始。 接口本身不提供任何功能,事实上,我们作为实现者必须以 NotifyPropertyChanged
方法的形式提供功能,并在每次属性值更改时调用该方法。 但这样做的回报是 UI 控件正在侦听并响应这些事件,因此,通过实现此接口,我们获得了这种额外的数据绑定功能。
但是,我们可以通过多种不同的方式在应用程序框架中提供功能。 两种主要方式是通过使用基类和接口。 这两种方法之间的主要区别与框架的用户为了创建各种应用程序组件而必须完成的开发量有关。
当我们使用接口时,我们基本上是通过提供自己的实现来提供开发人员必须遵守的合同。 但是,当我们使用基类时,我们可以为它们提供该实现。 因此,一般来说,基类提供现成的功能,而接口则依赖开发人员自己提供部分或全部功能。
我们刚刚看到了在视图模型基类中实现接口的示例。 现在让我们看看我们还可以在其他框架基类中封装哪些内容,并比较基类和接口中提供特性或功能之间的差异。。 现在让我们将注意力转向我们的数据模型类。
在基类中
我们已经看到,在 WPF 应用程序中,我们必须在视图模型基类中实现 INotifyPropertyChanged
接口。 同样,我们还需要在数据模型基类中进行类似的实现。 请记住,当此处提到数据模型时,我们正在讨论与第 1 章“使用 WPF 的更智能方式”中的第二个应用程序结构示例中的视图模型属性和功能相结合的业务模型类。
所有这些 DataModel
类都需要扩展其基类,因为它们都需要访问其 INotifyPropertyChanged
实现。 随着本书各章的进展,我们将看到越来越多的原因为什么我们需要为数据模型和视图模型使用单独的基类。 例如,假设我们想要为这些数据模型提供一些简单的审核属性,并研究我们的基类可能是什么样子::
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace CompanyName.ApplicationName.DataModels
{
public class BaseDataModel : INotifyPropertyChanged
{
private DateTime createdOn;
private DateTime? updatedOn;
private User createdBy, updatedBy;
public DateTime CreatedOn
{
get { return createdOn; }
set { createdOn = value;
NotifyPropertyChanged(); }
}
public User CreatedBy
{
get { return createdBy; }
set { createdBy = value;
NotifyPropertyChanged(); }
}
public DateTime? UpdatedOn
{
get { return updatedOn; }
set { updatedOn = value;
NotifyPropertyChanged(); }
}
public User UpdatedBy
{
get { return updatedBy; }
set { updatedBy = value;
NotifyPropertyChanged(); }
}
#region INotifyPropertyChanged Members
...
#endregion
}
}
在这里,我们看到了审核属性,以及我们之前看到的隐藏的 INotifyPropertyChanged
实现。 现在,让我们保持与 BaseViewModel
类的实现相同。 请注意,使用这个特定的基类将导致所有派生类都可以访问这些属性,无论它们是否需要它们。
然后,我们可能决定声明另一个基类,以便我们可以拥有一个提供对 INotifyPropertyChanged
接口的实现的访问的基类,以及一个扩展该基类并添加前面所示的新可审核属性的基类。 这样,所有派生类都可以使用 INotifyPropertyChanged
接口实现,并且也可以从第二个基类派生需要可审核属性的类:
对于这个基本示例,我们似乎已经解决了我们的问题。 如果这些可审核属性是我们想要提供给派生类的唯一属性,那么情况不会那么糟糕。 然而,一般的框架通常提供的远不止于此。
现在假设我们想要提供一些基本的撤消功能。 我们将在本章后面看到一个例子,但现在我们将保持简单。 在没有实际指定这个新基类所需成员的情况下,让我们先考虑一下这一点。
现在我们遇到的情况是,我们已经有两个不同的基类,并且我们想要提供一些进一步的功能。 我们应该在哪里声明我们的新属性? 我们可以从两个现有基类中的一个或间接派生,如下图所示,以便创建这个新的可同步基类:
所以现在,我们可以有四个不同的基类,使用我们框架的开发人员可以扩展它们。 对于到底需要扩展哪个基类可能会有些混乱,但总的来说,这种情况仍然是可以管理的。 但是,想象一下,如果我们想在一个或多个基类级别中提供一些附加属性或功能。
为了启用这些基类的每种功能组合,我们最终可能会得到多达八个单独的基类。 我们提供的每个附加功能级别都会使我们拥有的基类总数增加一倍,或者意味着开发人员有时必须从具有他们不需要的功能或属性的基类派生。 现在我们已经发现了使用基类的潜在问题,让我们看看声明接口是否可以帮助解决这种情况。
通过接口
回到我们的审计示例,我们可以在接口中声明这些属性。 让我们看看这可能是什么样子:
using System;
namespace CompanyName.ApplicationName.DataModels.Interfaces
{
public interface IAuditable
{
DateTime CreatedOn { get; set; }
User CreatedBy { get; set; }
DateTime? UpdatedOn { get; set; }
User UpdatedBy { get; set; }
}
}
现在,如果开发人员需要这些属性,他们可以实现这个接口以及扩展数据模型基类:
在这里插入图片描述
现在让我们在代码中看一个这样的例子:
using System;
using CompanyName.ApplicationName.DataModels.Interfaces;
namespace CompanyName.ApplicationName.DataModels
{
public class Invoice : BaseDataModel, IAuditable
{
private DateTime createdOn;
private DateTime? updatedOn;
private User createdBy, updatedBy;
public DateTime CreatedOn
{
get { return createdOn; }
set { createdOn = value;
NotifyPropertyChanged(); }
}
public User CreatedBy
{
get { return createdBy; }
set { createdBy = value;
NotifyPropertyChanged(); }
}
public DateTime? UpdatedOn
{
get { return updatedOn; }
set { updatedOn = value;
NotifyPropertyChanged(); }
}
public User UpdatedBy
{
get { return updatedBy; }
set { updatedBy = value;
NotifyPropertyChanged(); }
}
}
}
最初,这似乎是一个更好的方法,但让我们继续研究与基类相同的场景。 现在假设我们希望使用接口提供相同的基本撤消功能。 我们实际上并没有调查这需要哪些成员,但它需要属性和方法。
这就是界面方法开始出现问题的地方。 我们可以确保 ISynchronization
接口的实现者具有特定的属性和方法,但我们无法控制他们对这些方法的实现。 为了提供撤消更改的能力,我们需要提供这些方法的实际实现,而不仅仅是所需的脚手架。
如果这由开发人员在每次使用接口时实现,他们可能无法正确实现它,或者可能他们可能在不同的类中以不同的方式实现它并破坏应用程序的一致性。 因此,为了实现某些功能,我们似乎确实需要使用某种基类。
然而,我们还有第三种选择,涉及两种方法的混合。 我们可以在基类中实现一些功能,但我们可以向它们添加该类型的属性,而不是从中派生数据模型类,以便它们仍然可以访问其公共成员。
然后我们可以声明一个接口,它只具有这个新基类类型的单个属性。 通过这种方式,我们可以自由地将不同基类的不同功能添加到需要它们的类中。 让我们看一个例子:
public interface IAuditable
{
Auditable Auditable { get; set; }
}
此 Auditable
类将具有与前面代码中所示的先前 IAuditable
接口中的属性相同的属性。 新的 IAuditable
接口将由数据模型类通过简单地声明 Auditable
类型的属性来实现:
public class User : IAuditable
{
private Auditable auditable;
public Auditable Auditable
{
get { return auditable; }
set { auditable = value; }
}
...
}
例如,框架可以使用它来输出每个用户的名称以及将其创建到报告中的时间。 在下面的示例中,我们使用 C# 6.0 中引入的插值字符串语法来构造字符串。 它类似于 string.Format
方法,但方法调用替换为 $ 符号,并且数字格式项替换为其相关值:
foreach (IAuditable user in Users)
{
Report.AddLine($"Created on {user.Auditable.CreatedOn}" by {user.Auditable.CreatedBy.Name});
}
最有趣的是,由于该接口可以由许多不同类型的对象实现,因此前面的代码也可以与不同类型的对象一起使用。 请注意这个细微的差别:
List<IAuditable> auditableObjects = GetAuditableObjects();
foreach (IAuditable user in auditableObjects)
{
Report.AddLine($"Created on {user.Auditable.CreatedOn}" by
{user.Auditable.CreatedBy.Name});
}
值得指出的是,这种处理不同类型对象的有用能力不仅限于接口。 这也可以通过基类轻松实现。 想象一下一个视图,它使最终用户能够编辑许多不同类型的对象。
如果我们将一个名为 PropertyChanges
的属性(返回已更改属性的详细信息)添加到 BaseSynchronizedDataModel
类中(我们稍后将在构建自定义应用程序框架部分中看到),我们可以使用这段非常相似的代码来显示每个更改的确认信息 对象返回给用户:
List<BaseSynchronizableDataModel> baseDataModels = GetBaseDataModels();
foreach (BaseSynchronizableDataModel baseDataModel in baseDataModels)
{
if (baseDataModel.HasChanges)
FeedbackManager.Add(baseDataModel.PropertyChanges);
}
将预打包功能封装到数据模型类中时,我们有多种选择。 到目前为止,我们研究的每种方法都有优点和缺点。 如果我们确定需要在每个数据模型类中都有一些预先编写的功能,例如 INotifyPropertyChanged
接口的功能,那么我们可以简单地将其封装在基类中,并从中派生所有模型类。
如果我们只是希望我们的模型具有可以从框架的其他部分调用的某些属性或方法,但不关心实现,那么我们可以使用接口。 如果我们想要将这两种想法结合起来,那么我们可以同时使用这两种方法来实现解决方案。 我们需要选择最适合当前要求的解决方案。
使用扩展方法
还有另一种方法可以为应用程序的开发人员提供附加功能,在第 2 章“调试 WPF 应用程序”中研究应用程序结构时提到了该方法。 它是通过使用扩展方法来实现的。 如果您不熟悉这个令人惊叹的 .NET 功能,扩展方法使我们能够编写可用于我们未创建的对象的方法。
在这个阶段,值得指出的是,我们通常不会为已声明的类编写扩展方法。 这有两个主要原因。 首先,我们创建了这些类,因此我们可以访问它们的源代码,因此可以直接在这些类中声明新方法。
第二个原因是,大多数其他项目(包括我们的 DataModels
项目)都会添加对我们的 Extensions
项目的引用,以便它们都可以利用额外的功能。 因此,我们不能将对任何其他项目的引用添加到扩展项目中,因为这会创建循环依赖项。
您可能已经了解了扩展方法,尽管可能是无意的,因为大多数 LINQ 方法都是扩展方法。 一旦声明,它们就可以像我们正在扩展的各种类中声明的普通方法一样使用,尽管它们通过在 Visual Studio IntelliSense 显示中具有不同的图标来区分:
声明它们时的基本原则是拥有一个静态类,其中每个方法都有一个额外的输入参数,以 this
关键字为前缀,表示要扩展的对象。 请注意,这个额外的输入参数必须首先在参数列表中声明,并且在调用对象实例上的方法时,它在 IntelliSense 中不可见。
扩展方法被声明为静态方法,但通常使用实例方法语法进行调用。 一个简单的例子应该有助于澄清这种情况。 假设我们希望能够对集合中的每个项目调用一个方法。 事实上,我们将在本章后面看到在 BaseSynchronizedCollection
类中使用此方法的示例,但是现在,让我们看看如何做到这一点:
using System;
using System.Collections.Generic;
namespace CompanyName.ApplicationName.Extensions
{
public static class IEnumerableExtensions
{
public static void ForEach<T>(this IEnumerable<T> collection,
Action<T> action)
{
foreach (T item in collection) action(item);
}
}
}
在这里,我们看到 this
输入参数指定调用此扩展方法的目标类型的实例。 请记住,这不会出现在 Visual Studio 中 IntelliSense 的参数列表中,除非通过静态类本身调用它,如以下代码所示:
IEnumerableExtensions.ForEach(collection, i => i.RevertState());
在此方法中,我们只需迭代集合项,调用由操作输入参数指定的操作,并将每个项作为其参数传递。 将 using
指令添加到 CompanyName.ApplicationName.Extensions
命名空间后,让我们看看这个方法更常见的调用方式:
collection.ForEach(i => i.PerformAction());
所以,您现在可以看到扩展方法的强大功能以及它们可以给我们带来的好处。 如果 .NET Framework 中的某个类尚未提供我们想要的某些功能,那么我们可以简单地添加它。 就拿下一个例子来说吧。
这是现有 LINQ 扩展方法中非常缺少的扩展方法。 与其他 LINQ 方法一样,该方法也适用于 IEnumerable<T>
接口,因此也适用于扩展该接口的任何集合:
public static IEnumerable<TSource> DistinctBy<TSource, TKey>(
this IEnumerable<TSource> source, Func<TSource, TKey> keySelector)
{
HashSet<TKey> keys = new HashSet<TKey>();
foreach (TSource element in source)
{
if (keys.Add(keySelector(element))) yield return element;
}
}
我们首先看一下这个方法的声明。 我们可以看到我们的源集合将是 TSource
类型。 请注意,这与泛型类型参数被命名为 T
完全相同,就像在我们的其他示例中一样,只不过这提供了有关此类型参数的使用的更多详细信息。 此命名来自 Enumerable.OrderBy<TSource, TKey>
方法,其中类型 TSource
参数代表我们的源集合。
接下来,我们注意到方法名称后面带有两个泛型类型参数; 首先是 TSource
参数,然后是 TKey 参数。 这是因为我们需要两个泛型类型参数作为 FuncSource
类型的输入参数,TKey>。 如果您不熟悉 Func<T, TResult>
委托(正如 Microsoft 所称),它只是封装具有 T
类型的单个输入参数并返回 TResult
类型的值(或者在我们的示例中为 TKey
)的任何方法 。
“我们为什么要使用这个 Func<T, TResult> 委托?”,我听到你问。 嗯,这确实很简单; 使用此类,我们可以为开发人员提供与源集合中的对象类型相同的对象,并能够选择该类的成员,特别是他们想要执行不同查询的属性。 在查看此方法的其余部分之前,让我们先看看它的使用情况:
IEnumerable<User> distinctUsers = Users.DistinctBy(u => u.Id);
让我们假设我们有一个 User 对象的集合,其中包含所有购买的物品。 如果他们购买了不止一件商品,则该集合可能多次包含相同的 User 对象。 现在,让我们想象一下,我们想要从原始集合中编译出唯一用户的集合,以免向订购多个商品的人发送多张账单。 此方法将为每个不同的 Id 值返回一个成员。
回顾此方法的源代码,User 类表示 TSource 参数,这在示例中的 Lambda 表达式中显示为 u 输入参数。 TKey 参数由开发人员选择的类成员的类型确定,在本例中是由 Guid Id 值确定。 为了更清楚,这个例子的写法可能略有不同:
IEnumerable<User> distinctUsers = Users.DistinctBy((User user) => user.Id);
所以,我们的 FuncSource, TKey> 可以在这里看到,带有一个 User 输入参数和一个 Guid 返回值。 现在,让我们关注我们方法的魔力。 在我们的例子中,我们看到 Guid 类型的 HashSet 正在被初始化。 这种类型的集合对此方法至关重要,因为它只允许添加唯一值。
接下来,我们迭代源集合(在本例中为 User 类型),并尝试将集合中每个项目的相关属性值添加到 HashSet 中。 在我们的例子中,我们将每个 User 对象的身份值添加到此 HashSet 中。
如果标识值是唯一的并且 HashSet.Add 方法返回 true,我们将产生或从源集合中返回该项目。 第二次以及随后的每次读取已使用的 Id 值时,都会被拒绝。 这意味着此方法仅返回具有唯一标识值的第一个项目。 请注意,在此示例中,我们对购买不感兴趣,而是对进行购买的唯一用户感兴趣。
我们现在已经成功创建了我们自己的 LINQ 风格的扩展方法。 然而,并非我们所有的扩展方法都需要如此具有开创性。 通常,它们可以用来简单地封装一些常用的功能。
在某种程度上,我们可以将它们用作简单便捷的方法。 看一下本章后面的“使用转换器”部分中使用的以下示例:
using System;
using System.ComponentModel;
using System.Reflection;
namespace CompanyName.ApplicationName.Extensions
{
public static class EnumExtensions
{
public static string GetDescription(this Enum value)
{
FieldInfo fieldInfo = value.GetType().GetField(value.ToString());
if (fieldInfo == null) return Enum.GetName(value.GetType(), value);
DescriptionAttribute[] attributes = (DescriptionAttribute[])
fieldInfo.GetCustomAttributes(typeof(DescriptionAttribute), false);
if (attributes != null && attributes.Length > 0)
return attributes[0].Description;
return Enum.GetName(value.GetType(), value);
}
}
}
在此方法中,我们尝试获取与值输入参数提供的相关枚举的实例相关的 FieldInfo 对象。 如果尝试失败,我们只需返回特定实例的名称。 但是,如果成功,我们将使用该对象的 GetCustomAttributes 方法,传递 DescriptionAttribute 类的类型来检索属性数组。
如果我们在这个特定枚举实例的 DescriptionAttribute 中声明了一个值,那么它将始终是属性数组中的第一项。 如果我们没有设置值,那么数组将为空,我们将返回实例的名称。 请注意,由于我们在此方法中使用了 Enum 基类,因此我们可以在任何枚举类型上调用此方法。
创建这些方法时,应该注意的是,不需要将它们放入按类型拆分的单独类中,就像我们在这里所做的那样。 也没有指定的命名约定,事实上,将所有扩展方法放入一个类中也是完全可行的。 但是,如果我们有大量特定类型的扩展方法,那么进行这种分离可以帮助维护。
在继续之前,让我们看一下这些扩展方法的最后一个示例。 扩展方法最有用的特征之一是能够向 .NET Framework 中的现有类添加新的或缺失的功能。 例如,让我们看看如何复制 Linq 并为 IEnumerable 类定义一个简单的 Count 方法:
public static int Count(this IEnumerable collection)
{
int count = 0;
foreach (object item in collection) count++;
return count;
}
正如我们所看到的,这种方法几乎不需要解释。 它实际上只是计算 IEnumerable 集合中的项目数并返回该值。 尽管它很简单,但事实证明它很有用,我们将在后面的示例中看到。 现在我们已经研究了扩展方法,让我们将注意力转向在我们的框架中构建更多功能的另一种方法,这次重点关注视图组件。
在 UI 控件中
在应用程序框架中包含功能的另一种常见方法是将其封装到自定义控件中。 这样做时,我们可以使用依赖属性公开所需的功能,同时隐藏实现细节。 这也是提高整个应用程序的可重用性和一致性的另一种好方法。 让我们看一下包装 System.Windows.Forms.FolderBrowserDialog 控件功能的 UserControl 的简单示例:
<UserControl
x:Class="CompanyName.ApplicationName.Views.Controls.FolderPathEditField"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Controls="clrnamespace:CompanyName.ApplicationName.Views.Controls">
<TextBox Name="FolderPathTextBox"
Text="{Binding FolderPath, RelativeSource={RelativeSource
AncestorType={x:Type Controls:FolderPathEditField}}, FallbackValue='',
UpdateSourceTrigger=PropertyChanged}" Cursor="Arrow"
PreviewMouseLeftButtonUp="TextBox_PreviewMouseLeftButtonUp" />
</UserControl>
这个简单的 UserControl 仅包含一个文本框,其 Text 属性数据绑定到在控件后面的代码中声明的FolderPath 依赖属性。 请记住,在使用 MVVM 时,出于此目的使用 UserControl 背后的代码是完全可以接受的。 请注意,我们在这里使用了RelativeSource 绑定,因为尚未对此控件的DataContext 属性进行任何设置。 我们将在第 4 章“精通数据绑定”中了解有关数据绑定的更多信息,但现在让我们继续。
您可能会注意到,我们在后面的代码中附加了 PreviewMouseLeftButtonUp 事件的处理程序,并且由于那里没有使用与业务相关的代码,因此在使用 MVVM 时这也是完全可以接受的。 这里唯一值得注意的代码是我们将 Cursor 属性设置为当用户将鼠标悬停在我们的控件上时显示箭头。 现在让我们看一下 UserControl 背后的代码,看看功能是如何封装的:
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using FolderBrowserDialog = System.Windows.Forms.FolderBrowserDialog;
namespace CompanyName.ApplicationName.Views.Controls
{
public partial class FolderPathEditField : UserControl
{
public FolderPathEditField()
{
InitializeComponent();
}
public static readonly DependencyProperty FolderPathProperty =
DependencyProperty.Register(nameof(FolderPath),
typeof(string), typeof(FolderPathEditField),
new FrameworkPropertyMetadata(string.Empty,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
public string FolderPath
{
get { return (string)GetValue(FolderPathProperty); }
set { SetValue(FolderPathProperty, value); }
}
public static readonly DependencyProperty OpenFolderTitleProperty =
DependencyProperty.Register(nameof(OpenFolderTitle),
typeof(string), typeof(FolderPathEditField),
new FrameworkPropertyMetadata(string.Empty,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
public string OpenFolderTitle
{
get { return (string)GetValue(OpenFolderTitleProperty); }
set { SetValue(OpenFolderTitleProperty, value); }
}
private void TextBox_PreviewMouseLeftButtonUp(object sender,
MouseButtonEventArgs e)
{
if (((TextBox)sender).SelectedText.Length == 0 &&
e.GetPosition(this).X <= ((TextBox)sender).ActualWidth -
SystemParameters.VerticalScrollBarWidth)
ShowFolderPathEditWindow();
}
private void ShowFolderPathEditWindow()
{
string defaultFolderPath = string.IsNullOrEmpty(FolderPath) ?
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)
: FolderPath;
string folderPath = ShowFolderBrowserDialog(defaultFolderPath);
if (string.IsNullOrEmpty(folderPath)) return;
FolderPath = folderPath;
}
private string ShowFolderBrowserDialog(string defaultFolderPath)
{
using (FolderBrowserDialog folderBrowserDialog =
new FolderBrowserDialog())
{
folderBrowserDialog.Description = OpenFolderTitle;
folderBrowserDialog.ShowNewFolderButton = true;
folderBrowserDialog.SelectedPath = defaultFolderPath;
folderBrowserDialog.ShowDialog();
return folderBrowserDialog.SelectedPath;
}
}
}
}
我们从 using 指令开始,并查看 using 别名指令的示例。 在本例中,我们不想为 System.Windows.Forms 程序集添加普通的 using 指令,因为它包含许多与 UI 相关的类,这些类的名称与所需 System.Windows 程序集中的名称冲突。
为了避免这些冲突,我们可以为我们有兴趣从该程序集中使用的单一类型创建一个别名。 为了澄清这一点,Microsoft 决定不在 System.Windows 程序集中重新发明轮子,或者在本例中为 FoldBrowserDialog 控件,因此我们需要添加对 System.Windows.Forms 程序集的引用并使用其中的引用 。
查看此类,我们发现大部分代码都用于声明控件的依赖属性。 我们拥有的FolderPath 属性将保存从Windows.Forms 控件中选择的文件夹的文件路径,以及OpenFolderTitle 属性将在显示时填充FolderBrowserDialog 窗口的标题栏。
接下来,我们看到 TextBox_PreviewMouseLeftButtonUp 事件处理程序,它处理控件中单个 TextBox 元素的 PreviewMouseLeftButtonUp 事件。 在此方法中,我们首先验证用户是否没有从 TextBox 控件中选择文本或滚动文本,然后,如果为 true,则调用 ShowFolderPathEditWindow 方法。
为了验证用户没有选择文本,我们只需检查 TextBox 控件的 SelectedText 属性的长度。 为了确认用户没有滚动 TextBox 控件,我们将用户单击的相对水平位置与 TextBox 元素的长度减去其垂直滚动条的宽度进行比较,以确保他们的鼠标没有位于滚动条上方 ,如果存在的话。
ShowFolderPathEditWindow 方法首先准备显示 Windows.Forms 控件。 它使用Environment.GetFolderPath 方法和Environment.SpecialFolder.MyDocuments 枚举将defaultFolderPath 变量设置为FolderPath 属性的当前值(如果已设置)或当前用户的Documents 文件夹。
然后,它调用 ShowFolderBrowserDialog 方法来启动实际的FolderBrowserDialog 控件并检索选定的文件夹路径。 如果选择了有效的文件夹路径,我们将直接将其值设置为数据绑定的FolderPath属性,但请注意,我们可以通过其他方式设置它。
将 ICommand 属性添加到我们的控件中以返回选定的文件夹路径而不是使用此直接分配将非常容易。 当我们不希望立即设置数据绑定值时,这可能很有用; 例如,如果在子窗口中使用该控件,则需要单击确认按钮才能更新数据绑定值。
ShowFolderBrowserDialog 方法将FolderBrowserDialog 类的使用包装在using 语句中,以确保使用后将其释放。 在设置实际的FolderBrowserDialog 控件时,它使用defaultFolderPath 变量和OpenFolderTitle 属性。 请注意,此 OpenFolderTitle 属性只是为了演示如何从控件中的 FolderBrowserDialog 元素公开所需的属性。 通过这种方式,我们可以将Windows.Forms控件的使用和程序集封装在我们的控件内。
请注意,我们可以添加额外的依赖属性,以使框架的用户能够进一步控制FolderBrowserDialog 控件中的设置。 在这个基本示例中,我们只是为FolderBrowserDialog.ShowNewFolderButton 属性硬编码了一个正值,但我们可以将其公开为另一个属性。
我们还可以添加一个浏览按钮,甚至可能添加一个清除按钮来清除选定的文件夹值。 然后,我们可以添加额外的 bool 依赖属性来控制是否应显示这些按钮。 我们可以通过许多其他方法来改进此控件,但它仍然演示了如何将功能封装到视图组件中。 在下一节中,我们将看到另一种与视图相关的方法来捕获小功能片段。
转换器
转换器是我们在框架中打包有用功能的另一种方式。 我们已经在第 2 章“调试 WPF 应用程序”中看到了 IValueConverter 接口的一个有用示例,虽然这是一个非常简单的示例,但转换器实际上可以非常通用。
早在 Microsoft 推出 BooleanToVisibilityConverter 类之前,开发人员就必须创建自己的版本。 我们经常需要在 UIElement.Visibility 枚举与各种不同类型之间进行转换,因此最好从可以为多个转换器类提供服务的 BaseVisibilityConverter 类开始。 让我们看看这意味着什么:
using System.Windows;
using System.Windows.Data;
namespace CompanyName.ApplicationName.Converters
{
public abstract class BaseVisibilityConverter
{
public enum FalseVisibility { Hidden, Collapsed }
protected Visibility FalseVisibilityValue { get; set; } = Visibility.Collapsed;
public FalseVisibility FalseVisibilityState
{
get { return FalseVisibilityState == Visibility.Collapsed ?
FalseVisibility.Collapsed : FalseVisibility.Hidden; }
set { FalseVisibilityState = value == FalseVisibility.Collapsed ?
Visibility.Collapsed : Visibility.Hidden; }
}
public bool IsInverted { get; set; }
}
}
此转换器需要一个值来表示可见值,并且由于 UIElement.Visibility 枚举中只有一个对应值,因此该值显然是 Visibility.Visible 实例。 它还需要一个单一的值来表示不可见的值。
因此,我们使用 UIElement.Visibility 枚举中的两个相应值和 FalseVisibilityValue 属性声明 FalseVisibility 枚举,以使用户能够指定哪个值应代表 false 状态。 请注意,最常用的 Visibility.Collapsed 值被设置为默认值。
用户可以在使用控件时设置 FalseVisibilityState 属性,这会在内部设置受保护的 FalseVisibilityValue 属性。 最后,我们看到不可或缺的 IsInverted 属性,可以选择使用它来反转结果。 让我们看看 BoolToVisibilityConverter 类现在是什么样子:
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace CompanyName.ApplicationName.Converters
{
[ValueConversion(typeof(bool), typeof(Visibility))]
public class BoolToVisibilityConverter : BaseVisibilityConverter,
IValueConverter
{
public object Convert(object value, Type targetType,
object parameter, CultureInfo culture)
{
if (value == null || value.GetType() != typeof(bool))
return DependencyProperty.UnsetValue;
bool boolValue = IsInverted ? !(bool)value :(bool)value;
return boolValue ? Visibility.Visible : FalseVisibilityValue;
}
public object ConvertBack(object value, Type targetType,
object parameter, CultureInfo culture)
{
if (value == null || value.GetType() != typeof(Visibility))
return DependencyProperty.UnsetValue;
if (IsInverted) return (Visibility)value != Visibility.Visible;
return (Visibility)value == Visibility.Visible;
}
}
}
我们首先在 ValueConversion 属性中指定转换器实现中涉及的数据类型。 这有助于工具了解转换器中使用的类型,同时也让我们框架的用户清楚地了解。 接下来,我们扩展 BaseVisibilityConverter 基类并扩展所需的 IValueConverter 接口。
在 Convert 方法中,我们首先检查值输入参数的有效性,如果有效,我们将其转换为 bool 变量,同时考虑 IsInverted 属性设置。 对于无效输入值,我们返回 DependencyProperty.UnsetValue 值。 最后,我们将此 bool 变量的输出值解析为 Visibility.Visible 实例或 FalseVisibilityValue 属性的值。
在 ConvertBack 方法中,我们还首先检查值输入参数的有效性。 对于无效的输入值,我们再次返回 DependencyProperty.UnsetValue 值,否则我们输出一个 bool 值,该值指定 Visibility 类型的输入参数是否等于 Visibility.Visible 实例,同时再次考虑 IsInverted 属性的值。
请注意,使用 IsInverted 属性使用户能够指定当数据绑定 bool 值为 false 时元素应变得可见。 当我们希望一个对象在特定条件下可见,而另一个对象在相同条件下隐藏时,这非常有用。 我们可以像这样从此类声明两个转换器:
xmlns:Converters="clr-namespace:CompanyName.ApplicationName.Converters;
assembly=CompanyName.ApplicationName.Converters"
...
<Converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<Converters:BoolToVisibilityConverter
x:Key="InvertedBoolToVisibilityConverter" IsInverted="True" />
如前所述,我们经常需要在各种不同类型的 UIElement.Visibility 枚举之间进行转换。 现在让我们看一个与 Enum 类型相互转换的示例。 原理与上一个示例相同,其中单个数据绑定值表示 Visibility.Visible 实例,所有其他值表示隐藏或折叠状态:
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace CompanyName.ApplicationName.Converters
{
[ValueConversion(typeof(Enum), typeof(Visibility))]
public class EnumToVisibilityConverter : BaseVisibilityConverter,
IValueConverter
{
public object Convert(object value, Type targetType,
object parameter, CultureInfo culture)
{
if (value == null || (value.GetType() != typeof(Enum) &&
value.GetType().BaseType != typeof(Enum)) ||
parameter == null) return DependencyProperty.UnsetValue;
string enumValue = value.ToString();
string targetValue = parameter.ToString();
bool boolValue = enumValue.Equals(targetValue,
StringComparison.InvariantCultureIgnoreCase);
boolValue = IsInverted ? !boolValue : boolValue;
return boolValue ? Visibility.Visible : FalseVisibilityValue;
}
public object ConvertBack(object value, Type targetType,
object parameter, CultureInfo culture)
{
if (value == null || value.GetType() != typeof(Visibility) ||
parameter == null) return DependencyProperty.UnsetValue;
Visibility usedValue = (Visibility)value;
string targetValue = parameter.ToString();
if (IsInverted && usedValue != Visibility.Visible)
return Enum.Parse(targetType, targetValue);
else if (!IsInverted && usedValue == Visibility.Visible)
return Enum.Parse(targetType, targetValue);
return DependencyProperty.UnsetValue;
}
}
}
同样,我们首先在 ValueConversion 属性中指定转换器实现中涉及的数据类型。 在 Convert 方法中,我们首先检查 value 输入参数的有效性,如果有效,我们将其转换为值的字符串表示形式。 这个特定的类使用参数输入参数来传递将表示可见值的指定枚举实例,因此它被设置为字符串形式的 targetValue 变量。
然后,我们通过将当前枚举实例与目标实例进行比较来创建一个布尔值。 一旦我们有了 bool 值,最后两行就会复制 BoolToVisibilityConverter 类中的值。
ConvertBack 方法的实现有些不同。 从逻辑上讲,我们无法为隐藏可见性返回正确的枚举实例,因为它可以是除通过参数输入参数传递的可见值之外的任何值。
因此,只有当元素可见且 IsInverted 属性为 false,或者元素不可见且 IsInverted 属性为 true 时,我们才能返回指定值。 对于所有其他输入值,我们只需返回 DependencyProperty.UnsetValue 属性即可表明没有值。
转换器可以做的另一件非常有用的事情是将单个枚举实例转换为特定图像。 让我们看一个与 FeedbackManager 相关的示例,或者更准确地说,与显示的 Feedback 对象相关的示例。 每个 Feedback 对象都可以具有由 FeedbackType 枚举指定的特定类型,因此让我们首先看一下:
namespace CompanyName.ApplicationName.DataModels.Enums
{
public enum FeedbackType
{
None = -1,
Error,
Information,
Question,
Success,
Validation,
Warning
}
}
为了实现这项工作,我们显然需要为每个枚举实例提供合适的图像,但 None 实例除外。 我们的图像将驻留在启动项目根文件夹中名为 Images 的文件夹中:
using CompanyName.ApplicationName.DataModels.Enums;
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
namespace CompanyName.ApplicationName.Converters
{
[ValueConversion(typeof(FeedbackType), typeof(ImageSource))]
public class FeedbackTypeToImageSourceConverter : IValueConverter
{
public object Convert(object value, Type targetType,
object parameter, CultureInfo culture)
{
if (!(value is FeedbackType feedbackType) ||
targetType != typeof(ImageSource))
return DependencyProperty.UnsetValue;
string imageName = string.Empty;
switch ((FeedbackType)value)
{
case FeedbackType.None: return null;
case FeedbackType.Error: imageName = "Error_16"; break;
case FeedbackType.Success: imageName = "Success_16"; break;
case FeedbackType.Validation:
case FeedbackType.Warning: imageName = "Warning_16"; break;
case FeedbackType.Information: imageName = "Information_16"; break;
case FeedbackType.Question: imageName = "Question_16"; break;
default: return DependencyProperty.UnsetValue;
}
return $"pack://application:,,,/CompanyName.ApplicationName;
component/Images/{ imageName }.png";
}
public object ConvertBack(object value, Type targetType,
object parameter, CultureInfo culture)
{
return DependencyProperty.UnsetValue;
}
}
}
再次,我们首先在 ValueConversion 属性中指定转换器涉及的数据类型。 在 Convert 方法中,我们使用 C# 6.0 模式匹配来检查值输入参数的有效性,并将其转换为 FeedbackType 实例(如果有效)。 然后,我们在 switch 语句中使用它,为每个枚举实例生成相关的图像名称。
如果使用未知实例,我们将返回 DependencyProperty.UnsetValue 值。 在所有其他情况下,我们使用字符串插值来构建相关图像的完整文件路径,然后将其作为转换后的值从转换器返回。 由于此转换器中的 ConvertBack 方法没有有效用途,因此未实现它,只是返回 DependencyProperty.UnsetValue 值。
您可能已经注意到,我们在 ValueConversion 属性中指定了 ImageSource 类型,但我们返回了一个字符串。 这是可能的,因为 XAML 使用相关的类型转换器自动将字符串转换为 ImageSource 对象。 当我们在 XAML 中使用字符串设置 Image.Source 属性时,会发生完全相同的情况。
与我们框架的其他部分一样,当我们结合其他领域的功能时,我们可以使我们的转换器更加有用。 在这个特定的示例中,我们利用了本章前面介绍的扩展方法之一。 提醒您,GetDescription 方法将返回在每个枚举实例上设置的 DescriptionAttribute 的值。
DescriptionAttribute 使我们能够将任何字符串值与每个枚举实例相关联,因此这是为每个实例输出用户友好的描述的好方法。 示例如下:
using System.ComponentModel;
public enum BitRate
{
[Description("16 bits")]
Sixteen = 16,
[Description("24 bits")]
TwentyFour = 24,
[Description("32 bits")]
ThirtyTwo = 32,
}
通过这种方式,我们可以显示这些属性的更人性化的描述,而不是在 RadioButton 控件中显示实例的名称。 现在让我们看一下这个转换器类:
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
using CompanyName.ApplicationName.Extensions;
namespace CompanyName.ApplicationName.Converters
{
[ValueConversion(typeof(Enum), typeof(string))]
public class EnumToDescriptionStringConverter : IValueConverter
{
public object Convert(object value, Type targetType,
object parameter, CultureInfo culture)
{
if (value == null || (value.GetType() != typeof(Enum) &&
value.GetType().BaseType != typeof(Enum)))
return DependencyProperty.UnsetValue;
Enum enumInstance = (Enum)value;
return enumInstance.GetDescription();
}
public object ConvertBack(object value, Type targetType,
object parameter, CultureInfo culture)
{
return DependencyProperty.UnsetValue;
}
}
}
正如我们现在习惯做的那样,我们首先在 ValueConversion 属性中指定转换器中使用的数据类型。 在 Convert 方法中,我们再次检查值输入参数的有效性,如果无效则返回 DependencyProperty.UnsetValue 值。
如果它有效,我们将其转换为 Enum 实例,然后使用扩展方法的强大功能从每个实例的 DescriptionAttribute 返回值。 通过这样做,我们能够将此功能公开给我们的视图,并使我们框架的用户能够直接从 XAML 使用它。 现在我们已经对可以封装的各种方式有了大致的了解
构建自定义应用程序框架
不同的组件会有不同的要求,但通常,我们构建到数据模型基类中的属性和功能将被我们的其他基类利用并变得更有用,所以让我们首先看看各种数据模型基类 第一的。
我们需要决定的一件事是我们是否希望任何数据模型基类是通用的。 差异可能很微妙,但很重要。 想象一下,我们想要将一些基本的撤消功能添加到基类中。 实现此目的的一种方法是将一个对象添加到代表数据模型的未编辑版本的基类中。 在普通的基类中,它看起来像这样:
public abstract class BaseSynchronizableDataModel : BaseDataModel
{
private BaseSynchronizableDataModel originalState;
public BaseSynchronizableDataModel OriginalState
{
get { return originalState; }
private set { originalState = value; }
}
}
在通用基类中,它看起来像这样:
public abstract class BaseSynchronizableDataModel<T> : BaseDataModel
{
private T originalState;
public T OriginalState
{
get { return originalState; }
private set { originalState = value; }
}
}
为了使这个属性更有用,我们需要添加一些进一步的方法。 首先,我们将看到非通用版本:
public abstract void CopyValuesFrom(BaseSynchronizableDataModel dataModel);
public virtual BaseSynchronizableDataModel Clone()
{
BaseSynchronizableDataModel clone =
Activator.CreateInstance(this.GetType()) as
BaseSynchronizableDataModel;
clone.CopyValuesFrom(this);
return clone;
}
public abstract bool PropertiesEqual(BaseSynchronizableDataModel dataModel);
现在,让我们看看通用版本:
public abstract void CopyValuesFrom(T dataModel);
public virtual T Clone()
{
T clone = new T();
clone.CopyValuesFrom(this as T);
return clone;
}
public abstract bool PropertiesEqual(T dataModel);
这个基类的最后几个成员对于两个版本都是相同的:
public bool HasChanges
{
get { return originalState != null && !PropertiesEqual(originalState); }
}
public void Synchronize()
{
originalState = this.Clone();
NotifyPropertyChanged(nameof(HasChanges));
}
public void RevertState()
{
Debug.Assert(originalState != null, "Object not yet synchronized.");
CopyValuesFrom(originalState);
Synchronize();
NotifyPropertyChanged(nameof(HasChanges));
}
我们从 OriginalState 属性开始,它保存数据模型的未编辑版本。 之后,我们将看到开发人员需要实现的抽象 CopyValuesFrom 方法,并且很快我们将看到该实现的示例。 Clone 方法只需调用 CopyValuesFrom 方法即可执行数据模型的深度克隆。
接下来,我们有开发人员需要实现的抽象 PropertiesEqual 方法,以便将其类中的每个属性与 dataModel 输入参数中的属性进行比较。 同样,我们很快就会看到这个实现,但您可能想知道为什么我们不直接重写 Equals 方法,或者为此目的实现 IEquatable.Equals 方法。
我们不想使用这两个方法的原因是因为它们与 GetHashCode 方法一起被 WPF 框架在不同的地方使用,并且它们期望返回的值是不可变的。 由于我们对象的属性非常可变,因此它们不能用于返回这些方法的值。 因此,我们实现了自己的版本。 现在,让我们返回到该代码其余部分的描述。
HasChanges 属性是我们希望将数据绑定到 UI 控件以指示特定对象是否已被编辑的属性。 Synchronize 方法将当前数据模型的深度克隆设置为originalState 字段,并且重要的是,通知WPF 框架HasChanges 属性的更改。 这样做是因为 HasChanges 属性没有自己的 setter,并且此操作将影响其值。
非常重要的是,我们将克隆版本设置为originalState字段,而不是简单地将实际对象引用分配给它。 这是因为我们需要该对象的完全独立的版本来表示数据模型的未编辑版本。 如果我们只是将实际对象引用分配给originalState字段,那么它的属性值将随着数据模型对象而改变,并使其对该功能无用。
RevertState 方法首先检查数据模型是否已同步,然后将值从originalState 字段复制回模型。 最后,它调用 Synchronize 方法来指定这是对象的新的、未经编辑的版本,并向 WPF 框架通知 HasChanges 属性的更改。
因此,正如您所看到的,这两个版本的基类之间没有太大差异。 事实上,在派生类的实现中可以更清楚地看到差异。 现在让我们关注示例抽象方法的实现,从非泛型版本开始:
public override bool PropertiesEqual(BaseClass genreObject)
{
Genre genre = genreObject as Genre;
if (genre == null) return false;
return Name == genre.Name && Description == genre.Description;
}
public override void CopyValuesFrom(BaseClass genreObject)
{
Debug.Assert(genreObject.GetType() == typeof(Genre), "You are using
the wrong type with this method.");
Genre genre = (Genre)genreObject;
Name = genre.Name;
Description = genre.Description;
}
在讨论这段代码之前,让我们先看看通用实现:
public override bool PropertiesEqual(Genre genre)
{
return Name == genre.Name && Description == genre.Description;
}
public override void CopyValuesFrom(Genre genre)
{
Name = genre.Name;
Description = genre.Description;
}
最后,我们可以看到使用泛型基类和非泛型基类之间的区别。 如果不使用泛型,我们就必须使用基类输入参数,在我们可以访问其属性之前,需要将其转换为每个派生类中的适当类型。 尝试转换不适当的类型会导致异常,因此我们通常会尽力避免这些情况。
另一方面,当使用通用基类时,不需要进行强制转换,因为输入参数已经是正确的类型。 简而言之,泛型使我们能够创建类型安全的数据模型并避免重复类型特定的代码。 现在我们已经看到了使用泛型类的好处,让我们暂时停止泛型并仔细看看这个基类。
有些人可能已经注意到,WPF 框架收到 HasChanges 属性更改通知的唯一位置是 Synchronize 和 RevertState 方法。 但是,为了使此功能正常工作,每次更改任何属性的值时,我们都需要通知框架。
我们可以依靠开发人员调用 NotifyPropertyChanged 方法,每次为每个更改的属性调用该方法时传递 HasChanges 属性名称,但如果他们忘记这样做,可能会导致他们难以追踪的错误 。 相反,更好的解决方案是我们从基类重写 INotifyPropertyChanged 接口的默认实现,并在每次调用时通知它们的 HasChanges 属性的更改:
#region INotifyPropertyChanged Members
protected override void NotifyPropertyChanged(
params string[] propertyNames)
{
if (PropertyChanged != null)
{
foreach (string propertyName in propertyNames)
{
if (propertyName != nameof(HasChanges))
PropertyChanged(this,
new PropertyChangedEventArgs(propertyName));
}
PropertyChanged(this,
new PropertyChangedEventArgs(nameof(HasChanges)));
}
}
protected override void NotifyPropertyChanged(
[CallerMemberName]string propertyName = "")
{
if (PropertyChanged != null)
{
if (propertyName != nameof(HasChanges))
PropertyChanged(this,
new PropertyChangedEventArgs(propertyName));
PropertyChanged(this,
new PropertyChangedEventArgs(nameof(HasChanges)));
}
}
#endregion
第一个方法将引发 PropertyChanged 事件,仅传递一次 HasChanges 属性的名称,无论向该方法传递了多少个属性名称。 第二种方法还执行检查,以确保它不会多次引发具有 HasChanges 属性名称的事件,因此这些实现仍然高效。
现在,我们的基类将按预期工作,并且当数据模型类中的其他属性发生更改时,HasChanges 属性将正确更新。 该技术还可以用于其他场景; 例如,当验证我们的属性值时,正如我们稍后将在第 9 章“实现响应式数据验证”中看到的那样。 不过现在,让我们回过头来看看我们还能用泛型实现什么。
另一个经常使用泛型的领域与集合有关。 我确信你们都知道我们倾向于在 WPF 应用程序中使用 ObservableCollection 类,因为它具有 INotifyCollectionChanged 和 INotifyPropertyChanged 实现。 通常,但不是必需的,为我们拥有的每种类型的数据模型类扩展此类:
public class Users : ObservableCollection<User>
然而,我们可以声明一个 BaseCollection 类来扩展 ObservableCollection 类,并为我们的框架添加更多功能,而不是这样做。 我们框架的用户可以扩展这个类:
public class Users : BaseCollection<User>
我们可以做的一件真正有用的事情是将 T 类型的通用属性添加到我们的基类中,它将表示 UI 中数据绑定集合控件中当前选定的项目。 我们还可以声明一些委托来通知开发人员对选择或属性值的更改。 根据需求,我们可以在这里提供很多快捷方式和辅助方法,因此值得花一些时间进行研究。 让我们看一下几种可能性:
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using CompanyName.ApplicationName.Extensions;
namespace CompanyName.ApplicationName.DataModels.Collections
{
public class BaseCollection<T> :
ObservableCollection<T>, INotifyPropertyChanged
where T : class, INotifyPropertyChanged, new()
{
protected T currentItem;
public BaseCollection(IEnumerable<T> collection) : this()
{
foreach (T item in collection) Add(item);
}
public BaseCollection(params T[] collection) :
this(collection as IEnumerable<T>) { }
public BaseCollection() : base()
{
currentItem = new T();
}
public virtual T CurrentItem
{
get { return currentItem; }
set
{
T oldCurrentItem = currentItem;
currentItem = value;
CurrentItemChanged?.Invoke(oldCurrentItem, currentItem);
NotifyPropertyChanged();
}
}
public bool IsEmpty
{
get { return !this.Any(); }
}
public delegate void ItemPropertyChanged(T item,
string propertyName);
public virtual ItemPropertyChanged CurrentItemPropertyChanged
{ get; set; }
public delegate void CurrentItemChange(T oldItem, T newItem);
public virtual CurrentItemChange CurrentItemChanged { get; set; }
public T GetNewItem()
{
return new T();
}
public virtual void AddEmptyItem()
{
Add(new T());
}
public virtual void Add(IEnumerable<T> collection)
{
collection.ForEach(i => base.Add(i));
}
public virtual void Add(params T[] items)
{
if (items.Length == 1) base.Add(items[0]);
else Add(items as IEnumerable<T>);
}
protected override void InsertItem(int index, T item)
{
if (item != null)
{
item.PropertyChanged += Item_PropertyChanged;
base.InsertItem(index, item);
if (Count == 1) CurrentItem = item;
}
}
protected override void SetItem(int index, T item)
{
if (item != null)
{
item.PropertyChanged += Item_PropertyChanged;
base.SetItem(index, item);
if (Count == 1) CurrentItem = item;
}
}
protected override void ClearItems()
{
foreach (T item in this)
item.PropertyChanged -= Item_PropertyChanged;
base.ClearItems();
}
protected override void RemoveItem(int index)
{
T item = this[index];
if (item != null) item.PropertyChanged -= Item_PropertyChanged;
base.RemoveItem(index);
}
public void ResetCurrentItemPosition()
{
if (this.Any()) CurrentItem = this.First();
}
private void Item_PropertyChanged(object sender,
PropertyChangedEventArgs e)
{
if ((sender as T) == CurrentItem)
CurrentItemPropertyChanged?.Invoke(currentItem, e.PropertyName);
NotifyPropertyChanged(e.PropertyName);
}
#region INotifyPropertyChanged Members
...
#endregion
}
}
这里有很多东西需要消化,所以让我们仔细检查每个部分。 我们从 T 类型的私有成员开始,它将支持我们的 CurrentItem 属性。 然后,我们找到构造函数的一些重载,使我们能够从集合或相关类型的任意数量的输入参数初始化我们的集合。
接下来,我们再次看到第 1 章“使用 WPF 的更智能方式”中的 CurrentItem 属性,但现在有一些进一步的上下文。 如果一个类订阅了 CurrentItemChanged 属性,我们将从这里调用委托,传递当前项的新值和旧值。 IsEmpty 属性只是一个高效、方便的属性,供我们的开发人员在需要知道集合是否有内容时调用。
之后,我们看到集合委托和相关的属性包装器,使使用我们框架的开发人员能够利用它们。 接下来,我们看到方便的 GetNewItem 和 AddEmptyItem 方法,它们在分别返回或添加到集合之前都会生成 T 泛型类型参数的新项。 这就是我们需要在类定义中添加 new() 泛型类型约束的原因; 此类型约束指定所使用的泛型类型必须具有无参数构造函数。
现在我们到达了集合的各种 Add 方法; 请注意,必须处理向集合添加项目的每种方法,以便我们可以将 Item_PropertyChanged 处理程序附加到每个添加项目的 PropertyChanged 事件,以确保行为一致。
因此,我们从所有其他重载和辅助方法中调用 Add 方法,并从那里调用基本 Collection.Add 方法。 请注意,我们实际上将处理程序附加到受保护的 InsertItem 方法内,因为此重写方法是从 Collection 类中的 Add 方法调用的。
同样,当使用索引表示法设置项时,Collection 类将调用受保护的 SetItem 方法,因此我们也必须处理该方法。 同样,当从集合中删除项目时,从每个对象中删除对事件处理程序的引用同样重要(如果不是更重要)。 如果不这样做可能会导致内存泄漏,因为对事件处理程序的引用可能会阻止垃圾收集器处置数据模型对象。
因此,我们还需要处理从集合中删除对象的每种方法。 为此,我们重写 Collection 基类中的一些受保护的方法。 当用户对我们的集合调用 Clear 方法时,将在内部调用 ClearItems 方法。 同样,当用户调用任何公共删除方法时,都会调用RemoveItem方法,因此它是删除处理程序的最佳位置。
现在跳过 ResetCurrentItemPosition 方法,在类的底部,我们到达 Item_PropertyChanged 事件处理方法。 如果属性已更改的项目是集合中的当前项目,则我们引发与 CurrentItemPropertyChanged 属性连接的 ItemPropertyChanged 委托。
对于每个属性更改通知,无论该项目是否是当前项目,我们都会引发 INotifyPropertyChanged.PropertyChanged 事件。 这使得使用我们框架的开发人员能够直接将处理程序附加到我们集合上的 PropertyChanged 事件,并能够发现集合中任何项目的任何属性何时发生更改。
您可能还注意到集合类代码中的一些地方我们设置了 CurrentItem 属性的值。 此处选择的选项是始终自动选择集合中的第一个项目,但例如,选择最后一个项目将是一个简单的更改。 与往常一样,这些细节将取决于您的具体要求。
声明这些基集合类的另一个好处是,我们可以利用这些属性并扩展内置于数据模型基类中的功能。 回想一下 BaseSynchronizedDataModel 类的简单示例,让我们看看可以在新的基集合类中添加哪些内容来改进此功能。
然而,在执行此操作之前,我们需要能够指定新集合中的对象已实现 BaseSynchronizedDataModel 类中的属性和方法。 一种选择是像这样声明我们的新集合类:
public class BaseSynchronizableCollection<T> : BaseCollection<T>
where T : BaseSynchronizableDataModel<T>
然而,在 C# 中,我们只能扩展一个基类,而我们可以自由地实现任意数量的接口。 因此,更好的解决方案是我们将相关同步属性从基类提取到接口中,然后将其添加到基类定义中:
public abstract class BaseSynchronizableDataModel<T> :
BaseDataModel, ISynchronizableDataModel<T>
where T : BaseDataModel, ISynchronizableDataModel<T>, new()
然后我们可以像这样在我们的新集合类上指定这个新的通用约束:
public class BaseSynchronizableCollection<T> : BaseCollection<T>
where T : class, ISynchronizableDataModel<T>, new()
请注意,放置在 BaseSynchronizedDataModel 类上的任何其他通用约束也需要添加到此声明的 where T 部分。 例如,如果我们需要在基类中实现另一个接口,并且我们没有为基集合类中的 T 泛型类型参数添加相同的约束,那么在尝试使用基类的实例时,我们将收到编译错误 类作为 T 参数。 现在让我们看看这个新的基类:
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using CompanyName.ApplicationName.DataModels.Interfaces;
using CompanyName.ApplicationName.Extensions;
namespace CompanyName.ApplicationName.DataModels.Collections
{
public class BaseSynchronizableCollection<T> : BaseCollection<T>
where T : class, ISynchronizableDataModel<T>,
INotifyPropertyChanged, new()
{
public BaseSynchronizableCollection(IEnumerable<T> collection) :
base(collection) { }
public BaseSynchronizableCollection(params T[] collection) :
base(collection as IEnumerable<T>) { }
public BaseSynchronizableCollection() : base() { }
public virtual bool HasChanges
{
get { return this.Any(i => i.HasChanges); }
}
public virtual bool AreSynchronized
{
get { return this.All(i => i.IsSynchronized); }
}
public virtual IEnumerable<T> ChangedCollection
{
get { return this.Where(i => i.HasChanges); }
}
public virtual void Synchronize()
{
this.ForEach(i => i.Synchronize());
}
public virtual void RevertState()
{
this.ForEach(i => i.RevertState());
}
}
}
在保持简单的同时,这个基集合类提供了一些强大的功能。 我们从类声明开始,其泛型类型约束继承自我们的目标 T 类型类和 BaseCollection 类。 然后,我们实现了构造函数重载并将初始化职责直接传递给基类。
请注意,如果我们想要将额外级别的事件处理程序附加到集合项,我们将遵循基类的模式,而不是以这种方式调用基类构造函数。
HasChanges 属性可用作标志来检测集合中的任何项目是否有任何更改。 这通常与保存命令的 canExecute 参数相关联,以便在编辑集合中的任何项目时启用保存按钮,并在撤消更改时禁用保存按钮。
AreSynchronized 属性仅指定集合中的项目是否已全部同步,但此类的真正优点在于 ChangedCollection 属性。 使用简单的 LINQ 过滤器,我们仅返回集合中发生更改的项目。 想象一个场景,我们允许用户一次编辑多个项目。 借助此属性,我们的开发人员可以轻松地从集合中提取他们需要保存的项目。
最后,此类提供了一种方法可以同时同步集合中的所有项目,另一种方法可以同样撤消集合中所有已编辑项目的更改。 请注意最后两个方法中自定义 ForEach 扩展方法的使用; 如果您还记得前面的“使用扩展方法”部分,它使我们能够对集合中的每个项目执行操作。
通过框架的其他部分使用数据模型基类的属性和方法,我们能够进一步扩展它们的功能。 虽然以这种方式从不同组件构建复合功能通常是可选的,但它也可能是必要的,正如我们将在本书后面看到的那样。
我们可以在应用程序框架基类中构建的通用功能越多,使用我们框架的开发人员在开发应用程序时要做的工作就越少。 但是,我们必须仔细计划,不要强迫开发人员拥有不需要的属性和方法,以便扩展具有他们确实想要的其他功能的特定基类。
通常,不同的组件会有不同的要求。 数据模型类通常比视图模型有更多的基类,因为它们比视图模型发挥更大的作用。 视图模型只是为视图提供它们所需的数据和功能。 但是,数据模型类包含数据以及验证、同步以及可能的动画方法和属性。 考虑到这一点,让我们再次看看视图模型基类。
我们已经看到,我们需要在基类中实现 INotifyPropertyChanged 接口,但是我们还应该实现什么? 如果每个视图都将提供一些特定的功能,例如保存和删除项目,那么我们还可以直接将命令添加到我们的基类和每个派生视图模型类必须实现的抽象方法中
public virtual ICommand Refresh
{
get
{
return new ActionCommand(
action => RefreshData(),
canExecute => CanRefreshData());
}
}
protected abstract void RefreshData();
protected abstract bool CanRefreshData();
同样,将此命令声明为虚拟命令非常重要,以防开发人员需要提供自己的不同实现。 这种安排的另一种选择是只为每个命令添加抽象属性,以便各个实现完全取决于开发人员:
public abstract ICommand Save { get; }
谈到命令主题时,您可能还记得第 1 章“更智能的 WPF 工作方式”中 ActionCommand 的基本实现。 此时,值得绕道进一步研究这一点。 请注意,虽然所示的基本实现在大多数情况下都运行良好,但偶尔也会让我们感到困惑,我们可能会注意到按钮在应该启用时却没有启用。
让我们看一个例子。 想象一下,我们的 UI 中有一个按钮,用于打开一个文件夹供用户查看其中的文件,并且在 ICommand.CanExecute 方法中满足特定条件时启用该按钮。 假设这个条件是文件夹应该有一些内容。 毕竟,为用户打开空文件夹是没有意义的。
现在,让我们想象一下,当用户在 UI 中执行其他操作时,该文件夹将被填充。 用户单击启动此文件夹填充功能的按钮,应用程序开始填充它。 当填充功能完成且文件夹现在包含一些内容时,打开文件夹按钮应变为启用状态,因为其关联命令的 CanExecute 条件现在为 true。
不过,此时不会调用 CanExecute 方法,为什么要调用呢? 该按钮以及 CommandManager 类确实不知道该后台进程正在发生,并且现在已满足 CanExecute 方法的条件。 幸运的是,我们有几种选择来解决这种情况。
一种选择是手动引发 CanExecuteChanged 事件,以使数据绑定命令源重新检查 CanExecute 方法的输出并相应地更新其启用状态。 为此,我们可以在 ActionCommand 类中添加另一个方法,但我们必须首先重新安排一些事情。
当前实现不存储对附加到 CanExecuteChanged 事件的事件处理程序的任何引用。 它们实际上存储在 CommandManager 类中,因为它们只是直接传递给 RequerySuggested 事件进行处理。 为了能够手动引发事件,我们需要存储我们自己对处理程序的引用,为此,我们需要一个 EventHandler 对象:
private EventHandler eventHandler;
接下来,我们需要添加对附加处理程序的引用并删除那些分离的处理程序,同时仍将它们的引用传递给 CommandManager 的 RequerySuggested 事件:
public event EventHandler CanExecuteChanged
{
add
{
eventHandler += value;
CommandManager.RequerySuggested += value;
}
remove
{
eventHandler -= value;
CommandManager.RequerySuggested -= value;
}
}
对 ActionCommand 类的最后一个更改是添加一个方法,当我们希望 UI 控件的命令源检索新的 CanExecute 值并更新其启用状态时,我们可以调用该方法来引发 CanExecuteChanged 事件:
public void RaiseCanExecuteChanged()
{
eventHandler?.Invoke(this, new EventArgs());
}
现在,我们可以在需要时引发 CanExecuteChanged 事件,尽管我们还需要更改 ActionCommand 类的使用来执行此操作。 以前,我们只是在每次调用 getter 时返回一个新实例,现在我们需要保留对我们希望拥有此功能的每个命令的引用:
private ActionCommand saveCommand = null;
...
public ICommand SaveCommand
{
get { return saveCommand ?? (saveCommand =
new ActionCommand(action => Save(), canExecute => CanSave())); }
}
如果您不熟悉?? 前面代码中所示的运算符,它称为空合并运算符,如果不为空,则简单地返回左侧操作数,如果为空,则返回右侧操作数。 在这种情况下,右侧操作数将初始化该命令并将其设置为 saveCommand 变量。 然后,为了引发该事件,当我们完成操作时,我们在 ActionCommand 实例上调用新的 RaiseCanExecuteChanged 方法:
private void ExecuteSomeCommand()
{
// Perform some operation that fulfills the canExecute condition
// then raise the CanExecuteChanged event of the ActionCommand
saveCommand.RaiseCanExecuteChanged();
}
虽然我们的方法内置于 ActionCommand 类中,但有时我们可能无法访问需要引发事件的特定实例。 因此,此时应该注意的是,我们可以通过另一种更直接的方法让 CommandManager 类引发其 RequerySuggested 事件。
在这些情况下,我们可以简单地调用 CommandManager.InvalidateRequerySuggested 方法。 我们还应该注意,这些引发 RequerySuggested 事件的方法仅适用于 UI 线程,因此在异步代码中使用它们时应小心。 现在我们与命令相关的简短绕行已经完成,让我们返回看看我们可能想要放入视图模型基类中的其他常见功能。
如果我们选择为数据模型使用通用基类,那么我们可以在 BaseViewModel 类中利用它。 我们可以提供利用这些通用基类中的成员的通用方法。 让我们看一些简单的例子:
public T AddNewDataTypeToCollection<S, T>(S collection)
where S : BaseSynchronizableCollection<T>
where T : BaseSynchronizableDataModel<T>, new()
{
T item = collection.GetNewItem();
if (item is IAuditable)
((IAuditable)item).Auditable.CreatedOn = DateTime.Now;
item.Synchronize();
collection.Add(item);
collection.CurrentItem = item;
return item;
}
public T InsertNewDataTypeToCollection<S, T>(int index, S collection)
where S : BaseSynchronizableCollection<T>
where T : BaseSynchronizableDataModel<T>, new()
{
T item = collection.GetNewItem();
if (item is IAuditable)
((IAuditable)item).Auditable.CreatedOn = DateTime.Now;
item.Synchronize();
collection.Insert(index, item);
collection.CurrentItem = item;
return item;
}
public void RemoveDataTypeFromCollection<S, T>(S collection, T item)
where S : BaseSynchronizableCollection<T>
where T : BaseSynchronizableDataModel<T>, new()
{
int index = collection.IndexOf(item);
collection.RemoveAt(index);
if (index > collection.Count) index = collection.Count;
else if (index < 0) index++;
if (index > 0 && index < collection.Count &&
collection.CurrentItem != collection[index])
collection.CurrentItem = collection[index];
}
在这里,我们看到三个简单的方法封装了更常见的功能。 请注意,我们必须指定在 bass 类上声明的相同泛型类型约束。 如果不这样做,就会导致编译错误,或者我们无法将数据模型类与这些方法一起使用。
AddNewDataTypeToCollection 和 InsertNewDataTypeToCollection 方法几乎相同,并且首先使用通用 BaseSynchronizedCollection 类的 GetNewItem 方法创建相关类型的新项目。 接下来,我们将看到 IAuditable 接口的另一种用途。 在这种情况下,如果新项目实现了此接口,我们将设置它的 CreatedOn 日期。
因为我们在 T 类型参数上声明了泛型类型约束,指定它必须是或扩展 BaseSynchronizedDataModel 类,所以我们能够调用 Synchronize 方法来同步新项。 然后,我们将该项目添加到集合中并将其设置为 CurrentItem 属性的值。 最后,这两个方法都会返回新项目。
最后一个方法执行相反的操作; 它从集合中删除一个项目。 执行此操作之前,它会检查该项目在集合中的位置,并在可能的情况下将 CurrentItem 属性设置为下一个项目,或者如果删除的项目是集合中的最后一个项目,则将其设置为下一个最近的项目。
我们再次看到如何将常用功能封装到基类中,并为框架用户节省在每个视图模型类中重新实现此功能的时间和精力。 我们可以通过这种方式打包我们需要的任何常见功能。现在已经看到了在我们的基类中提供功能的几个示例,现在让我们将注意力转向提供框架组件之间的分离。
分离数据访问层
现在我们已经了解了通过基类和接口提供各种功能,接下来我们研究一下如何提供关注点分离,这在使用 MVVM 模式时至关重要。 我们再次求助于简单的界面来帮助我们实现这一目标。 让我们看一个简化的例子:
using System;
using CompanyName.ApplicationName.DataModels;
namespace CompanyName.ApplicationName.Models.Interfaces
{
public interface IDataProvider
{
User GetUser(Guid id);
bool SaveUser(User user);
}
}
我们从一个非常简单的界面开始。 当然,实际的应用程序会有比这多得多的方法,但原理是相同的,无论接口的复杂程度如何。 所以在这里,我们只有 DataProvider 类需要实现的 GetUser 和 SaveUser 方法。 现在,让我们看一下 ApplicationDataProvider 类:
using System;
using System.Data.Linq;
using System.Linq;
using CompanyName.ApplicationName.DataModels;
using CompanyName.ApplicationName.Models.Interfaces;
namespace CompanyName.ApplicationName.Models.DataProviders
{
public class ApplicationDataProvider : IDataProvider
{
public ApplicationDataContext DataContext
{
get { return new ApplicationDataContext(); }
}
public User GetUser(Guid id)
{
DbUser dbUser = DataContext.DbUsers.SingleOrDefault(u => u.Id == id);
if (dbUser == null) return null;
return new User(dbUser.Id, dbUser.Name, dbUser.Age);
}
public bool SaveUser(User user)
{
using (ApplicationDataContext dataContext = DataContext)
{
DbUser dbUser =
dataContext.DbUsers.SingleOrDefault(u => u.Id == user.Id);
if (dbUser == null) return false;
dbUser.Name = user.Name;
dbUser.Age = user.Age;
dataContext.SubmitChanges(ConflictMode.FailOnFirstConflict);
return true;
}
}
}
}
此 ApplicationDataProvider 类使用一些简单的 LINQ to SQL 来查询和更新由所提供的 id 值指定的用户的数据库。 这意味着该接口的特定实现需要与数据库的连接。 我们希望在测试应用程序时避免出现这种依赖性,因此我们需要该接口的另一个实现来用于测试目的。 现在让我们看看我们的模拟实现:
using System;
using CompanyName.ApplicationName.DataModels;
using CompanyName.ApplicationName.Models.Interfaces;
namespace Test.CompanyName.ApplicationName.Models.DataProviders
{
public class MockDataProvider : IDataProvider
{
public User GetUser(Guid id)
{
return new User(id, "James Smith", 25);
}
public bool SaveUser(User user)
{
return true;
}
}
}
在 IDataProvider 接口的 MockDataProvider 实现中,我们可以看到数据只是手动模拟的。 事实上,它只是从 GetUser 方法返回一个 User,并且总是从 SaveUser 方法返回 true,因此它相当无用。
在现实应用程序中,我们要么使用模拟框架,要么手动模拟一些更重要的测试数据。 尽管如此,这对于我们在这里关注的点来说已经足够了。 现在我们已经了解了所涉及的类,让我们看看如何使用它们。
我们的想法是,我们有某种位于 IDataProvider 接口和视图模型类之间的 DataController 类。 View Model 类从 DataController 类请求数据,而 DataController 类又通过接口请求数据。
因此,它反映了接口的方法,并且通常引入一些额外的功能,例如反馈处理。 让我们看看简化后的 DataController 类是什么样子的:
using System;
using CompanyName.ApplicationName.DataModels;
using CompanyName.ApplicationName.Models.Interfaces;
namespace CompanyName.ApplicationName.Models.DataControllers
{
public class DataController
{
private IDataProvider dataProvider;
public DataController(IDataProvider dataProvider)
{
DataProvider = dataProvider;
}
protected IDataProvider DataProvider
{
get { return dataProvider; }
private set { dataProvider = value; }
}
public User GetUser(Guid id)
{
return DataProvider.GetUser(id);
}
public bool SaveUser(User user)
{
return DataProvider.SaveUser(user);
}
}
}
正如我们所看到的,DataController 类有一个 IDataProvider 类型的私有成员变量,该变量在其构造函数中填充。 该变量用于访问应用程序数据源。 当应用程序运行时,我们的 ApplicationDataProvider 类的实例用于实例化 DataController 类,因此使用我们的实际数据源:
DataController dataController = new DataController(new ApplicationDataProvider());
但是,当我们测试应用程序时,我们可以使用 MockDataProvider 类的实例来实例化 DataController 类,从而消除对实际数据源的依赖:
DataController dataController = new DataController(new MockDataProvider());
通过这种方式,我们可以替换为视图模型提供数据的代码,同时保持其余代码不变。 这使我们能够在视图模型中测试代码,而无需连接到实际的数据存储设备。 在下一节中,我们将看到初始化这些类的更好方法,但现在,让我们看看 DataController 类还可以为我们做什么。
当接口被应用程序框架的一部分(而不是实现类)使用时,它们会变得更有用。 除了定义一些审计属性并可以输出它们的值之外,我们之前的 IAuditable 接口示例并没有太大用处。 但是,我们可以通过自动更新其值,在 DataController 类中进一步扩展其功能。 我们需要添加更多成员来实现这一目标:
using CompanyName.ApplicationName.DataModels.Interfaces;
...
public User CurrentUser { get; set; }
...
private void SetAuditUpdateFields<T>(T dataModel) where T : IAuditable
{
dataModel.Auditable.UpdatedOn = DateTime.Now;
dataModel.Auditable.UpdatedBy = CurrentUser;
return dataModel;
}
我们首先需要添加一个 User 类型的属性,我们将使用它来设置应用程序当前用户的值。 这可以在新用户登录应用程序时进行设置。 接下来,我们需要一个方法来更新 IAuditable 接口的“更新”值。 再次,我们添加一个泛型类型约束,以确保只有实现我们接口的对象才能传递到此方法中。 这样做的结果是使用我们的应用程序框架的开发人员可以轻松更新这些值:
public bool SaveUser(User user)
{
return DataProvider.SaveUser(SetAuditUpdateFields(user));
}
在添加新对象时,我们可以添加一个类似的方法来设置“已创建”审计属性:
public bool AddUser(User user)
{
return DataProvider.AddUser(SetAuditCreateFields(user));
}
...
private void SetAuditCreateFields<T>(T dataModel) where T : IAuditable
{
dataModel.Auditable.CreatedOn = DateTime.Now;
dataModel.Auditable.CreatedBy = CurrentUser;
return dataModel;
}
继续这个例子,我们可以扩展 DataController 类的构造函数来接受一个用户输入参数,我们可以使用它来设置我们的 CurrentUser 属性:
public DataController(IDataProvider dataProvider, User currentUser)
{
DataProvider = dataProvider;
CurrentUser = currentUser;
}
然后,我们可以使用 StateManager 类和 DependencyManager 类中的 CurrentUser 属性,通过它们的基类将我们的数据源公开给我们的视图模型,我们将在以下部分中看到:
protected DataController Model
{
get { return new DataController(
DependencyManager.Instance.Resolve<IDataProvider>(),
StateManager.CurrentUser); }
}
本质上,我们需要对来自应用程序数据源的数据执行的任何操作都可以在单个 DataController 类中实现。 但是,如果我们需要进行多次不同的修改,那么我们可以选择创建多个控制器类并将它们链接在一起,每个控制器类依次执行各自的任务。
由于它们都可以实现相同的方法,因此它们都可能实现相同的接口:
我们将在第 10 章“完成出色的用户体验”中看到这样的示例,但是现在我们已经了解了如何最好地设置应用程序数据源连接以提供 MVVM 模式所需的分离,我们可以专注于 将功能构建到我们的框架中的下一种方法。 让我们继续探索如何将更复杂和/或专门的功能插入到我们的框架中。
提供服务
应用程序框架中的基类和接口的工作是封装视图模型和数据模型常用的功能。 当所需的功能更复杂时,或者涉及特定资源或外部连接时,我们在单独的服务或管理器类中实现它。 在本书的其余部分中,我们将它们称为管理器类。 在较大的应用程序中,这些通常在单独的项目中提供。
将它们封装在一个单独的项目中使我们能够在其他应用程序中重用这些类的功能。 我们在此项目中使用哪些类取决于我们正在构建的应用程序的要求,但它通常包括提供发送电子邮件、访问最终用户的硬盘、以各种格式导出数据的功能的类, 或者例如管理全局应用程序状态。
我们将在本书中研究一些这样的类,以便我们更好地了解如何实现我们自己的自定义管理器类。 这些类中最常用的通常可以通过属性从基本视图模型类直接访问。 我们可以通过几种不同的方式将这些类公开给视图模型,所以让我们来检查一下它们。
当经常使用管理器类并且每次使用时间很短时,我们可以每次公开它们的新实例,如下所示:
public FeedbackManager FeedbackManager
{
get { return new FeedbackManager(); }
}
但是,如果应用程序的生命周期需要一个管理器类,因为它必须记住特定的状态或配置,那么我们通常会以一种或另一种方式使用 static 关键字。 最简单的选择是声明一个普通类,但通过静态属性公开它:
private static StateManager stateManager = new StateManager();
...
public static StateManager StateManager
{
get { return stateManager; }
}
我们可以使用单例模式来实例化一个类的一个且仅一个实例,并使其在应用程序运行期间保持活动状态。 虽然它在大约二十年前风靡一时,但不幸的是最近它与更现代的编程原则发生了冲突,例如 SOLID 之类的原则,它规定每个类应该有一个单一的责任。
单例模式打破了这一原则,因为它服务于我们设计它的任何目的,但它也负责实例化自身并维护单个访问点。 在进一步讨论此模式的优点和缺点之前,让我们看一下如何在管理器类中实现它:
namespace CompanyName.ApplicationName.Managers
{
public class StateManager
{
private static StateManager instance;
private StateManager() { }
public static StateManager Instance
{
get { return instance ?? (instance = new StateManager()); }
}
...
}
}
请注意,它可以通过多种方式实现,但这种特殊方式使用延迟初始化,其中实例在首次通过 Instance 属性引用之前不会被实例化。 使用 ?? 再次使用运算符,Instance 属性 getter 可以理解为“如果不为 null,则返回唯一实例化的实例,或者如果为 null,则实例化唯一的实例,然后返回它”。 该模式的重要部分是,由于没有公共构造函数,因此该类无法在外部实例化,因此该属性是访问内部对象的唯一方法。
然而,这正是给一些开发人员带来麻烦的部分,因为这使得这些类无法继承。 但在我们的例子中,我们不需要扩展 StateManager 类,因此这不是我们关心的问题。 其他人可能会指出这样的问题:公开此 Singleton 类(如以下代码所示)会将其与声明它的基本视图模型类紧密耦合:
public StateManager StateManager
{
get { return StateManager.Instance; }
}
虽然这是事实,但这对这个类有什么害处呢? 其目的是维护用户设置、常用或默认值以及UI显示和操作状态的值的状态。 它不包含任何资源,也没有真正的理由在运行单元测试时避免使用它,因此在这种情况下,紧耦合是无关紧要的。 在这方面,单例模式在适当的情况下仍然是一个有用的工具,但我们当然应该意识到它的陷阱。
然而,如果一个特定的管理器类确实利用资源或创建某种形式的与外界的连接,例如,像 EmailManager 那样,那么我们将需要为其创建一个接口来维护我们的关注点分离。 请记住,接口使我们能够在测试时断开实际的应用程序组件并用模拟组件替换它们。 在这些情况下,我们必须以稍微不同的方式公开基类中的功能:
private IEmailManager emailManager;
...
public BaseViewModel(IEmailManager emailManager)
{
this.emailManager = emailManager; }
}
...
public IEmailManager EmailManager
{
get { return emailManager; }
}
这里的总体想法是我们不直接接触现有的管理器类,而是通过接口方法和属性访问其功能。 通过这样做,我们能够将管理器类与使用它的视图模型解耦,从而使它们能够彼此独立地使用。 请注意,这是一个非常简单的依赖注入示例。
实现依赖注入
依赖注入是一种众所周知的设计模式,有助于解耦应用程序的各个组件。 如果一个类在内部使用另一个类来执行某些功能,则内部使用的类将成为使用它的类的依赖项。 没有它,它就无法实现其目标。 在某些情况下,这不是问题,但在其他情况下,这可能是一个巨大的问题。
例如,假设我们有一个 FeedbackManager 类,负责向最终用户提供操作反馈。 在该类中,我们有一个 FeedbackCollection 实例,它保存当前向当前用户显示的 Feedback 对象。 在这里,Feedback 对象是 FeedbackCollection 实例的依赖项,而该实例又是 FeedbackManager 类的依赖项。
这些对象都是紧密耦合的,这在软件开发中通常是一件坏事。 然而,它们也有必然的紧密联系。 如果没有 Feedback 对象,FeedbackCollection 对象将毫无用处,FeedbackManager 对象也是如此。
在这种特殊情况下,这些对象需要这种耦合才能使它们一起使用。 这就是所谓的组合,各个部分形成一个整体,但它们各自的作用很少,所以它们以这种方式连接起来确实没有问题。
另一方面,现在让我们考虑视图模型和 DAL 之间的连接。 我们的视图模型肯定需要访问一些数据,因此首先在我们的视图模型中封装一个提供所需数据的类似乎是有意义的。
虽然这肯定有效,但不幸的是,它会导致 DAL 类成为视图模型类的依赖项。 此外,它会将我们的视图模型组件永久耦合到 DAL,并打破 MVVM 提供的关注点分离。 在这种情况下,我们需要的连接类型更像是聚合,其中各个部分本身就很有用。
在这些情况下,我们希望能够单独使用各个组件,并避免它们之间的紧密耦合。 依赖注入是我们可以用来为我们提供这种分离的工具。 用最简单的术语来说,依赖注入是通过使用接口来实现的。 我们已经在分离数据访问层部分的 DataController 类和上一节的 EmailManager 示例中看到了一些基本示例。
然而,它们是非常基本的示例,有多种方法可以改进它们。 许多应用程序框架将为开发人员提供使用依赖注入将依赖项注入到他们的类中的能力,我们也可以对我们的类做同样的事情。 在最简单的形式中,我们的 DependencyManager 类只需要注册依赖项并在需要时提供解决它们的方法。 让我们来看看:
using System;
using System.Collections.Generic;
namespace CompanyName.ApplicationName.Managers
{
public class DependencyManager
{
private static DependencyManager instance;
private static Dictionary<Type, Type> registeredDependencies =
new Dictionary<Type, Type>();
private DependencyManager() { }
public static DependencyManager Instance
{
get { return instance ?? (instance = new DependencyManager()); }
}
public int Count
{
get { return registeredDependencies.Count; }
}
public void ClearRegistrations()
{
registeredDependencies.Clear();
}
public void Register<S, T>() where S : class where T : class
{
if (!typeof(S).IsInterface)
throw new ArgumentException("The S generic type parameter of the Register method must be an interface.", "S");
if (!typeof(S).IsAssignableFrom(typeof(T))) throw
new ArgumentException("The T generic type parameter must be a class that implements the interface specified by the S generic type parameter.", "T");
if (!registeredDependencies.ContainsKey(typeof(S)))
registeredDependencies.Add(typeof(S), typeof(T));
}
public T Resolve<T>() where T : class
{
Type type = registeredDependencies[typeof(T)];
return Activator.CreateInstance(type) as T;
}
public T Resolve<T>(params object[] args) where T : class
{
Type type = registeredDependencies[typeof(T)];
if (args == null || args.Length == 0)
return Activator.CreateInstance(type) as T;
else return Activator.CreateInstance(type, args) as T;
}
}
}
您可能已经注意到,我们在此类中再次使用单例模式。 在这种情况下,它再次完全符合我们的要求。 我们希望实例化该类的一个且仅一个实例,并且只要应用程序正在运行,我们就希望它保持活动状态。 测试时,它用于将我们的模拟依赖项注入到视图模型中,因此它是实现关注点分离的框架的一部分。
Count 属性和 ClearRegistrations 方法对于测试比运行应用程序更有用,并且实际操作在 Register 和 Resolve 方法中进行。 Register 方法注册由 S 泛型类型参数表示的接口类型,以及由 T 泛型类型参数表示的该接口的具体实现。
由于 S 泛型类型参数必须是一个接口,因此如果提供的类型参数类不是一个,则会在运行时引发 ArgumentException。 执行进一步的检查以确保 T 泛型类型参数指定的类型实际上实现了 S 泛型类型参数指定的接口,如果检查失败,则进一步抛出 ArgumentException。
然后,该方法验证所提供的类型参数是否已存在于 Dictionary 中,如果它在集合中是唯一的,则将其添加。 因此,在这个特定的实现中,我们只能为每个提供的接口指定一个具体的实现。 我们可以更改此设置,以便在再次传递现有类型时更新存储的引用,甚至为每个接口存储多个具体类型。 这一切都取决于应用程序的要求。
请注意在此方法上声明的泛型类型约束,它确保类型参数至少是类。 不幸的是,没有这样的约束允许我们指定特定的泛型类型参数应该是接口。 但是,应尽可能使用这种类型的参数验证,因为它可以帮助我们框架的用户避免使用具有不适当值的这些方法。
Resolve 方法使用一些简单的反射来返回由所使用的泛型类型参数表示的接口类型的具体实现。 再次注意这两个方法声明的泛型类型约束,它们指定用于类型 T 参数的类型必须是类。 这是为了防止 Activator.CreateInstance
方法在运行时抛出异常(如果使用了无法实例化的类型)。
第一个重载可用于没有任何构造函数参数的类,第二个重载有一个额外的 params
输入参数,用于传递参数以在实例化需要构造函数参数的类时使用。
可以在应用程序启动期间使用 App.xaml.cs
文件设置 DependencyManager
类。 为此,我们首先需要在 App.xaml
文件顶部的 Application
声明中找到以下 StartupUri
属性设置:
StartupUri="MainWindow.xaml"
然后,我们需要将此 StartupUri
属性设置替换为以下 Startup
属性设置:
Startup="App_Startup"
在此示例中,App_Startup
是我们希望在启动时调用的初始化方法的名称。 请注意,由于 WPF 框架不再启动 MainWindow
类,现在我们有责任这样做:
using System.Windows;
using CompanyName.ApplicationName.Managers;
using CompanyName.ApplicationName.ViewModels;
using CompanyName.ApplicationName.ViewModels.Interfaces;
namespace CompanyName.ApplicationName
{
public partial class App : Application
{
public void App_Startup(object sender, StartupEventArgs e)
{
RegisterDependencies();
new MainWindow().Show();
}
private void RegisterDependencies()
{
DependencyManager.Instance.ClearRegistrations();
DependencyManager.Instance.Register<IDataProvider,
ApplicationDataProvider>();
DependencyManager.Instance.Register<IEmailManager, EmailManager>();
DependencyManager.Instance.Register<IExcelManager, ExcelManager>();
DependencyManager.Instance.Register<IWindowManager, WindowManager>();
}
}
}
当我们想在运行时将这些依赖注入到应用程序的视图模型中时,我们可以像这样使用 DependencyManager
类:
UsersViewModel viewModel =
new UsersViewModel(DependencyManager.Instance.Resolve<IEmailManager>(),
DependencyManager.Instance.Resolve<IExcelManager>(),
DependencyManager.Instance.Resolve<IWindowManager>());
这个系统的真正美妙之处在于,在测试我们的视图模型时,我们可以注册我们的模拟管理器类。 然后,相同的前面代码会将接口解析为它们的模拟具体实现,从而将我们的视图模型从它们的实际依赖关系中释放出来:
private void RegisterMockDependencies()
{
DependencyManager.Instance.ClearRegistrations();
DependencyManager.Instance.Register<IDataProvider, MockDataProvider>();
DependencyManager.Instance.Register<IEmailManager, MockEmailManager>();
DependencyManager.Instance.Register<IExcelManager, MockExcelManager>();
DependencyManager.Instance.Register<IWindowManager, MockWindowManager>();
}
我们现在已经看到了在测试应用程序时使我们能够将依赖类替换为模拟实现的代码。 然而,我们也看到并非我们所有的经理类都需要这个。 那么,究竟什么代表了依赖呢? 让我们看一个涉及 UI 弹出消息框的简单示例:
using CompanyName.ApplicationName.DataModels.Enums;
namespace CompanyName.ApplicationName.Managers.Interfaces
{
public interface IWindowManager
{
MessageBoxButtonSelection ShowMessageBox(string message,
string title, MessageBoxButton buttons, MessageBoxIcon icon);
}
}
在这里,我们有一个声明单个方法的接口。 这是开发人员需要在 UI 中显示消息框时从视图模型类调用的方法。 它将在运行时使用真正的 MessageBox
对象,但它使用 System.Windows 命名空间中的许多枚举。
我们希望避免与视图模型中的这些枚举实例进行交互,因为这需要添加对PresentationFramework
程序集的引用,并将我们的视图模型绑定到视图组件的一部分。
因此,我们需要从接口方法定义中抽象它们。 在本例中,我们只是用我们域中仅复制原始值的自定义枚举替换了PresentationFramework
程序集中的枚举。 因此,在这里显示这些自定义枚举的代码没有什么意义。
虽然重复代码从来都不是一个好主意,但向我们的 ViewModels 项目中添加像PresentationFramework 程序集这样的UI 程序集是一个更糟糕的主意。 通过将此程序集封装在 Managers 项目中并转换其枚举,我们可以从中公开所需的功能,而无需将其绑定到我们的视图模型:
using System.Windows;
using CompanyName.ApplicationName.Managers.Interfaces;
using MessageBoxButton =
CompanyName.ApplicationName.DataModels.Enums.MessageBoxButton;
using MessageBoxButtonSelection =
CompanyName.ApplicationName.DataModels.Enums.MessageBoxButtonSelection;
using MessageBoxIcon =
CompanyName.ApplicationName.DataModels.Enums.MessageBoxIcon;
namespace CompanyName.ApplicationName.Managers
{
public class WindowManager : IWindowManager
{
public MessageBoxButtonSelection ShowMessageBox(string message,
string title, MessageBoxButton buttons, MessageBoxIcon icon)
{
System.Windows.MessageBoxButton messageBoxButtons;
switch (buttons)
{
case MessageBoxButton.Ok: messageBoxButtons =
System.Windows.MessageBoxButton.OK; break;
case MessageBoxButton.OkCancel: messageBoxButtons =
System.Windows. MessageBoxButton.OkCancel; break;
case MessageBoxButton.YesNo: messageBoxButtons =
System.Windows.MessageBoxButton.YesNo; break;
case MessageBoxButton.YesNoCancel: messageBoxButtons =
System.Windows.MessageBoxButton.YesNoCancel; break;
default: messageBoxButtons =
System.Windows.MessageBoxButton.OKCancel; break;
}
MessageBoxImage messageBoxImage;
switch (icon)
{
case MessageBoxIcon.Asterisk:
messageBoxImage = MessageBoxImage.Asterisk; break;
case MessageBoxIcon.Error:
messageBoxImage = MessageBoxImage.Error; break;
case MessageBoxIcon.Exclamation:
messageBoxImage = MessageBoxImage.Exclamation; break;
case MessageBoxIcon.Hand:
messageBoxImage = MessageBoxImage.Hand; break;
case MessageBoxIcon.Information:
messageBoxImage = MessageBoxImage.Information; break;
case MessageBoxIcon.None:
messageBoxImage = MessageBoxImage.None; break;
case MessageBoxIcon.Question:
messageBoxImage = MessageBoxImage.Question; break;
case MessageBoxIcon.Stop:
messageBoxImage = MessageBoxImage.Stop; break;
case MessageBoxIcon.Warning:
messageBoxImage = MessageBoxImage.Warning; break;
default: messageBoxImage = MessageBoxImage.Stop; break;
}
MessageBoxButtonSelection messageBoxButtonSelection =
MessageBoxButtonSelection.None;
switch (MessageBox.Show(message, title, messageBoxButtons,
messageBoxImage))
{
case MessageBoxResult.Cancel: messageBoxButtonSelection =
MessageBoxButtonSelection.Cancel; break;
case MessageBoxResult.No: messageBoxButtonSelection =
MessageBoxButtonSelection.No; break;
case MessageBoxResult.OK: messageBoxButtonSelection =
MessageBoxButtonSelection.Ok; break;
case MessageBoxResult.Yes: messageBoxButtonSelection =
MessageBoxButtonSelection.Yes; break;
}
return messageBoxButtonSelection;
}
}
}
我们从 using 指令开始,并查看使用别名指令的更多示例。 在本例中,我们创建了一些与 System.Windows 命名空间中的枚举类同名的枚举类。 为了避免为 CompanyName.ApplicationName.DataModels.Enums 命名空间添加标准 using 指令而引起的冲突,我们添加别名以使我们能够仅使用我们需要的命名空间中的类型。
此后,我们的 WindowManager 类只需将 UI 相关的枚举值与我们的自定义枚举进行相互转换,以便我们可以使用消息框的功能,但不受其实现的束缚。 想象一下我们需要使用它来输出错误消息的情况:
WindowManager.ShowMessageBox(errorMessage, "Error", MessageBoxButton.Ok,
MessageBoxIcon.Error);
当执行到达此点时,将弹出一个消息框,显示一条带有错误图标和标题的错误消息。 应用程序将在此时冻结,同时等待用户反馈,如果用户没有单击弹出窗口上的按钮,它将无限期地保持冻结状态。 如果在单元测试期间执行到达此点并且没有用户单击按钮,那么我们的测试将无限期冻结并且永远不会完成。
在此示例中,WindowManager 类依赖于是否有用户与其交互。 因此,如果视图模型直接使用此类,它们也会具有相同的依赖关系。 例如,其他类可能依赖于电子邮件服务器、数据库或其他类型的资源。 这些是视图模型只能通过接口与之交互的类类型。
通过这样做,我们提供了相互独立地使用我们的组件的能力。 使用我们的 IWindowManager 接口,我们能够独立于最终用户使用我们的 ShowMessageBox 方法。 通过这种方式,我们能够打破用户依赖并在没有它们的情况下运行我们的单元测试。 我们的接口模拟实现每次都可以简单地返回一个肯定的响应,并且程序可以继续执行而不被注意:
using CompanyName.ApplicationName.DataModels.Enums;
using CompanyName.ApplicationName.Managers.Interfaces;
namespace Test.CompanyName.ApplicationName.Mocks.Managers
{
public class MockWindowManager : IWindowManager
{
public MessageBoxButtonSelection ShowMessageBox(string message,
string title, MessageBoxButton buttons, MessageBoxIcon icon)
{
switch (buttons)
{
case MessageBoxButton.Ok:
case MessageBoxButton.OkCancel:
return MessageBoxButtonSelection.Ok;
case MessageBoxButton.YesNo:
case MessageBoxButton.YesNoCancel:
return MessageBoxButtonSelection.Yes;
default: return MessageBoxButtonSelection.Ok;
}
}
}
}
这个简单的示例展示了另一种将功能从源公开到我们的视图模型的方法,但不会成为依赖项。 通过这种方式,我们可以为视图模型提供完整的主机和各种功能,同时仍然使它们能够独立运行。
我们现在拥有知识和工具,可以通过多种不同方式将功能构建到我们的应用程序框架中,但我们对应用程序框架的探索仍然不太完整。 另一件重要的事情是将我们的视图与视图模型连接起来。 我们需要决定框架的用户应该如何执行此操作,所以让我们看看一些选择。
将视图与视图模型连接起来
在 WPF 中,有几种方法可以将我们的视图连接到它们的数据源。 我们都在后面的代码中看到了 View 将其 DataContext 属性设置为自身的最简单方法的示例:
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = this;
}
}
但是,这只能用于快速演示,而不能用于我们的实际应用程序。 如果我们需要将数据绑定到视图代码后面声明的属性,比如说对于特定的自定义 UserControl,那么我们应该使用relativesource 绑定。 我们将在第 4 章“精通数据绑定”中了解更多相关信息,但现在,让我们继续研究将视图与其数据源连接的替代方法。
下一个最简单的方法是利用 WPF 框架中内置的数据模板模型。 第 4 章“精通数据绑定”中还将更详细地介绍该主题,但简而言之,DataTemplate 用于通知 WPF 框架我们希望它如何呈现特定类型的数据对象。 这个简单的例子展示了我们如何定义 User 对象的视觉输出:
<DataTemplate DataType="{x:Type DataModels:User}">
<TextBlock Text="{Binding Name}" />
</DataTemplate>
在此示例中,DataType 属性指定它与哪种类型的对象相关,从而指定包含的 XAML 绑定可以访问哪些属性。 现在保持简单,我们只输出此 DataTemplate 中每个用户的名称。 当我们将一个或多个 User 对象数据绑定到此 DataTemplate 范围内的 UI 控件时,它们将由 WPF 框架呈现为指定其名称的 TextBlock。
当 WPF 框架的呈现引擎遇到自定义数据对象时,它会查找已为其类型声明的 DataTemplate,如果找到,它会根据相关模板中包含的 XAML 呈现该对象。 这意味着我们可以为视图模型类创建一个 DataTemplate,它只需将其相关的视图类指定为渲染输出:
<DataTemplate DataType="{x:Type ViewModels:UsersViewModel}">
<Views:UsersView />
</DataTemplate>
在此示例中,我们指定当 WPF 框架看到 UserViewModel 类的实例时,它应将其呈现为 UserView 类之一。 此时,它会将我们的 View Model 实例隐式设置为相关 View 的 DataContext 属性。 此方法的唯一缺点是,我们必须为每个视图-视图模型对添加一个新的 DataTemplate 到 App.xaml 文件中。
这种连接方法首先适用于视图模型,我们提供视图模型实例,然后 WPF 框架负责其余的工作。 在这些情况下,我们通常使用 ContentControl,其 Content 属性数据绑定到 ViewModel 属性,应用程序视图模型设置为该属性。 WPF 框架记录所设置的视图模型的类型,并根据其指定的 DataTemplate 呈现它:
private BaseViewModel viewModel;
public BaseViewModel ViewModel
{
get { return viewModel; }
set { viewModel = value; NotifyPropertyChanged(); }
}
...
ViewModel = new UserViewModel();
...
<ContentControl Content="{Binding ViewModel}" />
对于许多人来说,这是视图到视图模型连接的首选版本,因为 WPF 框架负责处理大部分细节。 然而,还有另一种构建这些连接的方法,即为流程添加一个抽象层。
定位视图模型
对于此方法,我们需要为每个视图模型创建接口。 它称为视图模型位置,与我们已经看到的依赖注入示例非常相似。 事实上,我们甚至可以使用现有的 DependencyManager 来实现类似的结果。 让我们先快速浏览一下:
DependencyManager.Instance.Register<IUserViewModel, UserViewModel>();
...
public partial class UserView : UserControl
{
public UserView()
{
InitializeComponent();
DataContext = DependencyManager.Instance.Resolve<IUserViewModel>();
}
}
...
<Views:UsersView />
在此示例中,我们在一些初始化代码中将 IUserViewModel 接口与该接口的 UserViewModel 具体实现相关联,然后在将其设置为视图的 DataContext 值之前解析依赖关系。 在 XAML 中声明视图后,它们会在运行时自动将自己连接到相关的视图模型。
这种将视图连接到视图模型的方法首先对视图起作用,我们在其中声明视图,然后它实例化自己的视图模型并设置自己的 DataContext。 此方法的缺点是我们必须为所有视图模型创建一个接口,并使用 DependencyManager 注册和解析每个视图模型。
此实现与视图模型定位器的主要区别在于定位器提供了 Singleton 类的抽象级别,这使我们能够从 XAML 间接实例化视图模型,而无需使用后面的代码。 它们还有一些额外的特定功能,可以在设计时使用虚拟数据。 让我们看一个最简单的例子:
using CompanyName.ApplicationName.Managers;
using CompanyName.ApplicationName.ViewModels;
using CompanyName.ApplicationName.ViewModels.Interfaces;
namespace CompanyName.ApplicationName.Views.ViewModelLocators
{
public class ViewModelLocator
{
public IUserViewModel UserViewModel
{
get { return DependencyManager.Instance.Resolve<IUserViewModel>(); }
}
}
}
在这里,我们有一个非常基本的视图模型定位器,它可以简单地定位单个视图模型。 重要的是,此视图模型类有一个空构造函数,以便可以从 XAML 实例化它。 让我们看看如何做到这一点:
<UserControl x:Class="CompanyName.ApplicationName.Views.UserView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ViewModelLocators="clr-namespace:
CompanyName.ApplicationName.Views.ViewModelLocators"
Height="30" Width="300">
<UserControl.Resources>
<ViewModelLocators:ViewModelLocator x:Key="ViewModelLocator" />
</UserControl.Resources>
<UserControl.DataContext>
<Binding Path="UserViewModel"
Source="{StaticResource ViewModelLocator}" />
</UserControl.DataContext>
<TextBlock Text="{Binding User.Name}" />
</UserControl>
附带说明一下,您可能已经注意到我们的 ViewModelLocator 类已在 Views 项目中声明。 此类的位置不是很重要,但它必须引用 ViewModel 和 Views 项目,这严重限制了它可以驻留在其中的项目数量。 通常,唯一有权访问这两个项目中的类的项目是 Views 项目和启动项目。
回到我们的示例,ViewModelLocator 类的实例在 View 的 Resources 部分中声明,并且只有在我们有无参数构造函数时才有效(包括在我们没有显式声明构造函数时为我们声明的默认无参数构造函数) 。 如果没有无参数构造函数,我们将在 Visual Studio 设计器中收到错误。
这次,我们的视图使用 ViewModelLocator 资源中 UserViewModel 属性的绑定路径,在 XAML 中设置自己的 DataContext 属性。 然后该属性利用我们的 DependencyManager 来解析 IUserViewModel 接口的具体实现并为我们返回它。
使用这种模式还有其他好处。 WPF 开发人员经常面临的一个问题是 Visual Studio WPF 设计器无法解析用于支持其具体实现的接口,也无法在设计时访问应用程序数据源。 这样做的结果是设计者通常不会显示无法解析的数据项。
我们可以使用 ViewModelLocator 资源做的一件事是提供模拟视图模型,这些视图模型具有从其属性返回的虚拟数据,我们可以使用这些数据在构建视图时帮助可视化视图。 为了实现这一点,我们可以使用 DesignerProperties .NET 类中的 IsInDesignMode 附加属性:
public bool IsDesignTime
{
get { return
DesignerProperties.GetIsInDesignMode(new DependencyObject()); }
}
这里的 DependencyObject 对象是 Attached Property 所必需的,并且实际上是正在检查的对象。 由于此处提供的所有对象都会返回相同的值,因此我们每次都可以自由地使用新的值。 如果我们担心此属性会比垃圾收集器更频繁地被调用,我们可以选择使用单个成员,仅用于此目的:
private DependencyObject dependencyObject = new DependencyObject();
public bool IsDesignTime
{
get { return DesignerProperties.GetIsInDesignMode(dependencyObject); }
}
但是,如果我们仅出于此目的需要 DependencyObject 对象,那么我们可以通过从 DependencyObject 类扩展 ViewModelLocator 类并将其自身用作所需参数来进一步简化事情。 当然,这意味着我们的类将继承不需要的属性,因此有些人可能更愿意避免这样做。 让我们看看如何使用此属性在设计时为 WPF 设计器提供模拟数据:
using System.ComponentModel;
using System.Windows;
using CompanyName.ApplicationName.Managers;
using CompanyName.ApplicationName.ViewModels;
using CompanyName.ApplicationName.ViewModels.Interfaces;
namespace CompanyName.ApplicationName.Views.ViewModelLocators
{
public class ViewModelLocator : DependencyObject
{
public bool IsDesignTime
{
get { return DesignerProperties.GetIsInDesignMode(this); }
}
public IUserViewModel UserViewModel
{
get
{
return IsDesignTime ? new MockUserViewModel() :
DependencyManager.Instance.Resolve<IUserViewModel>();
}
}
}
}
如果您查看我们的 UserViewModel 属性,您将看到我们返回的值现在取决于 IsDesignTime 属性的值。 如果我们在设计时,例如,当在WPF设计器中打开View文件时,那么将返回MockUserViewModel类。 然而,在运行时,我们将返回我们在 DependencyManager 中注册的 IUserViewModel 接口的具体实现。
MockUserViewModel 类通常会对一些模拟数据进行硬编码,并在请求时从其属性中返回它。 通过这种方式,WPF 设计器将能够在开发人员或设计人员构建视图时将数据可视化。
但是,每个视图都需要我们的定位器类中的一个新属性,并且我们需要从前面的代码中为每个视图复制此条件运算符语句。 与 OOP 中一样,我们可以进行进一步的抽象,以向使用我们框架的开发人员隐藏该实现。 我们可以为视图模型定位器创建一个通用基类:
using System.ComponentModel;
using System.Windows;
using CompanyName.ApplicationName.Managers;
namespace CompanyName.ApplicationName.Views.ViewModelLocators
{
public abstract class BaseViewModelLocator<T> : DependencyObject
where T : class
{
private T runtimeViewModel, designTimeViewModel;
protected bool IsDesignTime
{
get { return DesignerProperties.GetIsInDesignMode(this); }
}
public T ViewModel
{
get { return IsDesignTime ?
DesignTimeViewModel : RuntimeViewModel; }
}
protected T RuntimeViewModel
{
get { return runtimeViewModel ??
(runtimeViewModel = DependencyManager.Instance.Resolve<T>()); }
}
protected T DesignTimeViewModel
{
set { designTimeViewModel = value; }
get { return designTimeViewModel; }
}
}
}
我们首先声明一个带有泛型类型参数的抽象类,该参数表示我们尝试查找的视图模型的接口类型。 再次注意在泛型类型参数上声明的泛型类型约束,该约束指定所使用的类型必须是类。 现在这是必需的,因为此类调用 DependencyManager 类的 Resolve 方法,并且在其上声明了相同的约束。
我们有两个相关类型的视图模型接口的内部成员,它们支持具有相同名称的属性。 其中一个用于运行时视图模型,一个用于设计时视图模型。 同一类型的第三个视图模型属性是我们将从视图进行数据绑定的属性,它使用我们的 IsDesignTime 属性来确定要返回哪个视图模型。
这个类的一个优点是它为开发人员完成了大量的连接工作。 他们不需要关心 IsDesignTime 属性的实现,这个基类甚至会尝试自动解析运行时视图模型属性的具体视图模型依赖项。 因此,开发人员只需为每个视图模型声明以下代码即可利用此功能:
using CompanyName.ApplicationName.ViewModels;
using CompanyName.ApplicationName.ViewModels.Interfaces;
namespace CompanyName.ApplicationName.Views.ViewModelLocators
{
public class UserViewModelLocator : BaseViewModelLocator<IUserViewModel>
{
public UserViewModelLocator()
{
DesignTimeViewModel = new MockUserViewModel();
}
}
}
它可以在 UI 中设置,与我们原来的定位器版本几乎没有区别:
<UserControl x:Class="CompanyName.ApplicationName.Views.UserView"
...
<UserControl.Resources>
<Locators:UserViewModelLocator x:Key="ViewModelLocator" />
</UserControl.Resources>
<UserControl.DataContext>
<Binding Path="ViewModel" Source="{StaticResource ViewModelLocator}" />
</UserControl.DataContext>
...
</UserControl>
请注意,虽然这应该在较新版本的 Visual Studio 中自动工作,但您可能需要为旧版本中的 WPF 设计器提供帮助。 mc:Ignorable 属性指定 XAML 处理器可以忽略标记文件中遇到的哪些 XAML 命名空间前缀,并且设计器使用 d XAML 命名空间,因此我们可以在设计时直接为其指定 DataContext 位置:
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DataContext="{Binding ViewModel,
Source={StaticResource ViewModelLocator}}"
虽然这种安排有明显的好处,但与往常一样,我们必须权衡任何此类抽象的成本是否值得所带来的好处。 对于某些人来说,提取接口、声明其模拟版本以在设计时使用以及为每个视图模型创建视图模型定位器的成本绝对值得设计可视化数据的视图的好处。
对于其他人来说,这根本不值得。 每次我们添加一个抽象级别时,我们都需要做更多的工作才能达到相同的最终目标。 我们需要确定每个抽象在我们自己的情况下是否可行,并相应地构建我们的应用程序框架。
总结
我们现在已经研究了拥有应用程序框架的好处并开始构建我们自己的应用程序框架。 我们发现了各种不同的方法来将所需的功能封装到我们的框架中,并知道在哪些情况下使用每种方法。在探索了许多管理器类之后,我们还开始公开来自外部源的功能,但不依赖于 他们。
我们已经设法维护和改进了应用程序所需的关注点分离,现在应该能够分离各种应用程序组件并彼此独立地运行它们。 我们还能够在设计时为视图设计者提供模拟数据,同时在运行时保持松散耦合。
在下一章中,我们将彻底研究数据绑定的基本主题,这是 MVVM 模式的极少数要求之一。 我们将全面介绍各种绑定语法(包括长记法和简写记法),发现为什么绑定在某些时候无法工作,并更好地理解如何按照我们想要的方式显示数据。