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 所示。

圖 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 的交互中,有三個部分引起我的關注:
激活我的視圖時訂閱 DataRequested 事件。
停用我的視圖時取消訂閱 DataRequested 事件。
可向 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 的交互非常類似:
訂閱當前視圖的 CommandsRequested 事件。
取消訂閱當前視圖的 CommandsRequested 事件。
在引發該事件時添加我自己的 SettingsCommand 對象。
因此,我需要抽象化的接口與 IShareManager 接口非常類似:
public interface ISettingsManager
{
void Initialize();
void Cleanup();
Action<IList<SettingsCommand>> OnSettingsRequested { get; set; }
}
Initialize 應引用 SettingsPane 並訂閱 CommandsRequested 事件。Cleanup 應取消訂閱 CommandsRequested 事件。可在 OnSettingsRequested 中定義在引發 CommandsRequested 事件後調用什麼方法。現在我可以實現 ISettingsManager,如 圖 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 和 VerticalContentAlignment 設置為 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 源時發布 FeedsChangedMessage:
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 的交互抽象化。 在定義將漫游設置功能抽象化出的接口之前,我需要決定 要對它做些什麼:
查看是否存在密鑰。
添加或 更新設置。
刪除設置。
獲取設置。
現在 我萬事俱備,可創建一個名為 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);
}
注意,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 的重要新功能 ,同時並不影響最佳實踐或單元測試。
下載代碼示例