MVVM

利用 Windows 8 功能和 MVVM

Brent Edwards

下载代码示例

Windows 8 引入了许多新功能,开发人员可利用这些功能创建引人注目的应用程序和形式丰富的 UX。 遗憾的是,这些功能并非总是易于进行单元测试。 共享和辅助磁贴等功能可提高应用程序的互动性和趣味,但也会变得不太易于测试。

在本文中,我将介绍让应用程序可使用共享、设置、辅助磁贴、应用程序设置和应用程序存储等功能的多种不同方式。 通过使用模型-视图-视图模型 (MVVM) 模式、依赖注入和某些抽象,我将向您演示如何利用这些功能,同时将表示层保持易于进行单元测试。

关于示例应用程序

为了说明将在本文中谈论的概念,我已使用 MVVM 编写了一个示例 Windows 应用商店应用程序,用户使用它可通过其喜爱的博客的 RSS 源查看博客文章。 该应用程序说明了如何:

  • 通过“共享”超级按钮与其他应用程序共享有关某篇博客文章的信息
  • 用“设置”超级按钮更改用户要阅读的博客
  • 用辅助磁贴将喜爱的博客文章固定到“开始”屏幕供以后阅读
  • 保存喜爱的博客以供在所有具有漫游设置的设备上查看

除了该示例应用程序,我还使用了将在本文中谈论的特定 Windows 8 功能,并将其抽象化为一个名为 Charmed 的开源库。 Charmed 可用作帮助程序库或仅用作参考。 Charmed 的目标是成为一个适用于 Windows 8 和 Windows Phone 8 的跨平台 MVVM 支持库。 我将在以后的文章中详细谈论该库的 Windows Phone 8 一面。 可在 bit.ly/17AzFxW 了解 Charmed 库的进展。

我对于本文和示例代码的目标是演示我使用 Windows 8 提供的某些新功能开发采用 MVVM 模式的可测试应用程序的方法。

MVVM 概述

在深入探讨代码和特定 Windows 8 功能之前,我将简要介绍一下 MVVM。 MVVM 是近年来在基于 XAML 的技术方面广受青睐的一种设计模式,这些技术包括 Windows Presentation Foundation (WPF)、Silverlight、Windows Phone 7、Windows Phone 8 和 Windows 8(Windows Runtime,简称 WinRT)。 MVVM 将应用程序的体系结构划分为三个逻辑层: 模型、视图模型和视图,如图 1 所示。

The Three Logical Layers of Model-­View-ViewModel
图 1:模型-视图-视图模型的三个逻辑层

模型层涉及应用程序的业务逻辑,即业务对象、数据验证、数据访问等。 实际上,模型层通常分为更多层,甚至可能分为多个层级。 如图 1 所示,模型层是应用程序在逻辑意义上的底部,或称基础。

视图模型层容纳应用程序的表示逻辑,其中包括要显示的数据、帮助启用 UI 元素或使其可见的属性以及将同时与模型层和视图层进行交互的方法。 基本上,视图模型层是对于 UI 当前状态的一种与视图无关的表示形式。 我说“与视图无关”是因为它仅仅为要与之交互的视图提供数据和方法,而不指示该视图将如何表示数据,也不允许用户与这些方法进行交互。 如图 1 所示,视图模型层在逻辑上位于模型层与视图层之间,并可与后两者交互。 视图模型层包含以前将位于视图层的隐藏代码中的代码。

视图层包含应用程序的实际表示形式。 对于基于 XAML 的应用程序,如 Windows Runtime 应用程序,视图层主要(如果不是全部)由 XAML 构成。 视图层利用强大的 XAML 数据绑定引擎绑定到视图模型上的属性,同时将某种外观应用于在其他情况下没有可视化表示形式的数据。 如图 1 所示,视图层是应用程序在逻辑意义上的顶部。 视图层直接与视图模型层交互,但对模型层一无所知。

MVVM 模式的主要用途是将应用程序的表示形式与其功能相分离。 这样做使应用程序对于单元测试更加有益,因为功能现在位于普通旧 CLR 对象 (POCO) 中,而非自行决定生命周期的视图中。

合约

Windows 8 引入了合约的概念,即两个或更多应用程序对于用户系统达成的协议。 这些合约使所有应用程序保持一致,并使开发人员可从任何支持功能的应用程序中利用这些功能。 应用程序可在 Package.appxmanifest 文件中声明其支持的合约,如图 2 所示。

Contracts in the Package.appxmanifest File
图 2:Package.appxmanifest 文件中的合约

