Здравствуйте!
Давно я не писал статей на хабре, пора бы это исправить...
Как можно догадаться из названия, эта статья будет просвещенна framework'у Caliburn.Micro. Я постараюсь показать, что полезного может дать использование этого framework’а разработчику под платформу WP7, какие задачи он решает, его достоинства и недостатки.
Но самый важный вопрос, на который я буду пытаться ответить в течении всей статьи, это зачем вообще нужен еще один промежуточный слой, в виде какого-то framework’а, в достаточно устоявшемся царстве WP7.
Если вам интересна эта тема, то добро пожаловать под кат.
Подготовка
Я предполагаю, что материалы этой статьи будут интересны в основном людям уже знакомым с разработкой под WP7. Поэтому я не буду рассказывать как создать проект в VS и т.п., а буду полагать, что вы это уже знаете или можете узнать из множества других открытых источников.
Для самостоятельных экспериментов вам понадобятся:
- VS 2010 SP1
- Windows Phone SDK 7.1
- Caliburn.Micro
Создадим Windows Phone Application проект, который будем использовать для тестирования.
MVVM или Глава вместо предисловия
Приложение WP7 состоит из наборы экранов/страниц (Windows Phone Page), эти экраны представляют какие-то данные, например, записи в DB. Возникает вопрос, как связать эти данные и элементы UI на экране.
Предположим, что мы хотим показывать список пользователей в группе AD. Заголовок экрана будет именем группы, а в теле будет ListBox со списком пользователей. Как это можно было бы сделать? Простое, но не самое лучшее решение, прямо в теле страницы (*.xaml.cs) написать что-нибудь аля:
// PageTitle - TextBlock
PageTitle.Text = GroupConnector(GroupId).Name;
// Users - ListBox
Users.ItemsSource = GroupConnector(GroupId).Users;
Это будет работать, но у такого подхода есть множество минусов, вот некоторые из них:
- Сложно проектировать и поддерживать. Код и UI-элементы находятся в тесной связи, а это затрудняет процесс разработки т.к. требует дополнительные ресурсы для синхронизации UI и обслуживающего его кода. Так же такой код тяжело воспринимается сторонними людьми, в особенности если он содержит много элементов, а не два, как в нашем примере.
- Плохо распараллеливается. Т.е. программист зная с какими данными он работает, не может проверить работоспособность своего кода без готовой реализации UI. Если созданием UI и функциональным кодом занимаются разные люди, то эта проблема усиливается еще больше.
- Сложно писать unit-тесты. Код завязанный на элементы UI плохо поддается unit-тестированию, т.к. требует наличия xaml'а и всей инфраструктуры вокруг, что не быстро, проблематично если unit-тесты находятся в другом проекте или UI еще не готов (привет адептам TDD).
Для решения вышеизложенной проблемы служит паттерн Model-View-ViewModel. Про него много написано и я не буду останавливаться на деталях. Более подробно вы можете почитать об этом подходе, например, здесь или немного на хабре.
Если говорить коротко, то смысл MVVM-подхода в разнесении View от ее представления. Т.е. в хорошо спроектированной системе, независимо от того как будет выглядеть ваш конечный UI, представление останется неизменным, т.к. отражает высокоуровневое представление о данных, не прявязанное к конкретной реализации.
При разработке WP7-приложений MVVM-подход применяется повсеместно. Некоторые делают собственные решения, другие использует уже готовые наработки в виде одного из framework'ов (например, Prism и MVVM Light Toolkit). Caliburn.Micro как раз и является таким framework'ом. Т.е. это реализация MVVM-подхода с набором приятных плюшек.
Начало работы
Ok, мы создали проект, давайте подключим туда необходимые CM (далее я буду использовать это сокращение, чтобы каждый раз не писать Caliburn.Micro) библиотеки. Для этого:
- Создадим папку CaliburnMicro (или с любым другим названием) внутри проекта и скопируем туда файлы Caliburn.Micro.dll, Caliburn.Micro.Extensions.dll и System.Windows.Interactivity.dll из дистрибутива CM (binWP71Release).
- Добавим новые файлы в reference'ы.
Для работы CM нужно установить свою обертку (Bootstrapper) над основным классом приложения Application. Эта обертка будет отвечать за все изменения состояния приложения (активацию, деактивацию, переходы и т.п.) и что самое главное, именно здесь будут описываться все наши ViewModel классы, но об этом чуть позже.
Создадим класс SampleBootstrapper, наследник PhoneBootstrapper и добавим его в наш проект:
public class SampleBootstrapper : PhoneBootstrapper
{
private PhoneContainer _container;
protected override void Configure()
{
_container = new PhoneContainer(RootFrame);
_container.RegisterPhoneServices();
AddCustomConventions();
}
private static void AddCustomConventions(){}
protected override object GetInstance(Type service, string key)
{
return _container.GetInstance(service, key);
}
protected override IEnumerable<object> GetAllInstances(Type service)
{
return _container.GetAllInstances(service);
}
protected override void BuildUp(object instance)
{
_container.BuildUp(instance);
}
}
Пусть вас не смущает этот класс, в дальнейшем из него нам понадобится только метод Configure.
Теперь нужно указать приложению, что нужно использовать SampleBootstrapper. Для этого нужно отредактировать файл App.xaml, удалив оттуда все кроме ссылки на наш Bootstrapper:
<Application
x:Class="CaliburnMicroSamples.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:CaliburnMicroSamples="clr-namespace:CaliburnMicroSamples">
<Application.Resources>
<CaliburnMicroSamples:SampleBootstrapper x:Key="bootstrapper" />
</Application.Resources>
</Application>
Т.к. обработку евентов вроде Launching, Activated и пр. теперь на себя берет SampleBootstrapper, то старые методы, отвечающие за это раньше, из App.xaml.cs можно удалить. Т.е. класс App, теперь будет выглядеть намного проще:
public partial class App
{
public App()
{
InitializeComponent();
}
}
Компилируем и запускаем, если все выглядит как раньше, т.е. до изменений связанных с CM, значит вы все сделали правильно и можно идти дальше, если же приложение падает, то значит была допущена ошибка и нужно ее исправить до продолжения следующих экспериментов.
Первая ViewModel
Без использования сторонних framework'ов и при использовании ViewModel, эта ViewModel создается следующим образом. Создается класс реализующий ViewModel, далее ссылка на него передается в DataContext внутри страницы PhoneApplicationPage аля:
DataContext = ViewModelsConstructor.GetViewModel(this);
Тоже самое можно сделать и средствами xaml. Все это нужно чтобы работал binding пропертей ViewModel и элементов View (UI контролов).
В CM этот подход немного отличается. В нем нет необходимости устанавливать контекст самостоятельно, это на себя берет framework. Также нет необходимости работать с DataContext и файлами *.xaml.cs вообще, что позволяет еще больше приблизиться к идеальному MVVM, еще дальше отстранившись от View и xaml в частности. В CM применяется декларативный подход для описания VM (ViewModel), который я далее и постараюсь продемонстрировать.
После создания приложения у вас уже есть экран, создаваемый по умолчанию: MainPage. Давайте попробуем создать для него класс реализующий ViewModel с помощью CM. Для этого нужно создать класс с таким же именем как имя файла с расширением xaml, с окончанием ViewModel (т.е. класс MainPageViewModel), поместить его в отдельный файл с таким же именем и отнаследовать от CM класса Screen (в этом классе уже инкапсулированы все методы для работы с текущей страницей, но об этом я напишу позже). Т.е. класс должен выглядеть вот так:
public class MainPageViewModel : Screen
{
public MainPageViewModel()
{
}
}
Последнее, что осталось сделать это зарегистрировать нашу VM в Bootstrapper'e. Для этого в классе SampleBootstrapper, в метод Configure достаточно добавить следующий код:
_container.PerRequest<MainPageViewModel>();
И все! Наша ViewModel создана и будет привязана CM к странице MainPage при ее создании. PerRequest означает что класс MainPageViewModel будет создаваться каждый раз при запросе страницы MainPage. Если использовать метод Singleton, то класс MainPageViewModel будет реюзиться при всех последующих запросах.
Давайте посмотрим как работает binding свойств. Для этого добавим в наш VM класс свойство PageTitle и присвоим ему какое-нибудь значение, например, «sample page»:
public class MainPageViewModel : Screen
{
public string PageTitle { get; set; }
public MainPageViewModel()
{
PageTitle = "sample app";
}
}
И запустим приложение. Название страницы изменилось! Это произошло потому что CM взял все вопросы по связи ViewModel и View на себя и связал свойство PageTitle по его имени (в xaml оно называется также).
Привычный binding через указания связи напрямую (аля Text="{Binding PageTitle, Mode=TwoWay}"
) в xaml, также работает, но согласитесь, без него значительно проще.
Это был достаточно простой пример, о более сложных вариантах binding'а и возможностях CM в этом плане я расскажу позже, а пока продолжим.
Навигация
Помните наш пример с пользователями и группой из AD? Логично предположить, что для того чтобы показать информацию о какой-то группе нужно куда-то передать информацию о том, а что это за группа. Для простоты пусть это будет ее имя. Это «куда-то» конечно же VM, т.к. именно ей необходимо знать, что это за группа, чтобы вернуть нужные данные в View.
Стандартный способ решения такой задачи, это навигация с параметрами. Т.е. мы говорим, что хотим перейти на такой-то экран (в данном случае со списком пользователей), а в качестве параметра передаем идентификатор группы. Обычно это делается вот так:
NavigationService.Navigate(new Uri("/GroupPage.xaml?name=Administrators", UriKind.Relative));
А далее в OnNavigatedTo страницы GroupPage.xaml этот параметр извлекается конструкцией:
var name = NavigationContext.QueryString["name"];
Потом передается (например, через конструктор) в класс реализующий модель, а далее она передается в DataContext.
Согласитесь, не самое красивое решение, для такой простой задачи.
Другой популярный способ это использовать PhoneApplicationService.Current.State, для обмена шаренными данными:
PhoneApplicationService.Current.State["name"] = "Administrators";
Или просто через глобальные переменные внутри класса Application, благо его экземпляр можно получить где угодно через Application.Current.
Вообщем все это достаточно криво. Последние два примера плохи тем что нелепо использовать шаренные данные для обмена между экранами, в особенности когда есть стандартный способ для этого. Первый пример плох тем что очень легко сделать опечатку в пути, да и «шелуха» с выдергиванием этого значения (еще хуже если их много) из NavigationContext, с последующей передачей все этого куда-то, тоже не радует.
А вот как это делается в CM:
_navigationService.UriFor<GroupPageViewModel>()
.WithParam(x => x.Name, "Administrators")
.Navigate();
GroupPageViewModel — VM, сделанная по аналогии как мы делали MainPageViewModel, с публичным свойством Name:
public class GroupPageViewModel : Screen
{
public string Name { get; set; }
}
А _navigationService — объект реализующий INavigationService, которой можно получить внутри любой VM унаследованной от класса Screen и использовать для навигации между экранами. Через метод WithParam можно задать свойства VM у страницы назначения. INavigationService — это обертка над стандартным NavigationService, включающая в себя весь его функционал и расширяющая дополнительными возможнотсями.
Вот так выглядит получение экземпляра INavigationService для класса MainPageViewModel, CM просто передает объект INavigationService в конструктор MainPageViewModel:
public class MainPageViewModel : Screen
{
private INavigationService _navigationService;
public string PageTitle { get; set; }
public MainPageViewModel(INavigationService navigationService)
{
PageTitle = "sample app";
_navigationService = navigationService;
}
}
Плюсы очевидны:
- Очень мало кода, который нужно написать разработчику, чтобы передать параметр в VM.
- Т.к. INavigationService специализирован классом VM назначения и параметры задаются в привязке к этому классу, то невозможно ошибиться/опечататься при задании этих параметров, т.к. такой код просто не скомпилируется.
- Т.к. передается интерфейс, то поведение INavigationService хорошо мокируется, что очень удобно при написании unit-test'ов.
- Код полностью отвязан от UI.
Минусы:
- Сложные типы так передать не получится. Здесь нет никакой магии, CM не может сделать, то чего нет в WP7, т.к. базируется на ее же возможностях. Реализация базируется на стандартной реализации NavigationService (та что в первом примере). Но это можно обойти если использовать сериализацию и немного подправить исходники CM.
INotifyPropertyChanged
Оставим пользователей из AD, и представим другой пример. Допустим у нас есть почтовая программа, на одной из страниц которой находится список доступных почтовых ящиков с бейджиком непрочитанных сообщений.
Основное отличие от предыдущих примеров заключается в том, что данные теперь не статичные, а могут изменяться во времени (счетчик непрочитанных сообщений). Т.е. если представить, что у нас есть свойство отвечающее за количество непрочитанных сообщений, то нужен механизм чтобы уведомлять View о том, что оно изменилось. Для этой цели используется механизм уведомлений на базе реализации интерфейса INotifyPropertyChanged. Стандартный способ реализация этого подхода выглядел бы так:
public class MailboxPageViewModel : INotifyPropertyChanged
{
private int _unreadCount;
public int UnreadCount
{
get { return _unreadCount; }
set
{
_unreadCount = value;
NotifyPropertyChanged("UnreadCount");
}
}
public event PropertyChangedEventHandler PropertyChanged;
public void NotifyPropertyChanged(string propertyName)
{
if (PropertyChanged != null)
PropertyChanged(this,
new PropertyChangedEventArgs(propertyName));
}
}
Как видно, здесь сигналится event PropertyChanged при изменении свойства UnreadCount, что приведет к перечитыванию View его значения.
У этого кода есть два недостатка:
- Имя свойства задается по имени, а это чревато ошибками.
- При обновлении свойства не из UI-потока, приложение упадет с Invalid cross-thread access исключением.
Первое решается с использованием Expression'а, второе проверкой возможности выполнения операции в текущем потоке через Dispatcher.CheckAccess() и если операцию выполнить невозможно, то перезапуском ее же, но уже в UI потоке. Не сложно, но все же свой велосипед, тем более в CM об этом подумали. Вот код, которым можно заменить пример выше:
public class MailboxPageViewModel : Screen
{
private int _unreadCount;
public int UnreadCount
{
get { return _unreadCount; }
set
{
_unreadCount = value;
NotifyOfPropertyChange(() => UnreadCount);
}
}
}
CM метод NotifyOfPropertyChange решает описанные проблемы и доступен сразу же «из коробки».
В CM также можно найти класс BindableCollection, решающий схожие проблемы с доступом из не UI-потоков другого класса, реализующего INotifyPropertyChanged и используемого для хранения коллекций объектов, класса ObservableCollection. Т.е. при использовании BindableCollection можно не беспокоится о том в каком потоке вызываются методы коллекции.
Tombstone
Одна из проблем, с которой сталкиваются разработчики при написании приложений для WP7, является необходимость восстановления состояния приложения после выхода из состояния Tombstone.
В WP7 для этого используется PhoneApplicationService.State, IsolatedStorage и прочие решения. Работать с тем что есть без дополнительных оберток не так уж удобно. CM предлагает элегантное решение. Если вам нужно чтобы данные VM пережили tombstone, то достаточно создать класс c именем VM и окончанием Storage. Класс для нашего примера со списком пользователей будет называться GroupPageViewModelStorage и будет выглядеть вот так:
public class GroupPageViewModelStorage : StorageHandler<GroupPageViewModel>
{
public override void Configure()
{
Property(x => x.Name)
.InPhoneState()
.RestoreAfterActivation();
}
}
StorageHandler — это класс CM, реализующий стратегия сохранения данных внутри VM. В этом примере, мы говорим что хотим сохранить свойство Name внутри PhoneApplicationService.State и то что данные нужно восстановить вместе с активацией страницы.
Т.к. CM ничего не знает и не может знать о данных хранимых в Model, то их нужно сохранять отдельно. Для этого нужно добавить необходимый код по сериализации/десериализации, отвечающий логике вашего приложения, внутрь соответствующих методов Bootstapper'а (Launching, Closing, Deactivated и т.п.).
Конец первой части
Здесь я понял, что статья получается больше чем я ожидал вначале и что необходимо написать еще столько же. Это более чем достаточно для вводно-обзорной статьи, поэтому я решил разбить ее на две части.
Продолжение следует...
Автор: NevRA