MVVM

使用 MVVM 编写跨平台表示层

Brent Edwards

下载代码示例

随着 Windows 8 和 Windows Phone 8 的发布,Microsoft 向着真正的跨平台开发迈出了重大一步。 二者在相同的内核上运行,这意味着只需进行少量的规划,您的大部分应用程序代码就可以在两种系统中重复使用。 通过利用“模型-视图-视图模型”模式以及其他一些常用设计模式和技巧,您可以编写同时运行于 Windows 8 和 Windows Phone 8 的跨平台表示层。

在本文中,我将审视自己所面临的一些特定跨平台挑战,同时谈一谈可以运用哪些解决方案,以便让我的应用程序既保持完全的关注点分离,又无需牺牲为其编写良好单元测试的能力。

关于示例应用程序

在 2013 年 7 月期的《MSDN 杂志》中,我提供了一个示例 Windows 应用商店应用程序的代码,以及我开发的一个开源跨平台框架的开头部分,此框架名为 Charmed(“利用 Windows 8 功能和 MVVM”msdn.microsoft.com/magazine/dn296512)。 在本文中,我将向您演示我如何采用该示例应用程序和框架,并使之通过演化而变得更富跨平台特性。 我还使用同一框架开发了一个基本功能相同的配套 Windows Phone 8 应用程序。 此框架和示例应用程序可在 GitHub 上找到 (github.com/brentedwards/Charmed)。 随着我继续发表 MVVM 系列文章,此代码将继续演化;在此系列中,我将深入研究如何实际测试表示层以及有关可测试代码的一些附加注意事项。

此应用程序是一个简单的博客阅读器,名为 Charmed Reader。 此应用程序的每个平台版本都具有足够的功能来演示与跨平台开发有关的一些关键概念。 两种版本所提供的用户体验类似,但外观仍与各自的操作系统相契合。

解决方案结构

对于 Visual Studio 2012 跨平台开发来说,任何适当的讨论都必须从解决方案结构开始。 虽然 Windows 8 和 Windows Phone 8 的确在相同内核上运行,但它们的应用程序仍采用不同的编译方式,并且具有不同的项目类型。 可使用多种方法创建包含不同项目类型的解决方案,但我倾向于将所有平台特定的项目都包含在同一解决方案中。 图 1 显示了我要讨论的示例应用程序的解决方案结构。

Cross-Platform Charmed Reader Solution Structure
图 1 跨平台 Charmed 读取器解决方案结构

使用 Visual Studio,您可以使多个项目文件引用单一物理类文件,以便通过选择“添加为链接”来添加现有类文件(如图 2 所示)。

Adding an Existing Item with Add As Link
图 2 使用“添加为链接”功能添加现有项目

通过使用“添加为链接”功能,我可以一次编写很多代码,将其用于 Windows 8 和 Windows Phone 8 两种平台。 但是,我不想对每个类文件都这样做。 正如我将要演示的,有些情况下,每种平台将拥有自己的实现。

视图差异

虽然我可以重复使用大部分包含表示逻辑的 C# 代码,但无法重复使用通过 XAML 编写的实际表示代码。 这是因为 Windows 8 和 Windows Phone 8 的 XAML 风格略有不同,二者无法充分配合从而实现互换。 问题一方面在于语法,特别是命名空间声明,但主要是由于两种平台的可用控件不同以及所实施的样式概念不同。 例如,Windows 8 大量采用 GridView 和 ListView,但这些控件不适用于 Windows Phone 8。 另一方面,Windows Phone 8 具有透视控件和 LongListSelector,同样也不适用于 Windows 8。

尽管缺乏 XAML 代码可重用性,但两种平台仍可以共用一些设计资源,特别是在 Windows Phone UI 设计方面。 这是因为 Windows 8 具有对齐视图 (Snap View) 概念,其宽度固定为 320 像素。 对齐视图之所以采用 320 像素,是因为移动设计人员多年来一直针对 320 像素的屏幕宽度进行设计。 在跨平台开发中,这对我很有利,因为我不需要提出全新的对齐视图设计,只需调整我的 Windows Phone 设计即可。 当然,我必须考虑到每种平台都有自己独特的设计原则,因此我可能得稍作改变,以便让每个应用程序都在其平台上显得自然。

图 3 所示,我实现的 Windows 8 对齐视图 UI 与 Windows Phone 8 UI 非常类似,但并不完全相同。 当然,我不是设计人员,这一点从我完全无趣的 UI 设计上就可以看出来。 但是,我希望这可以说明 Windows 8 对齐视图与 Windows Phone 8 在 UI 设计方面能够达到怎样的相似程度。