虽然支持合约并非必需,但一般来说这样做是个好主意。 尤其有三个合约应被应用程序支持:“共享”、“设置”和“搜索”,因为始终可通过超级按钮菜单使用这三项,如图 3 所示。

The Charms Menu
图 3:超级按钮菜单

我将重点介绍两种合约类型: “共享”和“设置”。

共享

通过“共享”合约,应用程序可与用户系统中的其他应用程序共享特定于上下文的数据。 “共享”合约有两个方面: 源和目标。 源是进行共享的应用程序。 它以所需的任何格式提供一些要共享的数据。 目标是接收共享数据的应用程序。 由于用户始终可通过超级按钮菜单使用“共享”超级按钮,因此我希望示例应用程序至少是一个共享源。 并非每个应用程序都需要成为共享目标,因为并非每个应用程序都需要接受来自其他源的输入。 但是,很有可能任何给定应用程序将至少有一件事值得与其他应用程序共享。 因此,大部分应用程序很可能将发现成为共享源很有用。

当用户按“共享”超级按钮时,一个名为共享代理的对象即开始此过程:取得某个应用程序共享的数据,然后将这些数据发送到用户指定的共享目标。 有一个名为 DataTransferManager 的对象,我可使用它在该过程中共享数据。 DataTransferManager 有一个名为 DataRequested 的事件,当用户按“共享”超级按钮时引发该事件。 以下代码演示如何引用 DataTransferManager 和订阅 DataRequested 事件:

public void Initialize()
{
  this.DataTransferManager = DataTransferManager.GetForCurrentView();
  this.DataTransferManager.DataRequested += 
    this.DataTransferManager_DataRequested;
}
private void DataTransferManager_DataRequested(
  DataTransferManager sender, DataRequestedEventArgs args)
{
  // Do stuff ...
}

调用 DataTransferManager.GetForCurrentView 将返回对当前视图的活动 DataTransferManager 的引用。 虽然可将这段代码放入视图模型,但它将产生 DataTransferManager 的强依赖项,一个无法在单元测试中模拟的密封类。 由于我确实希望尽可能可测试我的应用程序,因此这不是理想情况。 一个更好的解决方案是将 DataTransferManager 交互抽象化为一个帮助程序类,并为该帮助程序类定义一个要实现的接口。

将此交互抽象化之前,我必须决定哪些部分真正重要。 在与 DataTransferManager 的交互中,有三个部分引起我的关注:

  1. 激活我的视图时订阅 DataRequested 事件。
  2. 停用我的视图时取消订阅 DataRequested 事件。
  3. 可向 DataPackage 添加共享数据。

考虑到这三点,我的接口具体形式为:

public interface IShareManager
{
  void Initialize();
  void Cleanup();
  Action<DataPackage> OnShareRequested { get; set; }
}

Initialize 应引用 DataTransferManager 并订阅 DataRequested 事件。 Cleanup 应取消订阅 DataRequested 事件。 可在 OnShareRequested 中定义在引发 DataRequested 事件后调用什么方法。 现在我可以实现 IShareManager,如图 4 所示。

图 4:实现 IShareManager

public sealed class ShareManager : IShareManager
{
  private DataTransferManager DataTransferManager { get; set; }
  public void Initialize()
  {
    this.DataTransferManager = DataTransferManager.GetForCurrentView();
    this.DataTransferManager.DataRequested +=
      this.DataTransferManager_DataRequested;
  }
  public void Cleanup()
  {
    this.DataTransferManager.DataRequested -=
      this.DataTransferManager_DataRequested;
  }
  private void DataTransferManager_DataRequested(
    DataTransferManager sender, DataRequestedEventArgs args)
  {
    if (this.OnShareRequested != null)
    {
      this.OnShareRequested(args.Request.Data);
    }
  }
  public Action<DataPackage> OnShareRequested { get; set; }
}

当引发 DataRequested 事件时,所得的事件参数包含 DataPackage。 需要在该 DataPackage 中放置实际的共享数据,而这正是 OnShareRequested 的 Action 采用 DataPackage 作为参数的原因。 通过定义 IShareManager 接口并由 ShareManager 实现它,现已准备好在视图模型中加入共享,同时不会无法进行我以之为目标的单元测试。

使用特选的控制反转 (IoC) 容器向视图模型注入 IShareManager 实例后,即可将该模型投入使用,如图 5 所示。

图 5:接通 IShareManager

