Windows 8 引入共享、設定、搜尋等「合約」,透過超級按鈕隨時可用。直接在 ViewModel 呼叫 DataTransferManager、SettingsPane 等密封類別會造成難以單元測試的硬性相依。本文示範以介面抽象、依賴注入(IoC)封裝這些系統服務,保持 ViewModel 可測試。
功能:共享文章、設定切換 RSS 來源、輔助磚釘選文章、漫遊設定同步訂閱清單。
開源輔助庫 Charmed(GitHub)封裝此類抽象,支援 Win8/WinPhone 8 跨平台 MVVM。
關鍵三點:啟動訂閱 DataRequested、停用取消訂閱、提供 DataPackage 內容。
public interface IShareManager {
void Initialize();
void Cleanup();
Action<DataPackage> OnShareRequested { get; set; }
}
public sealed class ShareManager : IShareManager {
private DataTransferManager dtm;
public void Initialize() {
dtm = DataTransferManager.GetForCurrentView();
dtm.DataRequested += Dtm_DataRequested;
}
public void Cleanup() => dtm.DataRequested -= Dtm_DataRequested;
private void Dtm_DataRequested(DataTransferManager s, DataRequestedEventArgs e) {
OnShareRequested?.Invoke(e.Request.Data);
}
public Action<DataPackage> OnShareRequested { get; set; }
}
ViewModel 注入 IShareManager,於 LoadState/SaveState 初始化/清理:
public FeedItemViewModel(IShareManager shareManager) { this.shareManager = shareManager; }
public override void LoadState(...) { shareManager.Initialize(); shareManager.OnShareRequested = ShareRequested; }
public override void SaveState(...) { shareManager.Cleanup(); }
private void ShareRequested(DataPackage dp) {
dp.Properties.Title = FeedItem.Title;
dp.SetUri(FeedItem.Link);
dp.SetText($"Check this out! {FeedItem.Title} ({FeedItem.Link})");
var html = $"Check this out!
";
dp.SetHtmlFormat(HtmlFormatHelper.CreateHtmlFormat(html));
}
同樣模式:訂閱 CommandsRequested、加入自訂 SettingsCommand。
public interface ISettingsManager {
void Initialize(); void Cleanup();
Action<IList<SettingsCommand>> OnSettingsRequested { get; set; }
}
public sealed class SettingsManager : ISettingsManager {
private SettingsPane pane;
public void Initialize() {
pane = SettingsPane.GetForCurrentView();
pane.CommandsRequested += Pane_CommandsRequested;
}
public void Cleanup() => pane.CommandsRequested -= Pane_CommandsRequested;
private void Pane_CommandsRequested(SettingsPane s, SettingsPaneCommandsRequestedEventArgs e) {
OnSettingsRequested?.Invoke(e.Request.ApplicationCommands);
}
public Action<IList<SettingsCommand>> OnSettingsRequested { get; set; }
}
ShellViewModel 注入並開啟 Callisto SettingsFlyout:
private void OnSettingsRequested(IList<SettingsCommand> cmds) {
var cmd = new SettingsCommand("FeedsSetting", "Feeds", _ => {
var flyout = new Callisto.Controls.SettingsFlyout {
FlyoutWidth = SettingsFlyout.SettingsFlyoutWidth.Wide,
HeaderText = "Feeds", Content = new SettingsView(),
HorizontalContentAlignment = HorizontalAlignment.Stretch,
VerticalContentAlignment = VerticalAlignment.Stretch, IsOpen = true
};
});
cmds.Add(cmd);
}
設定變更透過訊息總線(IMessageBus)廣播 FeedsChangedMessage,MainViewModel 訂閱並重新載入資料。
ApplicationData.Current.RoamingSettings(<100KB)同步裝置間設定。封裝介面:
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);
}
public sealed class Settings : ISettings {
private readonly ApplicationDataContainer roaming = ApplicationData.Current.RoamingSettings;
public void AddOrUpdate(string key, object value) => roaming.Values[key] = value;
public bool TryGetValue<T>(string key, out T value) {
if (roaming.Values.ContainsKey(key)) { value = (T)roaming.Values[key]; return true; }
value = default(T); return false;
}
public bool Remove(string key) => roaming.Values.Remove(key);
public bool ContainsKey(string key) => roaming.Values.ContainsKey(key);
}
SettingsViewModel 注入 ISettings 載入/儲存 RSS 陣列(字串陣列以節省空間),儲存後發布 FeedsChangedMessage。
固定/取消固定需非同步彈窗確認,封裝 ISecondaryPinner:
public interface ISecondaryPinner {
Task<bool> Pin(FrameworkElement anchor, Placement placement, TileInfo info);
Task<bool> Unpin(FrameworkElement anchor, Placement placement, string tileId);
bool IsPinned(string tileId);
}
public sealed class TileInfo {
public string TileId, ShortName, DisplayName, Arguments;
public TileOptions TileOptions; public Uri LogoUri, WideLogoUri;
// 兩個建構子...
}
public sealed class SecondaryPinner : ISecondaryPinner {
public async Task<bool> Pin(FrameworkElement anchor, Placement placement, TileInfo info) {
if (SecondaryTile.Exists(info.TileId)) return false;
var tile = new SecondaryTile(info.TileId, info.ShortName, info.DisplayName,
info.Arguments, info.TileOptions, info.LogoUri) { WideLogo = info.WideLogoUri };
return await tile.RequestCreateForSelectionAsync(GetElementRect(anchor), placement);
}
public async Task<bool> Unpin(FrameworkElement anchor, Placement placement, string tileId) {
if (!SecondaryTile.Exists(tileId)) return false;
var tile = new SecondaryTile(tileId);
return await tile.RequestDeleteForSelectionAsync(GetElementRect(anchor), placement);
}
public bool IsPinned(string tileId) => SecondaryTile.Exists(tileId);
private static Rect GetElementRect(FrameworkElement e) {
var t = e.TransformToVisual(null); var p = t.TransformPoint(new Point());
return new Rect(p, new Size(e.ActualWidth, e.ActualHeight));
}
}
FeedItemViewModel 注入 ISecondaryPinner,按鈕點擊呼叫 Pin/Unpin,以文章 ID 為參數。
App.xaml.cs 覆寫 OnLaunched,解析參數導航至對應 FeedItemViewModel:
protected override async void OnLaunched(LaunchActivatedEventArgs args) {
var 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>();
var items = await storage.LoadAsync<List<FeedItem>>(Constants.PinnedFeedItemsKey);
if (items != null && int.TryParse(args.Arguments, out int id)) {
var item = items.FirstOrDefault(f => f.Id == id);
if (item != null) Ioc.Container.Resolve<INavigator>().NavigateToViewModel<FeedItemViewModel>(item);
}
}
Window.Current.Activate();
}
以介面抽象系統服務(共享、設定、漫遊、輔助磚),搭配 IoC 注入 ViewModel,兼顧 Windows 8 豐富 UX 與單元測試性。後續將探討 ViewModel 單元測試撰寫與跨平台移植至 Windows Phone 8。
程式碼下載:Charmed 庫 / 示範專案原始碼