Sample App UI for Windows 8 Snap View (left) and Windows Phone 8 (right)
图 3 Windows 8 对齐视图(左)和 Windows Phone 8(右)的示例应用程序 UI

代码差异

当我着手进行 Windows 8 和 Windows Phone 8 跨平台开发时,我所面临的一个有趣的挑战是,每种平台处理特定任务的方式各不相同。 例如,虽然两种平台遵循同一种基于 URI 的导航方案,但所采用的参数却不相同。 辅助磁贴的创建也不相同。 虽然两种平台都支持辅助磁贴,但当点击辅助磁贴时,每种平台上所产生的结果完全不同。 每种平台还采用自己的方式来处理应用程序设置,并且具有不同的类来与这些设置进行交互。

最后,还有一些 Windows 8 具备但 Windows Phone 8 不具备的功能。 在我的 Windows 8 示例应用程序中,所采用的不为 Windows Phone 8 支持的主要概念是合约和超级按钮菜单。 这意味着 Windows Phone 不支持“共享”或“设置”超级按钮。

那么,您如何处理这些根本性的代码差异呢? 为此,您可以采用很多技巧。

编译器指令当 Visual Studio 创建 Windows 8 和 Windows Phone 8 项目类型时,会自动在项目设置中定义平台特定的编译器指令:Windows 8 为 ­NETFX_CORE,Windows Phone 8 为 WINDOWS_PHONE。 通过利用这些编译器指令,您可以告知 Visual Studio 应该为每种平台分别编译哪些内容。 在众多可采用的技巧中,这是最基本也最麻烦的一种。 结果会导致代码有一点像瑞士奶酪:漏洞百出。 虽然有时这是一种无法避免的灾难,但很多情况下可以采用一些更好的技巧。

抽象这是我用来处理平台差异的最简洁的技巧,它需要将平台特定的功能抽象到一个接口或抽象类中。 使用此技巧,您可以提供平台特定的接口实现,同时提供在整个代码库中使用的一致界面。 如果有可供每个平台特定的实现使用的帮助程序代码,您就可以使用这种通用帮助程序代码实施一个抽象类,然后提供平台特定的实现。 这种技巧要求接口或抽象类可通过上文提到的“添加为链接”功能同时用于两种项目。

抽象加编译器指令最后一种可使用的技巧是前两种技巧的组合。 您可以将平台差异抽象到接口或抽象类,然后在实际实现中使用编译器指令。 当平台差异非常细微,不值得针对每种项目类型对其区别对待时,使用这种方法非常便捷。

实际上,我发现自己很少单独使用编译器指令,特别是在视图模型中。 我喜欢尽可能让视图模型保持简洁。 因此,当最好采用编译器指令时,我通常也会插入一些抽象技巧,以便稍微掩盖一下类似瑞士奶酪的不足之处。

导航

在我的跨平台开发过程中,我遇到的首要挑战之一便是导航。 Windows 8 和 Windows Phone 8 中的导航不尽相同,但二者非常接近。 Windows 8 现在采用基于 URI 的导航,而 Windows Phone 自始至终一直在使用它。 二者的区别在于参数的传递方式。 Windows 8 仅接受单个对象作为参数,而 Windows Phone 8 则可以接受任意多个参数,但需要采用查询字符串的形式。 由于 Windows Phone 采用查询字符串,因此所有参数必须序列化为一个字符串。 事实证明,Windows 8 在这方面的差别并不太大。

虽然 Windows 8 仅接受单个对象作为参数,但当另一个应用程序占据中心位置并且我的应用程序停用时,此对象必须以某种方式进行序列化。 操作系统会选择一种简单的方法并对该参数调用 ToString,而当我的应用程序再次激活时,这对我的帮助并不大。 就我的开发工作来说,我希望尽可能将这两种平台集合在一起,因此有必要在导航前将我的参数序列化为一个字符串,然后在导航完成后进行反序列化。 我甚至可以通过实施导航器来为这一过程提供便利。

请注意,我想要避免直接引用视图模型中的视图,因此我希望实现视图模型驱动的导航。 我的解决方案是采用一种约定,其中将视图置于 Views 命名空间/文件夹中,而将视图模型置于 ViewModels 命名空间/文件夹中。 我还将确保将我的视图命名为 {Something}Page,将我的视图模型命名为 {Something}ViewModel。 通过采用这种规定,我可以提供一些简单的逻辑来根据视图模型类型解析视图实例。