public FeedItemViewModel(IShareManager shareManager)
{
  this.shareManager = shareManager;
}
public override void LoadState(
  FeedItem navigationParameter, Dictionary<string, 
  object> pageState)
{
  this.shareManager.Initialize();
  this.shareManager.OnShareRequested = ShareRequested;
}
public override void SaveState(Dictionary<string, 
  object> pageState)
{
  this.shareManager.Cleanup();
}

在激活页面和视图模型时调用 LoadState,在停用页面和视图模型时调用 SaveState。 既然 ShareManager 已设置妥当并准备好处理共享,那么我需要实现将在用户发起共享时调用的 ShareRequested 方法。 我要共享有关某篇特定博客文章 (FeedItem) 的一些信息,如图 6 所示。

图 6:填充 ShareRequested 上的 DataPackage

private void ShareRequested(DataPackage dataPackage)
{
  // Set as many data types as possible.
  dataPackage.Properties.Title = this.FeedItem.Title;
  // Add a Uri.
  dataPackage.SetUri(this.FeedItem.Link);
  // Add a text-only version.
  var text = string.Format(
    "Check this out! {0} ({1})", 
    this.FeedItem.Title, this.FeedItem.Link);
  dataPackage.SetText(text);
  // Add an HTML version.
  var htmlBuilder = new StringBuilder();
  htmlBuilder.AppendFormat("<p>Check this out!</p>", 
    this.FeedItem.Author);
  htmlBuilder.AppendFormat(
    "<p><a href='{0}'>{1}</a></p>", 
    this.FeedItem.Link, this.FeedItem.Title);
  var html = HtmlFormatHelper.CreateHtmlFormat(htmlBuilder.ToString());
  dataPackage.SetHtmlFormat(html);
}

我决定共享多种不同的数据类型。 一般来说这是个好主意,因为无法控制用户在其系统中拥有什么应用程序或这些应用程序支持什么数据类型。 请记住,共享本质上是一种即发即弃的方案,这一点很重要。 您不知道用户将决定与什么应用程序进行共享以及该应用程序将对共享数据做什么。 为了与尽可能最广泛的受众进行共享,我提供一个标题、一个 URI、一个仅文本版本和一个 HTML 版本。

设置

通过“设置”合约,用户可更改应用程序中特定于上下文的设置。 这些设置可影响整个应用程序,也可仅影响与当前上下文相关的特定项。 Windows 8 的用户将习惯于使用“设置”超级按钮对应用程序作出更改,而我希望示例应用程序支持该超级按钮,因为用户始终可通过超级按钮菜单使用它。 实际上,如果应用程序通过 Package.appxmanifest 文件声明 Internet 功能,则它必须通过在“设置”菜单中的某处提供基于 Web 的隐私策略的链接,实现“设置”合约。 由于使用 Visual Studio 2012 模板的应用程序在产生后即自动声明 Internet 功能,因此不应忽视这一点。

当用户按“设置”超级按钮时,操作系统开始动态生成将显示的菜单。 菜单和关联的浮出控件由操作系统控制。 我无法控制菜单和浮出控件的外观,但我可向菜单添加选项。 一个名为 SettingsPane 的对象将在用户选择“设置”超级按钮时通过 CommandsRequested 事件通知我。 引用 SettingsPane 和订阅 CommandsRequested 事件颇为简单:

public void Initialize()
{
  this.SettingsPane = SettingsPane.GetForCurrentView();
  this.SettingsPane.CommandsRequested += 
    SettingsPane_CommandsRequested;
}
private void SettingsPane_CommandsRequested(
  SettingsPane sender, 
  SettingsPaneCommandsRequestedEventArgs args)
{
  // Do stuff ...
}

麻烦的是这又会产生一个硬依赖项。 这次,依赖项是 SettingsPane,它又是一个无法模拟的类。 由于我希望能够对使用 SettingsPane 的视图模型进行单元测试,因此我需要将对它的引用抽象化,如同我对于对 DataTransferManager 的引用所做的一样。 结果证明,我与 SettingsPane 的交互与我与 DataTransferManager 的交互非常类似:

  1. 订阅当前视图的 CommandsRequested 事件。
  2. 取消订阅当前视图的 CommandsRequested 事件。
  3. 在引发该事件时添加我自己的 SettingsCommand 对象。

因此,我需要抽象化的接口与 IShare­Manager 接口非常类似:

public interface ISettingsManager
{
  void Initialize();
  void Cleanup();
  Action<IList<SettingsCommand>> OnSettingsRequested { get; set; }
}

