В Windows 8 появилось много новых средств, которые разработчики могут использовать для создания потрясающих приложений. К сожалению, эти возможности не всегда дружат с модульным тестированием. Такие средства, как обмен информацией (sharing) и дополнительные плитки (secondary tiles), делают ваше приложение не только более интерактивным и приятным в использовании, но и менее тестируемым.
В этой статье я рассмотрю различные способы, позволяющие задействовать в приложении средства вроде обмена информацией, экрана настроек, дополнительных плиток, параметров приложения и хранилища приложения. Используя шаблон Model-View-ViewModel (MVVM), встраивание зависимостей и некоторые абстракции, я покажу, как применять эти средства, в то же время сохраняя презентационный уровень поддающимся модульному тестированию.
О приложении-примере
Чтобы проиллюстрировать концепции, о которых я буду говорить в этой статье, я использовал MVVM для написания приложения-примера для Windows Store, позволяющего пользователю просматривать публикации из RSS-канала его любимых блогов. Это приложение показывает, как:
- обмениваться информацией о публикации в блоге с другими приложениями через кнопку Share;
- менять блоги, которые пользователь хочет читать, с помощью кнопки Settings;
- закреплять избранную публикацию в блоге на экране Start для последующего чтения с помощью дополнительных плиток;
- сохранять избранные блоги для просмотра на любых устройствах с применением перемещаемых настроек (roaming settings).
В дополнение к приложению-примеру я взял специфическую функциональность Windows 8, о которой буду рассказывать в этой статье, и абстрагировал ее в библиотеку Charmed с открытым исходным кодом. Charmed можно использовать как вспомогательную библиотеку или просто как ссылку. Цель Charmed — предоставить кросс-платформенную библиотеку поддержки MVVM для Windows 8 и Windows Phone 8. О той части библиотеки, которая относится к Windows Phone 8, я расскажу в будущей статье. Вы можете следить за развитием библиотеки Charmed на странице по ссылке bit.ly/17AzFxW.
Моя цель в этой статье и примерах кода — продемонстрировать мой подход к созданию тестируемых приложений на основе MVVM, используя некоторые новые средства Windows 8.
Обзор MVVM
Прежде чем углубиться в код и специфические средства Windows 8, я кратко рассмотрю MVVM. Это проектировочный шаблон, завоевавший фантастическую популярность в последние годы в области технологий на основе XAML, таких как Windows Presentation Foundation (WPF), Silverlight, Windows Phone 7, Windows Phone 8 и Windows 8 (Windows Runtime, или WinRT). MVVM разбивает архитектуру приложения на три логических уровня: Model, View Model и View, как показано на рис. 1.
Рис. 1. Три логических уровня ModelView-ViewModel
View Presentation | View Презентационный уровень |
View Model Presentation Logic | View Model Логика презентационного уровня |
Model Business Logic | Model Бизнес-логика |
Уровень Model содержит бизнес-логику (прикладную логику) приложения, обрабатывающую бизнес-объекты, проверку данных, доступ к данным и т. д. На практике уровень Model обычно разбивается на дополнительные уровни и, возможно, даже на несколько звенья (tiers). Как видно на рис. 1, уровень Model является логическим фундаментом приложения и находится в самом низу.
Уровень View Model хранит презентационную логику приложения, которая включает отображаемые данные, свойства, помогающие делать доступными или видимыми UI-элементы, и методы, взаимодействующие с уровнями Model и View. В основном уровень View Model является безразличным к View представлением текущего состояния UI. Я сказал «безразличным» потому, что он просто предоставляет данные и методы для представления, с которым нужно взаимодействовать, но не диктует, как это представление будет отображать данные или разрешать пользователю взаимодействовать с методами. На рис. 1 видно, что уровень View Model логически располагается между уровнями Model и View и может взаимодействовать с обоими. Уровень View Model содержит код, который ранее размещался бы в отделенном коде уровня View.
Уровень View содержит реальное представление приложения. Для приложений на основе XAML, например рассчитанных на Windows Runtime, уровень View в основном (если не полностью) состоит из XAML. Этот уровень использует мощный XAML-механизм связывания с данными для привязки к свойствам в модели представления, применяя визуальные стили оформления к данным, которые иначе не имели бы визуального представления. Как показано на рис. 1, уровень View находится на логической вершине приложения. Он напрямую взаимодействует с уровнем View Model, но ничего не знает об уровне Model.
Основная цель шаблона MVVM — отделение представления приложения от его функциональности. Это способствует тому, что приложение в большей мере поддается модульным тестам, так как функциональность теперь содержится в Plain Old CLR Objects (POCO), а не в представлениях со своими жизненными циклами.
Контракты
В Windows 8 введена концепция контрактов, которые являются соглашениями между двумя или более приложениями в системе пользователя. Эти контракты обеспечивают согласованность между всеми приложениями, позволяя разработчикам использовать функциональность из любого приложения, которое поддерживает нужные контракты. Приложение может объявить, какие контракты оно поддерживает, в файле Package.appxmanifest, как показано на рис. 2.
Рис. 2. Контракты в файле Package.appxmanifest
Хотя поддержка контрактов не обязательна, в целом, это хорошая идея. Однако для соответствия UI в Windows 8 приложение должно поддерживать минимум три контракта: Sharing, Settings и Search; дело в том, что они всегда доступны через меню так называемых чудо-кнопок (charms), показанных на рис. 3.
Рис. 3. Меню чудо-кнопок
Я сосредоточусь на двух типах контрактов: Sharing (или Share) и Settings.
Контракт Sharing
Этот контракт позволяет приложению обмениваться контекстно-зависимыми данными с другими приложениями в системе пользователя. В контракте Share две стороны: источник и получатель. Источник — это приложение, осуществляющее обмен информацией. Он предоставляет какие-то общие данные в любом необходимом формате. Получателем является приложение, принимающее общие данные. Поскольку кнопка Share всегда доступна пользователю через меню чудо-кнопок, мне нужно, чтобы приложение-пример, как минимум, было источником. Не каждое приложение должно быть получателем общих данных, поскольку не во всех программах есть необходимость принимать ввод из других источников. Однако довольно высока вероятность того, что в любом приложении есть минимум одно, чем стоит поделиться с другими приложениями. Поэтому в большинстве приложений вы скорее всего найдете полезным сделать так, чтобы они были источником общих данных.
Когда пользователь нажимает кнопку Share, объект с именем Share Broker начинает процесс приема данных из общих ресурсов приложения-источника (если таковые есть) и отправляет их получателю, указанному пользователем. Чтобы поделиться данными во время этого процесса я могу задействовать объект DataTransferManager. У него есть событие DataRequested, генерируемое, когда пользователь нажимает кнопку Share. Следующий код показывает, как получить ссылку на DataTransferManager и подписаться на событие DataRequested:
public void Initialize()
{
this.DataTransferManager = DataTransferManager.GetForCurrentView();
this.DataTransferManager.DataRequested +=
this.DataTransferManager_DataRequested;
}
private void DataTransferManager_DataRequested(
DataTransferManager sender, DataRequestedEventArgs args)
{
// Выполняем нужные операции...
}
Вызов DataTransferManager.GetForCurrentView возвращает ссылку на активный DataTransferManager для текущего представления. Хотя этот код можно поместить в модель представления, это создаст жесткую зависимость от DataTransferManager — запечатанного (sealed) класса, который нельзя имитировать в модульных тестах. Поскольку я хочу, чтобы мое приложение оставалось максимально тестируемым, такой вариант мне не подходит. Лучше абстрагировать взаимодействие DataTransferManager во вспомогательном классе и определить интерфейс, который должен реализовать этот вспомогательный класс.
Прежде чем абстрагировать взаимодействие, нужно решить, какие именно части этого взаимодействия по-настоящему важны. Меня интересуют три части взаимодействия с DataTransferManager.
- Подписка на событие DataRequested при активации моего представления.
- Отмена подписки на событие DataRequested при деактивации моего представления.
- Возможность добавлять общие данные в DataPackage.
Учитывая эти три момента, мой интерфейс материализует:
public interface IShareManager
{
void Initialize();
void Cleanup();
Action<DataPackage> OnShareRequested { get; set; }
}
IInitialize должен получить ссылку на 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, аргумент args этого события содержит DataPackage. Этот DataPackage — то место, куда нужно поместить реальные общие данные, и вот почему Action в OnShareRequested принимает DataPackage как параметр. Благодаря определенному мной IShareManager и реализующему его ShareManager я теперь готов включить обмен данными в свою модель представления, не жертвуя полнотой модульного тестирования, к которой я стремлюсь.
Используя выбранный на свой вкус контейнер Inversion of Control (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. Заполнение DataPackage в ShareRequested
private void ShareRequested(DataPackage dataPackage)
{
// Задаем как можно больше типов данных
dataPackage.Properties.Title = this.FeedItem.Title;
// Добавляем Uri
dataPackage.SetUri(this.FeedItem.Link);
// Добавляем чисто текстовую версию
var text = string.Format(
"Check this out! {0} ({1})",
this.FeedItem.Title, this.FeedItem.Link);
dataPackage.SetText(text);
// Добавляем HTML-версию
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);
}
Я выбрал обмен несколькими типами данных. Это в целом хорошая идея, так как у вас нет контроля над тем, какие приложения имеются в системе пользователя и какие типы данных они поддерживают. Важно помнить, что обмен данными в основном протекает по сценарию «выстрелил и забыл». Вы не имеете ни малейшего понятия, какое приложение пользователь выберет в качестве источника данных и какое приложение будет иметь дело с общими данными. Для поддержки максимально широкой аудитории я предоставляю заголовок (title), URI, чисто текстовую и HTML-версии.
Контракт Settings
Этот контракт дает возможность пользователю изменять контекстно-зависимые настройки в приложении. Это могут быть параметры, влияющие на приложение в целом, или просто на конкретные элементы, относящиеся к текущему контексту. Пользователи Windows 8 постепенно привыкнут к внесению изменений в приложение через чудо-кнопку Settings, и я хочу, чтобы приложение-пример поддерживало ее, так как она всегда доступна пользователю в меню чудо-кнопок. По сути, если какое-то приложение объявляет поддержку Интернета через файл Package.appxmanifest, оно должно реализовать контракт Settings, предоставляя где-то в меню Settings ссылку на политику конфиденциальности. Поскольку приложения, использующие шаблоны Visual Studio 2012, автоматически объявляют о поддержке Интернета, этот момент нельзя упускать из виду.
Когда пользователь нажимает чудо-кнопку Settings, ОС начинает динамически формировать меню, которое потом будет показано на экране. Это меню и связанное с ним раскрывающееся меню (flyout) управляются ОС. Я не могу контролировать, как будут выглядеть данное меню и связанное с ним раскрывающееся меню, но могу добавлять элементы в меню. Объект SettingsPane будет уведомлять меня о выборе пользователем кнопки Settings через событие CommandsRequested. Получить ссылку на SettingsPane и подписаться на событие CommandsRequested довольно несложно:
public void Initialize()
{
this.SettingsPane = SettingsPane.GetForCurrentView();
this.SettingsPane.CommandsRequested +=
SettingsPane_CommandsRequested;
}
private void SettingsPane_CommandsRequested(
SettingsPane sender,
SettingsPaneCommandsRequestedEventArgs args)
{
// Выполняем операции...
}
Здесь подвох в том, что появляется другая жесткая зависимость. На этот раз она вызывается 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 должен отменять подписку на это событие. В 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, через его аргумент args я в конечном счете получаю доступ к списку объектов SettingsCommand, представляющих элементы меню Settings. Чтобы добавить свой элемент в меню Settings, достаточно добавить экземпляр SettingsCommand в этот список. Объект SettingsCommand не просит слишком многого — вы должны предоставить уникальный идентификатор, текст метки и код, который будет выполняться при выборе пользователем этого элемента меню.
Для встраивания экземпляра ISettingsManager в модель представления я использую свой контейнер IoC, а затем настраиваю его инициализацию и очистку, как показано на рис. 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();
}
Я задействую Settings, чтобы пользователи с помощью приложения-примера могли менять просматриваемые RSS-каналы. Эта возможность должна быть доступна из любого места приложения, поэтому я включил ShellViewModel, экземпляр которого создается при запуске приложения. Если бы я хотел поддерживать смену RSS-каналов только в одном из других представлений, я включил бы код изменения параметров в соответствующую модель представления.
В Windows Runtime отсутствует встроенная функциональность создания и поддержки раскрывающихся меню для настроек. И понадобилось бы вручную писать уйму кода, чтобы получить функциональность, которая была бы единой для всех приложений. К счастью, не я один понимаю это. Тим Хойер (Tim Heuer), менеджер программ в группе Microsoft XAML, создал превосходную инфраструктуру Callisto, помогающую справиться с этой проблемой. Callisto доступен на GitHub (bit.ly/Kijr1S) и NuGet (bit.ly/112ehch). Я использую ее в своем приложении-примере и рекомендую всем проверить эту инфраструктуру..
Поскольку у меня есть SettingsManager, подключенный к модели представления, мне нужно лишь предоставить код, выполняемый при запросе доступа к настройкам (рис. 9).
Рис. 9. Отображение SettingsView в SettingsRequested с помощью Callisto
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, присваиваю ему идентификатор «FeedsSetting» и текст метки «Feeds». В лямбде, используемой мной для обратного вызова, который запускается при выборе элемента меню «Feeds», применяется элемент управления SettingsFlyout из инфраструктуры Callisto. Этот элемент управления выполняет всю черновую работу, связанную с тем, где следует разместить раскрывающееся меню, насколько широким оно должно быть и когда открывать и закрывать его. Мне надо лишь сообщить ему, какую версию я предпочитаю — широкую или узкую, передать ему текст заголовка и контент, а затем установить IsOpen в true, чтобы открыть раскрывающееся меню. Кроме того, советую задать HorizontalContentAlignment и VerticalContentAlignment как Stretch. Иначе ваш контент может не совпасть по размеру с SettingsFlyout.
Шина сообщений
Важный момент в работе с контрактом Settings заключается в том, что любые изменения в настройках должны применяться и отражаться на приложении немедленно. Реализовать широковещательное распространение изменений в настройках можно несколькими способами. Я предпочел задействовать шину сообщений (также известную как агрегатор событий). Шина сообщений является системой публикации сообщений в рамках всего приложения. Концепция шины сообщений не предусмотрена в Windows Runtime, а значит, я должен был либо создать эту шину, либо воспользоваться одной из готовых инфраструктур. Поэтому я включил реализацию шины сообщений, с которой уже имел дело в нескольких проектах, — Charmed. Ее исходный код вы найдете по ссылке bit.ly/12EBHrb. Есть ряд и других хороших реализаций. В Caliburn.Micro имеется EventAggregator, а в MVVM Light — Messenger. Все реализации, как правило, следуют одному и тому же шаблону, позволяя подписываться на получение определенных сообщений, отменять подписку и публиковать сообщения.
Используя шину сообщений Charmed в сценарии с изменением настроек, я конфигурирую свой MainViewModel (тот, который показывает каналы) для подписки на FeedsChangedMessage:
this.messageBus.Subscribe<FeedsChangedMessage>((message) =>
{
LoadFeedData();
});
Как только MainViewModel готов к прослушиванию изменений в каналах, я настраиваю SettingsViewModel на публикацию FeedsChangedMessage, когда пользователь добавляет или удаляет какой-либо RSS-канал:
this.messageBus.Publish<FeedsChangedMessage>(new FeedsChangedMessage());
При работе с шиной сообщений важно, чтобы каждая часть приложения использовала один и тот же экземпляр шины. Поэтому я конфигурирую свой контейнер IoC так, чтобы на каждый запрос разрешения IMessageBus он выдавал singleton-экземпляр.
Теперь приложение-пример позволяет пользователю вносить изменения в RSS-каналы, отображаемые чудо-кнопкой Settings, и обновлять основное представление, чтобы оно отражало эти изменения..
Перемещаемые настройки
Другая интересная концепция в Windows 8 — перемещаемые настройки (roaming settings). Такие настройки позволяют разработчикам приложений переносить небольшие объемы данных между всеми устройствами пользователя. Эти данные должны быть объемом менее 100 Кб и ограничены лишь информацией, необходимой для создания сохраняемой настраиваемой среды между всеми устройствами. В приложении-примере я хочу иметь возможность сохранять RSS-каналы, которые пользователь предпочитает просматривать на всех своих устройствах.
Контракт Settings, о котором я говорил ранее, обычно идет рука об руку с перемещаемыми настройками. Получить доступ к перемещаемым настройкам, как и в случае других средств, рассмотренных в этой статье, довольно нетрудно. Класс 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 сначала проверяет, существует ли данный ключ, и присваивает значение выходному параметру, если таковой есть. Вместо генерации исключения, если ключ не найден, он возвращает значение типа bool, указывающее, найден ключ или нет. Остальные методы понятны и без объяснений.
Теперь контейнер 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, заключается в том, что на деле я сохраняю настройки в строковом массиве. Поскольку перемещаемые настройки ограничены объемом менее 100 Кб, старайтесь ничего не усложнять и придерживаться использования элементарных типов.
Дополнительные плитки
Разработка приложений, способных привлечь пользователей, может оказаться весьма трудным делом. Как удержать пользователей после установки вашего приложения? Один из элементов, которые могут помочь в этом, — дополнительные плитки (secondary tiles). Дополнительная плитка обеспечивает глубокий переход в приложении, позволяя обходить остальную часть приложения и попадать непосредственно туда, куда больше всего нужно пользователю. Дополнительная плитка фиксируется на начальном экране со значком на ваш выбор. При одном касании дополнительная плитка запускает ваше приложение с аргументами, которые сообщают ему, какую страницу оно должно открыть и что загрузить. Поддержка функциональности дополнительных плиток — хороший способ адаптации вашего приложения самими пользователями под свои потребности.
Дополнительные плитки — концепция более сложная, чем все, о чем я рассказывал в этой статье до сих пор, потому что для корректной работы дополнительных плиток нужно сначала реализовать несколько других вещей.
Закрепление дополнительной плитки включает создание экземпляра класса SecondaryTile. Конструктор SecondaryTile принимает несколько параметров, помогающих ему определить, как будет выглядеть плитка, включая отображаемое название (display name), URI файла изображения с эмблемой для этой плитки и строковые аргументы, которые будут переданы приложению при нажатии этой плитки. После создания экземпляра SecondaryTile я должен вызвать некий метод, который в конечном счете откроет небольшое всплывающее окно с просьбой к пользователю подтвердить закрепление плитки, как показано на рис. 12.
Рис. 12. Запрос от SecondaryTile разрешения на закрепление плитки на начальном экране
Когда пользователь нажимает Pin to Start, первая половина работы закончена. Вторая половина — конфигурирование приложения на реальную поддержку глубокого связывания с использованием аргументов плитки при ее нажатии. Прежде чем перейти ко второй половине, позвольте мне рассказать о реализации первой половины с сохранением тестируемости.
Поскольку 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 использует асинхронные задачи, предлагая пользователю закрепить или открепить плитку. Кроме того, это означает, что методы Pin и Unpin в моем ISecondaryPinner могут быть ожидаемыми (awaited).
Также обратите внимание на то, что 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 первым делом проверит, что запрошенная плитка уже существует, а потом попросит подтверждения от пользователя об ее откреплении. Оба метода вернут значение типа bool, указывающее, успешно ли выполнено закрепление или открепление.
Далее я могу встроить экземпляр 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, передавая ему уникально отформатированный идентификатор, заголовок канала, URI на обычную и широкую эмблемы, а также идентификатор канала в качестве аргумента запуска. Pin принимает нажатую кнопку как якорный элемент, рядом с которым появится всплывающее окно с запросом на закрепление. По результату метода SecondaryPinner.Pin я определяю, был ли закреплен элемент канала.
В Unpin я передаю уникально отформатированный идентификатор плитки, используя обратное значение результата, чтобы определить, закреплен ли еще элемент канала. И вновь нажатая кнопка передается в Unpin как якорный элемент для вывода всплывающего окна с соответствующим запросом.
Закончив с этим и получив возможность закреплять на экране Start какую-либо публикацию в блоге (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, чтобы проверять, были ли переданы аргументы при запуске. Если да, я разбираю аргументы в переменную типа int, которая используется как идентификатор канала. Далее получаю канал с этим идентификатором из сохраненного списка каналов и передаю его в FeedItemViewModel для отображения. Обратите внимание на то, что я удостоверяюсь, что приложение уже показывает основную страницу, и сначала перехожу на нее, если она пока не отображена. Тем самым пользователь сможет нажать кнопку возврата и перейти на основную страницу независимо от того, запущено ли уже приложение.
Заключение
В этой статье я рассказал о своем подходе к реализации тестируемого приложения Windows Store на основе шаблона MVVM, в то же время используя некоторые из интересных новых средств Windows 8. А именно мы рассмотрели абстрагирование обмена информацией, настроек, перемещаемых настроек и дополнительных плиток во вспомогательные классы, реализующие имитируемые в целях тестирования интерфейсы. Благодаря этой методике я могу в максимальной мере выполнять модульное тестирование функциональности модели представления.
В будущих статьях я подробнее расскажу о специфике того, как писать модульные тесты для моделей представления, которые я сделал более тестируемыми. Кроме того, я исследую, как применять те же методики, чтобы мои модели представления стали кросс-платформенными и работали в Windows Phone 8, в то же время сохранив их тестируемость.