接下来,我需要确定还需要哪些导航功能:

  • 视图模型驱动的导航
  • 返回功能
  • Windows Phone 8 中删除 Back 堆栈条目的功能

前两个功能非常简单。 稍后我将说明为何需要删除 Back 堆栈条目,但此功能会内置到 Windows Phone 8 而不会内置到 Windows 8 中。

Windows 8 和 Windows Phone 8 的导航均采用不易模拟的类。 由于我这些应用程序的主要目标之一是保持其可测试性,因此我希望将此代码抽象到可模拟的接口之后。 因此,我的导航器将采用抽象和编译器指令组合。 这样一来,我的接口便如下所示:

public interface INavigator
{
  bool CanGoBack { get; }
  void GoBack();
  void NavigateToViewModel<TViewModel>(object parameter = null);
#if WINDOWS_PHONE
  void RemoveBackEntry();
#endif // WINDOWS_PHONE
}

请注意 #if WINDOWS_PHONE 的使用。 这会告知编译器仅当定义 WINDOWS_PHONE 编译器指令时才可将 RemoveBackEntry 编译到接口定义,因为此功能将用于 Windows Phone 8 项目。 下面就是我的实现,如图 4 所示。

图 4 实现 INavigator

public sealed class Navigator : INavigator
{
  private readonly ISerializer serializer;
  private readonly IContainer container;
#if WINDOWS_PHONE
  private readonly Microsoft.Phone.Controls.PhoneApplicationFrame frame;
#endif // WINDOWS_PHONE
  public Navigator(
    ISerializer serializer,
    IContainer container
#if WINDOWS_PHONE
    , Microsoft.Phone.Controls.PhoneApplicationFrame frame
#endif // WINDOWS_PHONE
    )
  {
    this.serializer = serializer;
    this.container = container;
#if WINDOWS_PHONE
    this.frame = frame;
#endif // WINDOWS_PHONE
  }
  public void NavigateToViewModel<TViewModel>(object parameter = null)
  {
    var viewType = ResolveViewType<TViewModel>();
#if NETFX_CORE
    var frame = (Frame)Window.Current.Content;
#endif // NETFX_CORE
      if (parameter != null)
                             {
#if WINDOWS_PHONE
      this.frame.Navigate(ResolveViewUri(viewType, parameter));
#else
      frame.Navigate(viewType, this.serializer.Serialize(parameter));
#endif // WINDOWS_PHONE
    }
    else
    {
#if WINDOWS_PHONE
      this.frame.Navigate(ResolveViewUri(viewType));
#else
      frame.Navigate(viewType);
#endif // WINDOWS_PHONE
    }
  }
  public void GoBack()
  {
#if WINDOWS_PHONE
    this.frame.GoBack();
#else
    ((Frame)Window.Current.Content).GoBack();
#endif // WINDOWS_PHONE
  }
  public bool CanGoBack
  {
    get
    {
#if WINDOWS_PHONE
      return this.frame.CanGoBack;
#else
      return ((Frame)Window.Current.Content).CanGoBack;
#endif // WINDOWS_PHONE
    }
  }
  private static Type ResolveViewType<TViewModel>()
  {
    var viewModelType = typeof(TViewModel);
    var viewName = viewModelType.AssemblyQualifiedName.Replace(
      viewModelType.Name,
      viewModelType.Name.Replace("ViewModel", "Page"));
    return Type.GetType(viewName.Replace("Model", string.Empty));
  }
  private Uri ResolveViewUri(Type viewType, object parameter = null)
  {
    var queryString = string.Empty;
    if (parameter != null)
    {
      var serializedParameter = this.serializer.Serialize(parameter);
      queryString = string.Format("?parameter={0}", serializedParameter);
    }
    var match = System.Text.RegularExpressions.Regex.Match(
      viewType.FullName, @"\.Views.*");
    if (match == null || match.Captures.Count == 0)
    {
      throw new ArgumentException("Views must exist in Views namespace.");
    }
    var path = match.Captures[0].Value.Replace('.', '/');
    return new Uri(string.Format("{0}.xaml{1}", path, queryString),
      UriKind.Relative);
  }
#if WINDOWS_PHONE
  public void RemoveBackEntry()
  {
    this.frame.RemoveBackEntry();
  }
#endif // WINDOWS_PHONE
}

我需要在图 4 中着重指出导航器实现的几个部分,尤其是 WINDOWS_PHONE 和 NETFX_CORE 两种编译器指令的使用。 这样一来,我便可以在同一代码文件中隔离平台特定的代码。 我还想要指明 ResolveViewUri 方法,特别是查询字符串的定义方法。 为尽可能在两种平台之间保持一致,我将仅允许传递一个参数。 随后,这个参数将进行序列化,并在平台特定的导航中传递。 在 Windows Phone 8 中,该参数将通过查询字符串中的“参数”变量传递。