Initialize 应引用 SettingsPane 并订阅 CommandsRequested 事件。 Cleanup 应取消订阅 CommandsRequested 事件。 可在 OnSettingsRequested 中定义在引发 CommandsRequested 事件后调用什么方法。 现在我可以实现 ISettings­Manager,如图 7 所示。

图 7:实现 ISettingsManager

public sealed class SettingsManager : ISettingsManager
{
  private SettingsPane SettingsPane { get; set; }
  public void Initialize()
  {
    this.SettingsPane = SettingsPane.GetForCurrentView();
    this.SettingsPane.CommandsRequested += 
      SettingsPane_CommandsRequested;
  }
  public void Cleanup()
  {
    this.SettingsPane.CommandsRequested -= 
      SettingsPane_CommandsRequested;
  }
  private void SettingsPane_CommandsRequested(
    SettingsPane sender, SettingsPaneCommandsRequestedEventArgs args)
  {
    if (this.OnSettingsRequested != null)
    {
      this.OnSettingsRequested(args.Request.ApplicationCommands);
    }
  }
  public Action<IList<SettingsCommand>> OnSettingsRequested { get; set; }
}

当引发 CommandsRequested 事件时,事件参数最终允许我访问表示“设置”菜单选项的 SettingsCommand 对象的列表。 若要添加我自己的“设置”菜单选项,我只需要向该列表添加一个 SettingsCommand 实例。 SettingsCommand 对象要求的不多,仅仅是唯一标识符、标签文本和要在用户选择选项时执行的代码。

我使用 IoC 容器向视图模型注入一个 ISettingsManager 实例,然后设置它以进行初始化和清理,如图 8 所示。

图 8:接通 ISettingsManager

public ShellViewModel(ISettingsManager settingsManager)
{
  this.settingsManager = settingsManager;
}
public void Initialize()
{
  this.settingsManager.Initialize();
  this.settingsManager.OnSettingsRequested = 
    OnSettingsRequested;
}
public void Cleanup()
{
  this.settingsManager.Cleanup();
}

我将使用“设置”允许用户更改其可用示例应用程序查看哪些 RSS 源。 此时我希望用户可从应用程序中的任意位置进行更改,因此我已加入了 ShellViewModel,它在应用程序启动时即实例化。 如果我希望仅从其他某个视图中更改 RSS 源,则我要在关联的视图模型中加入设置代码。

Windows 运行时中缺少用于为设置创建浮出控件和维护它的内置功能。 为了获得应在所有应用程序间保持一致的功能,需要进行更多本不应进行的手动编码。 幸运的是,不仅是我有这种感觉。 Tim Heuer 是 Microsoft XAML 团队中的一名计划经理,它创造了一个杰出的框架,名为 Callisto,可帮助解决这一难点。 可在 GitHub (bit.ly/Kijr1S) 和 NuGet (bit.ly/112ehch) 上获得 Callisto。 我在示例应用程序中使用了它,建议您仔细研究一下它。

由于我在视图模型中完全接通了 SettingsManager,因此我只需提供要在请求设置时执行的代码,如图 9 所示。

图 9:用 Callisto 在 SettingsRequested 时显示 SettingsView

private void OnSettingsRequested(IList<SettingsCommand> commands)
{
  SettingsCommand settingsCommand =
    new SettingsCommand("FeedsSetting", "Feeds", (x) =>
  {
    SettingsFlyout settings = new Callisto.Controls.SettingsFlyout();
    settings.FlyoutWidth =
      Callisto.Controls.SettingsFlyout.SettingsFlyoutWidth.Wide;
    settings.HeaderText = "Feeds";
    var view = new SettingsView();
    settings.Content = view;
    settings.HorizontalContentAlignment = 
      HorizontalAlignment.Stretch;
    settings.VerticalContentAlignment = 
      VerticalAlignment.Stretch;
    settings.IsOpen = true;
  });
  commands.Add(settingsCommand);
}

我新建一个 SettingsCommand,向其提供 ID“FeedsSetting”和标签文本“Feeds”。我用于回调的 lambda(在用户选择“Feeds”菜单项时调用)利用了 Callisto 的 SettingsFlyout 控件。 SettingsFlyout 控件处理在何处放置浮出控件、决定其宽度以及何时打开和关闭它等重要工作。 我只需告诉它我需要宽版还是窄版,向其提供一些标题文本和内容,然后将 IsOpen 设置为 true 即可打开它。 我还建议将 HorizontalContentAlignment 和 VerticalContent­Alignment 设置为 Stretch。 否则,您的内容将不符合 SettingsFlyout 的大小。

