本文章是由機器翻譯。

MVVM

透過 MVVM 運用 Windows 8 功能

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 Overview

在深入探討代碼和特定 Windows 8 功能之前,我將簡要介紹一下 MVVM。MVVM 是近年來在基於 XAML 的技術方面廣受青睞的一種設計模式,這些技術包括 Windows Presentation Foundation (WPF)、Silverlight、Windows Phone 7、Windows Phone 8 和 Windows 8(Windows Runtime,簡稱 WinRT)。MVVM 將應用程式的體系結構劃分為三個邏輯層:模型、視圖模型和視圖,如圖 1 所示。


圖 1:模型-視圖-視圖模型的三個邏輯層

模型層涉及應用程式的業務邏輯,即業務物件、資料驗證、資料訪問等。實際上,模型層通常分為更多層,甚至可能分為多個層級。如圖 1 所示,模型層是應用程式在邏輯意義上的底部,或稱基礎。

視圖模型層容納應用程式的表示邏輯,其中包括要顯示的資料、説明啟用 UI 元素或使其可見的屬性以及將同時與模型層和視圖層進行交互的方法。基本上,視圖模型層是對於 UI 目前狀態的一種與視圖無關的表示形式。我說「與視圖無關」是因為它僅僅為要與之交互的視圖提供資料和方法,而不指示該視圖將如何表示資料,也不允許使用者與這些方法進行交互。如圖 1 所示,視圖模型層在邏輯上位於模型層與視圖層之間,並可與後兩者交互。視圖模型層包含以前將位於視圖層的隱藏代碼中的代碼。

視圖層包含應用程式的實際表示形式。對於基於 XAML 的應用程式,如 Windows Runtime 應用程式,視圖層主要(如果不是全部)由 XAML 構成。視圖層利用強大的 XAML 資料繫結引擎綁定到視圖模型上的屬性,同時將某種外觀應用於在其他情況下沒有視覺化表示形式的資料。如圖 1 所示,視圖層是應用程式在邏輯意義上的頂部。視圖層直接與視圖模型層交互,但對模型層一無所知。

MVVM 模式的主要用途是將應用程式的表示形式與其功能相分離。這樣做使應用程式對於單元測試更加有益,因為功能現在位於普通舊 CLR 物件 (POCO) 中,而非自行決定生命週期的視圖中。

合約

Windows 8 引入了合約的概念,即兩個或更多應用程式對於使用者系統達成的協議。這些合約使所有應用程式保持一致,並使開發人員可從任何支援功能的應用程式中利用這些功能。應用程式可在 Package.appxmanifest 檔中聲明其支援的合約,如圖 2 所示。


圖 2:Package.appxmanifest 檔中的合約

雖然支援合約並非必需,但一般來說這樣做是個好主意。尤其有三個合約應被應用程式支援:「共用」、「設置」和「搜索」,因為始終可通過超級按鈕功能表使用這三項,如圖 3 所示。


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

I create a new SettingsCommand, giving it the id “FeedsSetting” and the label text “Feeds.” The lambda I use for the callback, which gets called when the user selects the “Feeds” menu item, leverages Callisto’s SettingsFlyout control. SettingsFlyout 控制項處理在何處放置浮出控制項、決定其寬度以及何時打開和關閉它等重要工作。 我只需告訴它我需要寬版還是窄版,向其提供一些標題文本和內容,然後將 IsOpen 設置為 true 即可打開它。 我還建議將 HorizontalContentAlignment 和 VerticalContent­Alignment 設置為 Stretch。 否則,您的內容將不符合 SettingsFlyout 的大小。

消息匯流排

在處理「設置」合約時,一個要點是對設置的任何更改都應立即應用於應用程式並在應用程式中反映出來。 可使用多種方法將使用者進行的設置更改廣播出去。 我更願意使用的方法是消息匯流排(也稱為事件聚合器)。 消息匯流排是整個應用程式範圍內的一種消息發佈系統。 Windows 運行時中並未內置消息匯流排的概念,這意味著我不得不創建一個消息匯流排或使用其他框架中的消息匯流排。 我已加入了一個消息匯流排實現,而我已在許多專案中將其與 Charmed 框架配合使用。 可在 bit.ly/12EBHrb 上找到原始程式碼。 還有許多其他好的實現。 Caliburn.Micro has the EventAggregator and MVVM Light has the 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 所示。


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

Notice that both Pin and Unpin return 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 系列產品和移動應用程式的開發。 He’s also a cofounder of the Twin Cities Windows 8 User Group in Minneapolis, Minn. Reach him at brente@magenic.com.*

衷心感謝以下技術專家對本文的審閱: Rocky Lhotka (Magenic)
Rockford Lhotka 是 Magenic 的 CTO,他創作了廣為使用的 CSLA .NET 開發框架。他著書眾多,並經常在世界各地的大型會議上發言。Rockford 是一名 Microsoft 技術代言人和 MVP。Magenic (www.magenic.com) 是一家專門規劃、設計、生成和維護企業對任務最關鍵的系統的公司。有關詳細資訊,請訪問 www.lhotka.net