当然,我的导航器实现非常有限,特别是由于过于简单的约定预期所致。 如果使用诸如 Caliburn.Micro 等 MVVM 库,它便可以通过一种更可靠的方式来处理您的实际导航。 然而,在您自己的导航中,您可能仍需要应用此抽象加编译器指令技巧,以便消除库本身所存在的平台差异。

应用程序设置

应用程序设置是 Windows 8 与 Windows Phone 8 的另一个不同之处。 每种平台都能够非常轻松地保存应用程序设置,而且它们的实现也相当类似。 它们的不同之处在于所使用的类,但二者都采用不易模拟的类,会破坏我的视图模型的可测试性。 因此,我将再一次选择抽象加编译器指令。 我必须先确定我的接口应该是什么样子。 接口必须:

  • 添加或更新设置
  • 尝试获取设置,而无需在失败时抛出异常
  • 删除设置
  • 确定给定密钥是否存在设置

要求非常简单,因此我的接口也会非常简单:

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);
}

两种平台将具有相同的功能,因此我无需为接口中的编译器指令而烦扰,从而让我的视图模型变得美观而简洁。 图 5 显示了我的 ISettings 接口实现。

图 5:实现 ISettings

public sealed class Settings : ISettings
{
  public void AddOrUpdate(string key, object value)
  {
#if WINDOWS_PHONE
    IsolatedStorageSettings.ApplicationSettings[key] = value;
    IsolatedStorageSettings.ApplicationSettings.Save();
#else
    ApplicationData.Current.RoamingSettings.Values[key] = value;
#endif // WINDOWS_PHONE
  }
  public bool TryGetValue<T>(string key, out T value)
  {
#if WINDOWS_PHONE
    return IsolatedStorageSettings.ApplicationSettings.TryGetValue<T>(
      key, out value);
#else
    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;
#endif // WINDOWS_PHONE
  }
  public bool Remove(string key)
  {
#if WINDOWS_PHONE
    var result = IsolatedStorageSettings.ApplicationSettings.Remove(key);
    IsolatedStorageSettings.ApplicationSettings.Save();
    return result;
#else
    return ApplicationData.Current.RoamingSettings.Values.Remove(key);
#endif // WINDOWS_PHONE
  }
  public bool ContainsKey(string key)
  {
#if WINDOWS_PHONE
    return IsolatedStorageSettings.ApplicationSettings.Contains(key);
#else
    return ApplicationData.Current.RoamingSettings.Values.ContainsKey(key);
#endif // WINDOWS_PHONE
  }
}

就像 ISettings 接口本身一样,图 5 中显示的实现也非常简单。 它说明了每种平台之间只是略有不同,只需要稍微不同的代码来添加、检索和删除应用程序设置。 我要指出的一点是,Windows 8 版本的代码采用漫游设置,这是一种允许应用程序在云端存储其设置的 Windows 8 特定功能,这样用户便可以在另一个 Windows 8 设备上打开应用程序,同时应用相同的设置。

辅助磁贴

我前面说过,Windows 8 和 Windows Phone 8 都支持创建辅助磁贴,这种磁贴以编程方式创建并固定在用户的主屏幕上。 辅助磁贴提供了深度链接功能,这意味着用户可以通过点击辅助磁贴直接跳转到应用程序的特定部分。 这对用户来说非常有用,因为他们实际上可以为应用程序的某些部分创建标签,无需浪费时间即可直接跳转到这些部分。 就我的示例应用程序来说,我希望用户能够为单一博客文章 (FeedItem) 创建标签,然后从主屏幕直接跳转到该文章。

辅助磁贴有趣的地方在于,虽然两种平台都支持辅助磁贴,但我必须采用非常不同的方式在每种平台中实现此功能。 这为探索更加复杂的抽象示例提供了一个绝佳的案例。

如果您读过我在 2013 年 7 月发表的文章,就可能会记得我曾经谈过如何在 Windows 8 的 MVVM 开发中抽象化辅助磁贴。 我当时提供的解决方案完美适用于 Windows 8,但该解决方案甚至无法针对 Windows Phone 8 进行编译。 现在我要介绍该 Windows 8 解决方案的自然演化版,它在 Windows 8 和 Windows Phone 8 中都可以取得良好的效果。

以下是此接口在 Windows 8 中的实现:

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);
}