消息总线

在处理“设置”合约时,一个要点是对设置的任何更改都应立即应用于应用程序并在应用程序中反映出来。 可使用多种方法将用户进行的设置更改广播出去。 我更愿意使用的方法是消息总线(也称为事件聚合器)。 消息总线是整个应用程序范围内的一种消息发布系统。 Windows 运行时中并未内置消息总线的概念,这意味着我不得不创建一个消息总线或使用其他框架中的消息总线。 我已加入了一个消息总线实现,而我已在许多项目中将其与 Charmed 框架配合使用。 可在 bit.ly/12EBHrb 上找到源代码。 还有许多其他好的实现。 Caliburn.Micro 具有 EventAggregator,而 MVVM Light 具有 Messenger。 所有实现通常都遵循同一模式,并提供订阅、取消订阅和发布消息的方式。

通过在设置方案中使用 Charmed 消息总线,我将 MainViewModel(显示源的那个模型)配置为订阅 FeedsChangedMessage:

this.messageBus.Subscribe<FeedsChangedMessage>((message) =>
  {
    LoadFeedData();
  });

将 MainViewModel 设置为侦听对源的更改后,我将 SettingsViewModel 配置为在用户添加或删除 RSS 源时发布 FeedsChanged­Message:

this.messageBus.Publish<FeedsChangedMessage>(new FeedsChangedMessage());

只要涉及消息总线,应用程序的每个部分就要使用同一消息总线实例,这一点很重要。 因此,我确保将我的 IoC 容器配置为向每个请求仅提供单一实例以解析 IMessageBus。

现在,示例应用程序经过设置,使用户可对通过“设置”超级按钮显示的 RSS 源作出更改并更新主视图以反映这些更改。

漫游设置

Windows 8 引入的另一个好东西是漫游设置的概念。 通过漫游设置,应用程序开发人员可在用户的所有设备中转移少量数据。 这些数据必须小于 100KB,并且应仅限于在所有设备上创造持久、自定义的 UX 所需的那些信息。 在示例应用程序的情况下,我希望能够在所有此类设备上保持用户要阅读的 RSS 源。

我先前谈论过的“设置”合约通常与漫游设置并用。 只有在具有漫游设置的设备上保持我允许用户使用“设置”合约做出的自定义保留才有意义。

访问漫游设置就像我到现在为止谈到的其他问题一样,比较简单。 通过 ApplicationData 类可同时访问 LocalSettings 和 RoamingSettings。 向 RoamingSettings 加入信息只需提供密钥和对象:

ApplicationData.Current.RoamingSettings.Values[key] = value;

虽然 ApplicationData 易于使用,但另有一个密封类在单元测试中无法模拟。 因此,为了尽可能可测试我的视图模型,我需要将与 ApplicationData 的交互抽象化。 在定义将漫游设置功能抽象化出的接口之前,我需要决定要对它做些什么:

  1. 查看是否存在密钥。
  2. 添加或更新设置。
  3. 删除设置。
  4. 获取设置。

现在我万事俱备,可创建一个名为 ISettings 的接口:

public interface ISettings
{
  void AddOrUpdate(string key, object value);
  bool TryGetValue<T>(string key, out T value);
  bool Remove(string key);
  bool ContainsKey(string key);
}

定义该接口后,需要实现它,如图 10 所示。

图 10:实现 ISettings

public sealed class Settings : ISettings
{
  public void AddOrUpdate(string key, object value)
  {
    ApplicationData.Current.RoamingSettings.Values[key] = value;
  }
  public bool TryGetValue<T>(string key, out T value)
  {
    var result = false;
    if (ApplicationData.Current.RoamingSettings.Values.ContainsKey(key))
    {
      value = (T)ApplicationData.Current.RoamingSettings.Values[key];
      result = true;
    }
    else
    {
      value = default(T);
    }
    return result;
  }
  public bool Remove(string key)
  {
    return ApplicationData.Current.RoamingSettings.Values.Remove(key);
  }
  public bool ContainsKey(string key)
  {
    return ApplicationData.Current.RoamingSettings.Values.ContainsKey(key);
  }
}

TryGetValue 将首先检查是否存在给定的密钥,如果存在,则向 out 参数赋值。如果未找到该密钥,它并不引发异常,而是返回一个布尔值,指示是否找到了该密钥。其余方法不言自明。

现在,可让 IoC 容器解析 ISettings,然后将其提供给 SettingsViewModel。这样做后,视图模型将使用这些设置加载用户的源以进行编辑,如图 11 所示。

