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 的重要新功能 ,同時並不影響最佳實踐或單元測試。
下載代碼示例