我前面说过,此接口无法针对 Windows Phone 8 进行编译。 FrameworkElement 和 Placement 枚举的使用尤其存在问题。 在 Windows Phone 8 和 Windows 8 中,二者均不相同。 我的目标是稍微修改此接口,以便它能够顺利地供两种平台使用。 如您所见,ISecondaryPinner.Pin 方法是将 TileInfo 对象作为参数。 TileInfo 是我创建的简单数据传输对象 (DTO),其中包含创建辅助磁贴所需的相关信息。 对我来说,最容易做的是将 Windows 8 版本所需的参数移到 TileInfo 类中,然后使用编译器指令将它们编译到 Windows 8 版本的 TileInfo 类。 这样做会使我的 ISecondaryPinner 接口发生如下更改:

public interface ISecondaryPinner
{
  Task<bool> Pin(TileInfo tileInfo);
  Task<bool> Unpin(TileInfo tileInfo);
  bool IsPinned(string tileId);
}

您可以看到方法仍然完全相同,但 Pin 和 Unpin 的参数已然略微发生变化。 因此,TileInfo 类也与之前相比发生了变化,现在如下所示:

public sealed class TileInfo
{
  public string TileId { get; set; }
  public string ShortName { get; set; }
  public string DisplayName { get; set; }
  public string Arguments { get; set; }
  public Uri LogoUri { get; set; }
  public Uri WideLogoUri { get; set; }
  public string AppName { get; set; }
  public int? Count { get; set; }
#if NETFX_CORE
  public Windows.UI.StartScreen.TileOptions TileOptions { get; set; }
  public Windows.UI.Xaml.FrameworkElement AnchorElement { get; set; }
  public Placement RequestPlacement { get; set; }
#endif // NETFX_CORE
}

实际上,对于每个像这样使用帮助程序 DTO 的情景,我更倾向于提供构造函数,以便更明确地指明哪些时间需要哪些参数。 为了简洁起见,我未在 TileInfo 代码段中包括各种构造函数,但您可以在示例代码中完整地看到它们。

TileInfo 现在已经拥有 Windows 8 和 Windows Phone 8 二者所需的全部属性,因此,接下来要做的便是实现 ISecondaryPinner 接口。 每种平台的实现大不相同,因此,我将在两种项目类型中使用同一接口,但分别提供平台特定的实现。 这会减少编译器指令在这种情况下会造成的瑞士奶酪效应。 图 6 显示了 ISecondaryPinner 的 Windows 8 实现,此时已具有更新后的方法签名。

图 6 在 Windows 8 中实现 ISecondaryPinner

public sealed class Win8SecondaryPinner : ISecondaryPinner
{
  public async Task<bool> Pin(TileInfo tileInfo)
  {
    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(tileInfo.AnchorElement), tileInfo.RequestPlacement);
    }
    return isPinned;
  }
  public async Task<bool> Unpin(TileInfo tileInfo)
  {
    var wasUnpinned = false;
    if (SecondaryTile.Exists(tileInfo.TileId))
    {
      var secondaryTile = new SecondaryTile(tileInfo.TileId);
      wasUnpinned = await secondaryTile.RequestDeleteForSelectionAsync(
        GetElementRect(tileInfo.AnchorElement), tileInfo.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));
  }
}

值得注意的是,在 Windows 8 中,您无法在未经用户批准的情况下以编程方式悄悄创建辅助磁贴。 这一点与 Windows Phone 8 不同,后者允许您这样做。 因此,我必须创建一个 SecondaryTile 实例并调用 RequestCreateForSelectionAsync,以便在我提供的位置弹出一个对话框,提示用户批准创建(或删除)辅助磁贴。 帮助程序方法 GetElementRect 会接受 FrameworkElement(用户将按此按钮来固定辅助磁贴),随后计算其矩形以便用于定位请求对话框。

图 7 显示了 ISecondaryPinner 的 Windows Phone 8 实现。

图 7 在 Windows Phone 8 中实现 ISecondaryPinner