图 11:加载并保存用户的源

 

public SettingsViewModel(
  ISettings settings,
  IMessageBus messageBus)
{
  this.settings = settings;
  this.messageBus = messageBus;
  this.Feeds = new ObservableCollection<string>();
  string[] feedData;
  if (this.settings.TryGetValue<string[]>(Constants.FeedsKey, out feedData))
  {
    foreach (var feed in feedData)
    {
      this.Feeds.Add(feed);
    }
  }
}
public void AddFeed()
{
  this.Feeds.Add(this.NewFeed);
  this.NewFeed = string.Empty;
  SaveFeeds();
}
public void RemoveFeed(string feed)
{
  this.Feeds.Remove(feed);
  SaveFeeds();
}
private void SaveFeeds()
{
  this.settings.AddOrUpdate(Constants.FeedsKey, this.Feeds.ToArray());
  this.messageBus.Publish<FeedsChangedMessage>(new FeedsChangedMessage());
}

关于图 11 中的代码要注意的一点是:实际保存到设置中的数据是一个字符串数组。 由于漫游设置限制为最大 100KB,因此需要使内容保持简洁并坚持使用基元类型。

辅助磁贴

开发出吸引用户参与的应用程序说得上是一个难题。 但在用户安装您的应用程序之后,怎样让他们不断地再次使用? 可帮助应对这种难题的一种方法是辅助磁贴。 通过辅助磁贴,可深入链接到应用程序中,从而使用户可跳过应用程序的其余部分,直达他们最关心的部分。 辅助磁贴固定在用户的主屏幕,上面显示您选择的图标。 点击辅助磁贴后,它就会启动您的应用程序,带有告知该应用程序去何处和加载什么的参数。 向用户提供辅助磁贴功能是让其可自定义其体验的好方法,这样使他们想再次使用。

辅助磁贴比我在本文中介绍的其他主题复杂,因为有许多东西必须先实现,然后使用辅助磁贴的完整体验才能正常发挥作用。

固定辅助磁贴涉及将 SecondaryTile 类实例化。 SecondaryTile 采用多个参数帮助它决定磁贴的外观,包括显示名称、要用于磁贴的徽标图像文件的 URI 以及在按该磁贴时将向应用程序提供的字符串参数。 将 SecondaryTile 实例化后,我必须调用一个方法,该方法最后将显示一个小型的弹出窗口,其中请求用户允许固定磁贴,如图 12 所示。

SecondaryTile Requesting Permission to Pin a Tile to the Start Screen
图 12:SecondaryTile 请求允许将磁贴固定到“开始”屏幕

用户按“固定到‘开始’屏幕”后,即完成前一半工作。 后一半是使用在按磁贴时它提供的参数配置应用程序,使其真正支持深入链接。 在我详细介绍后一半之前,我要谈论一下我将怎样以可测试的方式实现前一半。

由于 SecondaryTile 使用直接与操作系统交互的方法(接下来由操作系统显示 UI 组件),因此无法在不影响可测试性的前提下直接从视图模型中使用它。 因此,我将抽象化出另一个接口,我将其称为 ISecondaryPinner(通过它,我应可固定和取消固定磁贴以及检查磁贴是否已固定):

public interface ISecondaryPinner
{
  Task<bool> Pin(FrameworkElement anchorElement,
    Placement requestPlacement, TileInfo tileInfo);
  Task<bool> Unpin(FrameworkElement anchorElement,
    Placement requestPlacement, string tileId);
  bool IsPinned(string tileId);
}

注意,Pin 和 Unpin 都返回 Task<bool>。 这是因为 SecondaryTile 使用异步任务提示用户固定或取消固定磁贴。 这还意味着可等待 ISecondaryPinner 的 Pin 和 Unpin 方法。

另请注意,Pin 和 Unpin 均采用 FrameworkElement 和 Placement 枚举值作为参数。 原因是 SecondaryTile 需要矩形和 Placement 指示它将固定请求弹出窗口放在何处。 我打算让我的 SecondaryPinner 实现根据传入的 FrameworkElement 计算该矩形。

最后,我创建一个帮助器类 TileInfo 以传递由 SecondaryTile 使用的必要和可选参数,如图 13 所示。

图 13:TileInfo 帮助器类

public sealed class TileInfo
{
  public TileInfo(
    string tileId,
    string shortName,
    string displayName,
    TileOptions tileOptions,
    Uri logoUri,
    string arguments = null)
  {
    this.TileId = tileId;
    this.ShortName = shortName;
    this.DisplayName = displayName;
    this.Arguments = arguments;
    this.TileOptions = tileOptions;
    this.LogoUri = logoUri;
    this.Arguments = arguments;
  }
  public TileInfo(
    string tileId,
    string shortName,
    string displayName,
    TileOptions tileOptions,
    Uri logoUri,
    Uri wideLogoUri,
    string arguments = null)
  {
    this.TileId = tileId;
    this.ShortName = shortName;
    this.DisplayName = displayName;
    this.Arguments = arguments;
    this.TileOptions = tileOptions;
    this.LogoUri = logoUri;
    this.WideLogoUri = wideLogoUri;
    this.Arguments = arguments;
  }
  public string TileId { get; set; }
  public string ShortName { get; set; }
  public string DisplayName { get; set; }
  public string Arguments { get; set; }
  public TileOptions TileOptions { get; set; }
  public Uri LogoUri { get; set; }
  public Uri WideLogoUri { get; set; }
}

根据数据的不同,TileInfo 可使用两个构造函数。 现在,我实现 ISecondaryPinner,如图 14 所示。

图 14 实现 ISecondaryPinner

public sealed class SecondaryPinner : ISecondaryPinner
{
  public async Task<bool> Pin(
    FrameworkElement anchorElement,
    Placement requestPlacement,
    TileInfo tileInfo)
  {
    if (anchorElement == null)
    {
      throw new ArgumentNullException("anchorElement");
    }
    if (tileInfo == null)
    {
      throw new ArgumentNullException("tileInfo");
    }
    var isPinned = false;
    if (!SecondaryTile.Exists(tileInfo.TileId))
    {
      var secondaryTile = new SecondaryTile(
        tileInfo.TileId,
        tileInfo.ShortName,
        tileInfo.DisplayName,
        tileInfo.Arguments,
        tileInfo.TileOptions,
        tileInfo.LogoUri);
      if (tileInfo.WideLogoUri != null)
      {
        secondaryTile.WideLogo = tileInfo.WideLogoUri;
      }
      isPinned = await secondaryTile.RequestCreateForSelectionAsync(
        GetElementRect(anchorElement), requestPlacement);
    }
    return isPinned;
  }
  public async Task<bool> Unpin(
    FrameworkElement anchorElement,
    Placement requestPlacement,
    string tileId)
  {
    var wasUnpinned = false;
    if (SecondaryTile.Exists(tileId))
    {
      var secondaryTile = new SecondaryTile(tileId);
      wasUnpinned = await secondaryTile.RequestDeleteForSelectionAsync(
        GetElementRect(anchorElement), requestPlacement);
    }
    return wasUnpinned;
  }
  public bool IsPinned(string tileId)
  {
    return SecondaryTile.Exists(tileId);
  }
  private static Rect GetElementRect(FrameworkElement element)
  {
    GeneralTransform buttonTransform =
      element.TransformToVisual(null);
    Point point = buttonTransform.TransformPoint(new Point());
    return new Rect(point, new Size(
      element.ActualWidth, element.ActualHeight));
  }
}

Pin 将首先确保尚未存在所请求的磁贴,然后它将提示用户固定该磁贴。 Unpin 将首先确保已存在所请求的磁贴,然后它将提示用户取消固定该磁贴。 两者都将返回一个布尔值,指示固定或取消固定是否成功。

现在,可将一个 ISecondaryPinner 实例注入视图模型并将其投入使用,如图 15 所示。

图 15:用 ISecondaryPinner 进行固定和解除固定

public FeedItemViewModel(
  IShareManager shareManager,
  ISecondaryPinner secondaryPinner)
{
  this.shareManager = shareManager;
  this.secondaryPinner = secondaryPinner;
}
public async Task Pin(FrameworkElement anchorElement)
{
  var tileInfo = new TileInfo(
    FormatSecondaryTileId(),
    this.FeedItem.Title,
    this.FeedItem.Title,
    TileOptions.ShowNameOnLogo | TileOptions.ShowNameOnWideLogo,
    new Uri("ms-appx:///Assets/Logo.png"),
    new Uri("ms-appx:///Assets/WideLogo.png"),
    this.FeedItem.Id.ToString());
    this.IsFeedItemPinned = await this.secondaryPinner.Pin(
    anchorElement,
    Windows.UI.Popups.Placement.Above,
    tileInfo);
}
public async Task Unpin(FrameworkElement anchorElement)
{
  this.IsFeedItemPinned = !await this.secondaryPinner.Unpin(
    anchorElement,
    Windows.UI.Popups.Placement.Above,
    this.FormatSecondaryTileId());
}