public sealed class WP8SecondaryPinner : ISecondaryPinner
{
  public Task<bool> Pin(TileInfo tileInfo)
  {
    var result = false;
    if (!this.IsPinned(tileInfo.TileId))
    {
      var tileData = new StandardTileData
      {
        Title = tileInfo.DisplayName,
        BackgroundImage = tileInfo.LogoUri,
        Count = tileInfo.Count,
        BackTitle = tileInfo.AppName,
        BackBackgroundImage = new Uri("", UriKind.Relative),
        BackContent = tileInfo.DisplayName
      };
      ShellTile.Create(new Uri(tileInfo.TileId, UriKind.Relative), 
        tileData);
      result = true;
    }
  return Task.FromResult<bool>(result);
  }
  public Task<bool> Unpin(TileInfo tileInfo)
  {
    ShellTile tile = this.FindTile(tileInfo.TileId);
    if (tile != null)
    {
      tile.Delete();
    }
    return Task.FromResult<bool>(true);
  }
  public bool IsPinned(string tileId)
  {
    return FindTile(tileId) != null;
  }
  private ShellTile FindTile(string uri)
  {
    return ShellTile.ActiveTiles.FirstOrDefault(
      tile => tile.NavigationUri.ToString() == uri);
  }
}

在 Windows Phone 8 实现中,我想要指出以下几点。 首先是如何使用 StandardTileData 类和静态 ShellTile.Create 方法创建辅助磁贴。 其次,在 Windows Phone 8 实现中,辅助磁贴的创建是异步的。 由于 Windows 8 实现是异步的,因此我必须使接口支持异步/等待模式。 幸运的是,通过使用静态、通用的 Task.FromResult 方法,可以非常轻松地使原本非异步的方法支持异步/等待模式。 事实上,Windows 8 本身就是异步而 Windows Phone 8 本身则不是异步,但使用 ISecondaryPinner 接口的视图模型无需为此而烦恼。

您现在可以看到 Windows 8(图 6)和 Windows Phone 8(图 7)在其辅助磁贴的实现上有何不同。 但是,辅助磁贴的讨论还没有结束。 我只是演示了 ISecondaryPinner 接口的实现。 由于每种平台都各不相同,并且必须为不同的 TileInfo 类属性提供值,因此对于采用这些属性的各个视图模型,我还必须提供其平台特定的实现。 在我的示例应用程序中,我将提供固定单一博客文章 (FeedItem) 的功能,因此相关视图模型为 FeedItemViewModel。

从视图模型的角度看,一些通用功能确实同时存在于 Windows 8 和 Windows Phone 8 中。 当用户固定 FeedItem 时,我想要让两种平台都将该 FeedItem 保存在本地,以便在用户点击其辅助磁贴时可以将其重新加载。 另一方面,当用户将 FeedItem 取消固定时,我想要让两种平台都从其本地存储删除该 FeedItem。 两种平台都需要实现此通用功能,还需通过平台特定的辅助磁贴功能实现对其进行扩展。 因此,有必要提供一个基类来实现此通用功能,并且使这一基类可同时用于两种平台。 然后,每种平台都可以继承该基类和一些平台特定的类,后者可以为辅助磁贴的固定和取消固定操作提供平台特定的实现。

图 8 显示了 FeedItemViewModel,即两种平台都会继承的基类。 FeedItemViewModel 包含两种平台的所有通用内容。

图 8 FeedItemViewModel 基类

public abstract class FeedItemViewModel : ViewModelBase<FeedItem>
{
  private readonly IStorage storage;
  protected readonly ISecondaryPinner secondaryPinner;
  public FeedItemViewModel(
    ISerializer serializer,   
    IStorage storage,
    ISecondaryPinner secondaryPinner)
    : base(serializer)
  {
    this.storage = storage;
    this.secondaryPinner = secondaryPinner;
  }
  public override void LoadState(FeedItem navigationParameter,
    Dictionary<string, object> pageState)
  {
    this.FeedItem = navigationParameter;
  }
  protected async Task SavePinnedFeedItem()
  {
    var pinnedFeedItems =
      await this.storage.LoadAsync<List<FeedItem>>(
      Constants.PinnedFeedItemsKey);
    if (pinnedFeedItems == null)
    {
      pinnedFeedItems = new List<FeedItem>();
    }
    pinnedFeedItems.Add(feedItem);
    await this.storage.SaveAsync(Constants.PinnedFeedItemsKey, 
      pinnedFeedItems);
  }
  protected async Task RemovePinnedFeedItem()
  {
    var pinnedFeedItems =
      await this.storage.LoadAsync<List<FeedItem>>(
      Constants.PinnedFeedItemsKey);
    if (pinnedFeedItems != null)
    {
       var pinnedFeedItem = pinnedFeedItems.FirstOrDefault(fi => fi.Id ==
         this.FeedItem.Id);
       if (pinnedFeedItem != null)
       {
         pinnedFeedItems.Remove(pinnedFeedItem);
       }
      await this.storage.SaveAsync(Constants.PinnedFeedItemsKey, 
        pinnedFeedItems);
    }
  }
  private FeedItem feedItem;
  public FeedItem FeedItem
  {
    get { return this.feedItem; }
    set { this.SetProperty(ref this.feedItem, value); }
  }
  private bool isFeedItemPinned;
  public bool IsFeedItemPinned
  {
    get { return this.isFeedItemPinned; }
    set { this.SetProperty(ref this.isFeedItemPinned, value); }
  }
}

有了基类来为固定 FeedItem 的保存和删除操作提供便利,接下来我便可以着手进行平台特定的实现。 图 9 显示了 Windows 8 的 FeedItemViewModel 实现,以及 TileInfo 类和 Windows 8 所关注属性的使用。

图 9 Windows 8 的 FeedItemViewModel 具体实现

public sealed class Win8FeedItemViewModel : FeedItemViewModel
{
  private readonly IShareManager shareManager;
  public Win8FeedItemViewModel(
    ISerializer serializer,
    IStorage storage,
    ISecondaryPinner secondaryPinner,
    IShareManager shareManager)
    : base(serializer, storage, secondaryPinner)
  {
  this.shareManager = shareManager;
  }
  public override void LoadState(Models.FeedItem navigationParameter,
    Dictionary<string, object> pageState)
  {
    base.LoadState(navigationParameter, pageState);
    this.IsFeedItemPinned =
      this.secondaryPinner.IsPinned(FormatSecondaryTileId());
  }
  public override void SaveState(Dictionary<string, object> pageState)
  {
    base.SaveState(pageState);
    this.shareManager.Cleanup();
  }
  public async Task Pin(Windows.UI.Xaml.FrameworkElement anchorElement)
  {
    // Pin the feed item, then save it locally to make sure it is still
    // available when they return.
    var tileInfo = new TileInfo(
      this.FormatSecondaryTileId(),
      this.FeedItem.Title,
      this.FeedItem.Title,
      Windows.UI.StartScreen.TileOptions.ShowNameOnLogo |
        Windows.UI.StartScreen.TileOptions.ShowNameOnWideLogo,
      new Uri("ms-appx:///Assets/Logo.png"),
      new Uri("ms-appx:///Assets/WideLogo.png"),
      anchorElement,
      Windows.UI.Popups.Placement.Above,
      this.FeedItem.Id.ToString());
    this.IsFeedItemPinned = await this.secondaryPinner.Pin(tileInfo);
    if (this.IsFeedItemPinned)
    {
       await SavePinnedFeedItem();
    }
  }
  public async Task Unpin(Windows.UI.Xaml.FrameworkElement anchorElement)
  {
    // Unpin, then delete the feed item locally.
  var tileInfo = new TileInfo(this.FormatSecondaryTileId(), anchorElement,
    Windows.UI.Popups.Placement.Above);
    this.IsFeedItemPinned = !await this.secondaryPinner.Unpin(tileInfo);
    if (!this.IsFeedItemPinned)
    {
      await RemovePinnedFeedItem();
    }
  }
  private string FormatSecondaryTileId()
  {
    return string.Format(Constants.SecondaryIdFormat, this.FeedItem.Id);
  }
}

图 10 显示了 Windows Phone 8 的 FeedItemViewModel 具体实现。 与 Windows 8 相比,在 Windows Phone 8 中使用 TileInfo 时所需要的属性较少。

图 10 Windows Phone 8 的 FeedItemViewModel 具体实现

public sealed class WP8FeedItemViewModel : FeedItemViewModel
{
  public WP8FeedItemViewModel(
    ISerializer serializer,
    IStorage storage,
    ISecondaryPinner secondaryPinner)
    : base(serializer, storage, secondaryPinner)
  {
  }
  public async Task Pin()
  {
    // Pin the feed item, then save it locally to make sure it is still
    // available when they return.
    var tileInfo = new TileInfo(
      this.FormatTileIdUrl(),
      this.FeedItem.Title,
      Constants.AppName,
      new Uri("/Assets/ApplicationIcon.png", UriKind.Relative));
    this.IsFeedItemPinned = await this.secondaryPinner.Pin(tileInfo);
    if (this.IsFeedItemPinned)
    {
      await this.SavePinnedFeedItem();
    }
  }
  public async Task Unpin()
  {
    // Unpin, then delete the feed item locally.
    var tileInfo = new TileInfo(this.FormatTileIdUrl());
    this.IsFeedItemPinned = !await this.secondaryPinner.Unpin(tileInfo);
    if (!this.IsFeedItemPinned)
    {
      await this.RemovePinnedFeedItem();
    }
  }
  private string FormatTileIdUrl()
  {
    var queryString = string.Format("parameter={0}", FeedItem.Id);
    return string.Format(Constants.SecondaryUriFormat, queryString);
  }
}