在 Pin 中,我创建一个 TileInfo 帮助器实例,并向其提供一个格式独一无二的 ID、源标题、徽标和宽徽标的 URI 以及作为启动参数的源 ID。 Pin 将所单击的按键作为决定固定请求弹出窗口位置的定位元素。 我使用 SecondaryPinner.Pin 方法的结果判断源项是否已固定。

在 Unpin 中,我给出格式独一无二的磁贴 ID,并使用结果的颠倒形式判断源项是否仍固定。 又一次,将所单击的按键作为取消固定请求弹出窗口的定位元素传递给 Unpin。

将此安排妥当并使用它将一篇博客文章 (FeedItem) 固定到“开始”屏幕之后,点击新创建的磁贴即可启动应用程序。 但是,它启动应用程序的方式将与以前相同,即进入主页,显示所有博客文章。 我想让它进入我所固定的特定博客文章。 而这正是后一半功能发挥作用的地方。

后一半功能通过所启动的应用程序进入 app.xaml.cs,如图 16 所示。

图 16:启动应用程序

protected override async void OnLaunched(LaunchActivatedEventArgs args)
{
  Frame rootFrame = Window.Current.Content as Frame;
  if (rootFrame.Content == null)
  {
    Ioc.Container.Resolve<INavigator>().
      NavigateToViewModel<MainViewModel>();
  }
  if (!string.IsNullOrWhiteSpace(args.Arguments))
  {
    var storage = Ioc.Container.Resolve<IStorage>();
    List<FeedItem> pinnedFeedItems =
      await storage.LoadAsync<List<FeedItem>>(Constants.PinnedFeedItemsKey);
    if (pinnedFeedItems != null)
    {
      int id;
      if (int.TryParse(args.Arguments, out id))
      {
        var pinnedFeedItem = pinnedFeedItems.FirstOrDefault(fi => fi.Id == id);
        if (pinnedFeedItem != null)
        {
          Ioc.Container.Resolve<INavigator>().
            NavigateToViewModel<FeedItemViewModel>(
            pinnedFeedItem);
        }
      }
    }
  }
  Window.Current.Activate();
}

我向重写的 OnLaunched 方法的结尾添加了一些代码以检查是否已在启动过程中传入了参数。 如果已传入参数,则我将这些参数分析为要用作源 ID 的 int。 我从保存的源中获得具有该 ID 的源,然后将其传递给要显示的 FeedItemViewModel。 要注意的一点是,我确保该应用程序已显示主页,如果尚未显示主页,则我先导航到那里。 这样,用户可按后退按钮并进入主页,无论他是否已在运行应用程序都是如此。

总结

在本文中,我谈论了我的一种方法,该方法使用 MVVM 模式实现可测试的 Windows 应用商店应用程序,同时仍利用 Windows 8 提供的一些绝妙新功能。 具体而言,我谈到将共享、设置、漫游设置和辅助磁贴抽象化为实现可模拟接口的帮助器类。 通过此方法,我可以尽可能多地对视图模型功能进行单元测试。

既然已将这些视图模型设置得更加可进行测试,那么在以后的文章中,我将深入介绍有关可怎样真正编写对这些视图模型的单元测试。 我还将探讨可怎样应用同样这些方法以使视图模型可跨平台用于 Windows Phone 8,同时仍可测试这些模型。

稍作规划,即可创建具有创新 UX 的优秀应用程序,其中利用 Windows 8 的重要新功能,同时并不影响最佳实践或单元测试。

Brent Edwards是 Magenic 的一名副首席咨询顾问,这是一家定制应用程序开发公司,主要从事 Microsoft 系列产品和移动应用程序的开发。 他还是位于明尼苏达州明尼阿波利斯的 Twin Cities Windows 8 User Group 的联合创始人。 可通过 brente@magenic.com 与他联系。

衷心感谢以下技术专家对本文的审阅: Rocky Lhotka (Magenic)
Rockford Lhotka 是 Magenic 的 CTO,他创作了广为使用的 CSLA .NET 开发框架。 他著书众多,并经常在世界各地的大型会议上发言。 Rockford 是一名 Microsoft 技术代言人和 MVP。 Magenic (www.magenic.com) 是一家专门规划、设计、生成和维护企业对任务最关键的系统的公司。 有关详细信息,请访问 www.lhotka.net