在 FeedItemViewModel 的 Windows 8(图 9)和 Windows Phone 8(图 10)实现之后,我的应用程序已全部设置为将辅助磁贴分别固定在各自的主屏幕上。 功能方面的最后一环是用户实际点击固定辅助磁贴时的处理。 对于这两种应用程序,我的目标是启动应用程序时直接进入辅助磁贴所代表的博客文章,但允许用户按后退按钮转到列出所有博客的主页,而不是退出应用程序本身。

从 Windows 8 的角度看,一切与我在 2013 年 7 月的文章中所谈论的并无二致。 图 11 显示了用于应用程序启动时处理的未更改 Windows 8 代码,此代码是 App.xaml.cs 类文件中的一个片段。

图 11 在 Windows 8 中启动应用程序

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();
}

在 Windows Phone 8 中稍微有些棘手。 这种情况下,辅助磁贴采用的是 URI,而不只是参数。 这意味着 Window Phone 8 可以自动将我的应用程序启动到任何我需要的页面,而无需像 Windows 8 一样先经过一个集中启动点。 由于我想要在两种平台中提供一致的体验,因此为 Windows Phone 8 创建了一个自己的集中启动点,我称之为 SplashViewModel(如图 12 所示)。 我已将自己的项目设置为每当应用程序启动时(无论是否通过辅助磁贴启动),都通过此集中启动点来启动。

图 12 Windows Phone 8 的 SplashViewModel

public sealed class SplashViewModel : ViewModelBase<int?>
{
  private readonly IStorage storage;
  private readonly INavigator navigator;
  public SplashViewModel(
    IStorage storage,
    INavigator navigator,
    ISerializer serializer)
    : base(serializer)
  {
    this.storage = storage;
    this.navigator = navigator;
  }
  public override async void LoadState(
    int? navigationParameter, Dictionary<string, object> pageState)
  {
    this.navigator.NavigateToViewModel<MainViewModel>();
    this.navigator.RemoveBackEntry();
    if (navigationParameter.HasValue)
    {
      List<FeedItem> pinnedFeedItems =
        await storage.LoadAsync<List<FeedItem>>(Constants.PinnedFeedItemsKey);
      if (pinnedFeedItems != null)
      {
        var pinnedFeedItem =
          pinnedFeedItems.FirstOrDefault(fi => 
            fi.Id == navigationParameter.Value);
        if (pinnedFeedItem != null)
        {
  this.navigator.NavigateToViewModel<FeedItemViewModel>(pinnedFeedItem);
        }
      }
    }
  }
}

SplashViewModel 的代码非常简单。 我要指出的一点是,我希望确保将此页面从 Back 堆栈中删除,否则它会导致用户无法退出应用程序。 每当用户退回到此页面时,总会被发送回应用程序。 这时,Windows Phone 8 的这一重要 INavigator 添加项便有了用武之地:RemoveBackEntry。 在导航到 MainViewModel 后,我调用 RemoveBackEntry 以便将启动页面从 Back 堆栈中除去。 此页面现在成了一种仅在应用程序启动时使用的一次性页面。

总结

在本文中,我介绍了 Windows 8 和 Windows Phone 8 的跨平台开发。 我谈到了哪些可以在两种平台间重复使用(设计和某些代码),而哪些则不能 (XAML)。 我还谈论了从事跨平台应用程序开发的开发人员所面临的一些挑战,并介绍了几种解决方案。 我希望,除我所提到的特定导航、应用程序设置和辅助磁贴示例之外,这些解决方案还可以应用到其他用途。 这些解决方案可以将部分操作系统交互抽象出来,然后通过接口使这些交互变得可模拟,从而有助于让您的视图模型保持可测试性。

由于这些跨平台应用程序已全部准备好进行测试,因此,我将在下一篇文章中更加具体地研究它们的实际单元测试。 针对一些我所做出的与测试有关的决定,我将详细介绍其背后的原因,以及实际中如何着手进行应用程序的单元测试。

通过以在两种平台上提供相似用户体验为目的而从事跨平台开发,并且提前做一些规划,我可以在编写应用程序时最大限度地重复使用代码和帮助进行单元测试。 我可以利用诸如 Windows 8 超级按钮菜单等平台特定的功能,而无需牺牲每种平台所提供的体验。

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

衷心感谢以下技术专家对本文的审阅:Jason Bock (Magenic)