Разработка с использованием паттерна проектирования Model-View-ViewModel на примере Twitter клиента шаг за шагом

Введение

Статья посвящена работе с MVVM и WPF. В ней описывается процесс разработки twitter client. Процесс разработки разбит на шаги. В конце каждого шага читатель параллельно пишущий приложение должен иметь работающее приложение. Каждый последующий шаг добавляет какую-то функциональность к написанному на предыдущем шаге. Используется thirdparty библиотека TweetSharp. Ссылку на исходный код, а так же оригинал статьи, написанный мной на английском, можно найти тут.
Статья рассчитана на новичков в WPF разработке. Но предполагается, что читатель имеет некоторый начальный опыт работы с WPF, в частности освоил data binding.
Я не буду писать зачем нужно использовать MVVM – считаю, что об этом хорошо написано в статье “Приложения WPF с шаблоном проектирования модель-представление-модель представления” от Джоша Смита. Если вы не хотите читать эту статью – просто поверьте мне – неверное спроектированное GUI в случае с WPF превращается в большую головную боль.


Предварительный анализ твиттер клиента


Перед тем, как перейти к проектированию дизайна нашего клиента, давайте взглянем на GUI какого-нибудь существующего клиента, чтобы понять какую функциональность мы должны предоставить пользователю:
image
Пользователь использует несколько закладок вверху страницы – recent, replies, user и так далее. В зависимости от выбранной закладки в основном пространстве окна отображается разная информация. Например, при нажатии на Recent демонстрируются последнии твитты из вашей подписки, а при нажатии на Messages в основном окне появятся ваши прямые сообщения.
Поразмыслив еще немного, мы понимаем, что разумно использовать TabControl.

Кратко о MVVM

Начну с примера. Допустим вам надо написать UserControl, который будет отображать твитты. Вы нажмете добавить UserControl, накидаете в xaml нужные элементы управления. Далее вы создадите свойство в code behind, где будете хранить твитты. Затем в тот же code behind добавите обработчик событий – например нажатие на UserPicture в твитте. В общем все так же, как если бы вы делали этот контрол с использованием технологий предыдущего покаления вроде WinForms. Повнимательнее присмотримся к этому контролу — у него есть описание части видимой пользователю – те xaml. А так же есть code behind.
Теперь положим, вам приходит первый feature request — пользователю захотелось отображать другие твитты, но в таком же точно контроле. Вы может быть сделаете соответствующий флаг в code behind или, если вы более искушены в software design, будете использовать GoF’s стратегию. Спустя какое-то время приходит второй feature request — каждый твитт содержит слишком много ненужной на взгляд пользователя информации и вам нужно сделать новый look вашему контролу, но в то же время не хотите обидеть старых пользователей и оставляете возможность работы со старым визуальным представлением. И так далее – требования поступают все новые, вы используете различные трюки и хитроумные решения, чтобы их реализовать.
Стоп! Вернемя назад во времени – к ситуации когда был только один контрол. Что если разнести code behind и сам класс контрола? Те разделить сущность на две. Одну назвать View – это то что видит пользователь, те класс унаследованный от UserControl. А вторую назвать ViewModel – это даные непосредственно связанные с тем, что показывается пользователю через View, а так же функции-обработчики событий, вроде нажатия по аватару. Тогда можно будет легко решить первый feature request – вы просто напишите ViewModel и сопоставите ему старый View. Так же легко будет решить и первый feature request – вы можете сопоставлять одной ViewModel разные View. Хорошая идея – думаете вы.
Но как сделать связь между View и ViewModel? Для этого используется data binding – ключевой инструмент в WPF. Вы укажите View тем или иным способом какой DataContext ей использовать, те скажите откуда брать данные, и дальше будете биндиться к данным почти точно так же как и в случае с code behind. Я намеренно опускаю здесь технические детали – они будут рассказыны далее.
Так выглядит типичная диаграмма классов для приложения написанного с использованием MVVM:
image
Есть классы View, они используют данные из ViewModel, но ничего не знают о модели (в нашем случае модель реализованна в библиотеке TweetSharp). ViewModel в свою очередь не знает о том, какой именно View их использует, но могут взять информацию из Model.
Обычно так же есть некоторые главные View и ViewModel, которые агрегируют остальные View и ViewModel.

Шаг 1. Каркас приложения

Для начала создадим приложение с TabControl, содержащем только один TabItem – Recent. Будем действовать в соответствии с MVVM.
Создадим новое WPF приложение. В окне MainWindow добавим TabControl:

<TabControl Height="400"
HorizontalAlignment="Left"
Name="Tabs"
VerticalAlignment="Top" Width="300">
/>


Это окно будет главным элементом View. В нем будут отображаться все остальные элементы (tabItems). Ему должен соответствовать соответствующий класс ViewModel, который будет главным в своей области. Именно в нем, а не в MainWindow’s code behind, мы будем хранить данные и описывть функции-обработчики событий. Создадим новый public класс SimpleTwitterClientViewModel. Пусть пока он будет пустым. Теперь нам нужно указать классу окна, чтобы он искал данные не в code behind, а в SimpleTwitterClientViewModel. Те нам нужно задать DataContext. Для этого добавим поле ViewModel в code behind, добавим свойство имя окну и определим DataContext:
<Window x:Class="SimpleTwitterClient.MainWindow"
x:Name="MainWindowInstance"
xmlns:view="clr-namespace:SimpleTwitterClient.View"
xmlns:viewModel="clr-namespace:SimpleTwitterClient.ViewModel"
DataContext="{BindingViewModel,ElementName=MainWindowInstance}">


public partial class MainWindow : Window
{
private SimpleTwitterClientViewModel _viewModel;

public SimpleTwitterClientViewModel ViewModel
{
get { return _viewModel; }
set { _viewModel = value; }
}

public MainWindow()
{
_viewModel = new SimpleTwitterClientViewModel();
InitializeComponent();
}
}

Теперь создадим Recent’s View и ViewModel. RecentView – будет просто UserControl с пустым code behind. Добавим на него какой-нибудь контрол, чтобы отличить ее от той пустой TabItem, которая есть сейчас. RecentViewModel пусть будет пустым.
SimpleTwitterClientViewModel будет тем классом который будет хранить ViewModel для наших TabItems. Пока у нас есть только Recent. Так что напишем такой код:
public class SimpleTwitterClientViewModel
{
RecentViewModel _recentPage = new RecentViewModel();

public RecentViewModel RecentPage
{
get { return _recentPage; }
set { _recentPage = value; }
}
}

Если мы сейчас запустим наше приложение, то увидим, что пока что ничего не изменилось. Нужно еще указать нашей TabItem, чтобы она отображала RecentView. Как это сделать? Здесь тонкий но важный момент – во первых, мы должны установить Content на RecentPage:
/>
Во вторых, нужно создать отображение из ViewModel в View. Те код, который бы проверял что если TabItem’s Content – RecentViewModel, то нужно отобразить RecentView. Вот этот код:
<Window.Resources>
<DataTemplateDataType="{x:TypeviewModel:RecentViewModel}">
<view:RecentView />

</Window.Resources>
В результате всех этих действий мы имеем такое приложение:
image
Зачем такие сложности? Это только начало, затем когда приложение станет более сложным, вы оцените преимущества MVVM.

Шаг 2. Первые твитты

Наполним нашу RecentPage данными, те последними твиттами. Для этого мы будем использовать библиотеку TweetSharp. Добавим ее в список сборок.
Для того чтобы использовать наше приложение с Twitter API необходимо сделать ряд махинаций.
1. ConsumerKey and ConsumerSecret. Пройдите по ссылке http://dev.twitter.com/ и нажмите register. После этого появится страница с информацией о вашем приложении, возьмите оттуда ConsumerKey and ConsumerSecret и сохраните их в settings.
2. PIN (oauth_verifier). Когда ваше приложении будет запущено первый раз, оно должно послать запрос содержащий ConsumerSecret и ConsumerKey серверу:
FluentTwitter.SetClientInfo(
new TwitterClientInfo
{
ConsumerKey = Settings.Default.ConsumerKey,
ConsumerSecret = Settings.Default.ConsumerSecret
});

var twit = FluentTwitter.CreateRequest().Authentication.GetRequestToken();

var response = twit.Request();

var RequestToken = response.AsToken();
twit = twit.Authentication.AuthorizeDesktop(RequestToken.Token);

Последняя строка откроет ваш браузер по-умолчанию. Там будет вопрос о том разрешить ли приложению использовать сервис, нажмите Allow и введите PIN в диалоговое окно приложения. Я вызываю этот диалог в методе getPinFromUser:
string verifier = getPinFromUser();
3. AccesToken. Теперь пошлите запрос содержащий consumerKey, consumerSecret иPin. Сервис вернет AccessToken который будет использоваться в дальнейшем:
twit.Authentication.GetAccessToken(RequestToken.Token, verifier);
var response2 = twit.Request();

4. Освежите свой AccessToken. По прошествии некоторого времени, сервис может попросить обновить AccessToken.

Добавьте класс OAuthHandler в папку Model. Туда же добавьте UserControl AskPinFromUserDialog. А в ресурсы вашего приложения добавьте ConsumerKey, ConsumerSecret, полученные от Twitter, когда вы регистрировали свое приложение. Туда же добавьте OauthInfoFileName в которой пропишите путь до конфигурационного файла для нашего приложения. Там будут храниться ключи полученные от twitter. Не очень безопасно, но зато просто. Наконец добавьте создание объекта данного класса в главную ViewModel:
Model.OAuthHandler _oauthHandler = newModel.OAuthHandler();
Теперь можно приступать к работе с RecentPage. Для начала, добавим контейнер для хранения твиттов в класс RecentPage:

public ObservableCollection Tweets
{
get; set;
}

Мы используем ObservableCollection, а не какой-нибудь List потому что этот контейнер синхронизирует данные хранимые в нем и элементы View, которые биндятся на него.
Теперь скачаем твитты. Чтобы что-либо скачивать нам нужна идентефикационная информация которая хранится в объекте _oauthHandler главного ViewModel. Добавим конструктор для RecentViewModel, куда передадим _oauthHandler и сохраним ссылку на этот объект там.
Замечу, что тут уместно было бы сделать _oauthHandler сигнлетоном, но для простоты оставим наше решение как есть.
Пора написать метод для скачивания твитттов. Для этого добавим метод LoadTweets к RecentViewModel:
public void LoadTweets()
{
TwitterResult response = FluentTwitter
.CreateRequest()
.AuthenticateWith(
Settings.Default.ConsumerKey,
Settings.Default.ConsumerSecret,
Model.OAuthHandler.Token,
Model.OAuthHandler.TokenSecret)
.Statuses()
.OnHomeTimeline().AsJson().Request());

var statuses = response.AsStatuses();
foreach (TwitterStatus status in statuses)
{
Tweets.Add(status);
}
}

Для первого раза позовем этот метод в конструкторе RecentViewModel:
public RecentViewModel(Model.OAuthHandleroauthHandler)
{
_oauthHandler = oauthHandler;
Tweets = newObservableCollection();
LoadTweets();
}

Далее создадим ListBox который будет отображать твитты из контейнера:
<ListBoxx:Name="RecentTweetList"
ItemsSource="{Binding Path=Tweets}"
IsSynchronizedWithCurrentItem="True"/>

Если теперь запустить приложение, то вместо твиттов в тексте будет выведен результат вызова метода TweetStatus.ToString(). Это происходит потому, что элементы ListBox не знают о том, как отображать объект типа TweetStatus. Для того, чтобы сказать ему об этом, нужно создать DataTemplate и разместить его в UserControl.Resources. Внутри DataTemplate мы пишем биндинг так, как будто мы используем TweetStatus. Например, у TweetStatus есть свойство Text, если мы хотим чтобы наш элемент биндился к нему мы пишем Binding Path=Text.
Вот код DataTemplate:

<DataTemplate x:Key="TweetItemTemplate">
<Grid x:Name="TTGrid">
<Grid.RowDefinitions>
/>
/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
/>
/>
/>
</Grid.ColumnDefinitions>
<Image Source="{Binding Path=User.ProfileImageUrl}"
Name="UserAvatarImage" />
<TextBlock
Name="ScreenNameTextBlock"
Text="{Binding Path=User.ScreenName}"
Grid.Row="1"Grid.ColumnSpan="2"/>
<TextBlock
Text="{Binding Path=Text}"
TextWrapping="Wrap"Grid.Column="1"Grid.ColumnSpan="2" />



Теперь укажем нашему ListBox, что нужно использовать объявленный DataTemplate:
ItemTemplate="{StaticResourceTweetItemTemplate}"
Теперь наше приложение должно выглядеть примерно так:
image

Шаг 3. Новые страницы

Закладка Recent отображает последний твитты, которые написали те, за кем вы следите, а так же ваши собственные твитты. В терминах TweetSharp это HomeTimeline. Но это не все твитты, которые вас могут заинтересовать. Если вы зайдете в класс TwitterStatusesExtensions, в котором объявлен метод OnHomeTimeline, то увидете другие методы возвращающие список твиттов, сгруппированных по различным критериям: OnFriendsTimeline, OnListTimeline, RetweetedByMe…
Создадим страницу которая будет содержать ретвитты пользователей на которых вы подписаны. Аналогично созданию Recent, создадим RetweetsViewModel. Я не буду подробно описывать их код, оставив читателю в качесте упражнения. Вы думаете написать еще и View? Хм, но ведь инфорация отображаемая в Recent и Retweets структурно одинаковая. MVVM и WPF позволяют минимизировать copy-past – те в данном случае пока не думайте о RetweetsView. В конце Шага 3 вы увидите, что это излишне.
Теперь нам нужно добавить объект класса RetweetsViewModel в главную ViewModel. Если у нас будет только две страницы – то это нормальное решение. Но мы хотим сделать много страниц. Так что это решение не лучшее. Лучше хранить в главной ViewModel контейнер с различными ViewModel. Замечаем, что RetweetsViewModel очень похож на RecentViewModel и можно с легкостью применить Extract Interface from class. Так же вынесем OauthHandler в свойства и не будем его инициализировать в конструкторе, а сделаем это в базовом классе. Назовем интерфейс IPageBase:

public interface IPageBase
{
void LoadTweets();
ObservableCollection Tweets { get; set; }
}


Меняем соответствующим образом код главной ViewModel:
ObservableCollection _pages;

public ObservableCollection Pages
{
get { return _pages; }
set { _pages = value; }
}

public SimpleTwitterClientViewModel()
{
_pages = new ObservableCollection();
_pages.Add(new RecentViewModel());
_pages.Add(new RetweetsViewModel());
foreach (var page in _pages)
{
page.LoadTweets();
}
}

Теперь добавим в основню View DataTemplate для RetweetsPage. Возникает дилема с TabItem – у нас был один tabItem с прямым биндингом к свойству ViewModel RecentPage:
/>

Но теперь у нас должно быть столько TabItems сколько объектов в Pages. На наше счастье у класса TabControl есть свойство ItemsSource. Если ему забиндить Pages то можно вообще не писать TabItems – они сами создадутся по элементам контейнера Pages. Но тут есть одна тонкость – TabItems как мы помним имеют Header. А как нам указать что писать в header для сгенерированных TabItems? Для начала добавим свойство Name в интерфейс IPageBase. И в каждом классе проинициализируем его по-своему. Далее, используя стили укажем генерируемым TabItem как нужно выставлять свойство Header:

<TabControlName="Tabs"
ItemsSource="{Binding Pages}">
<TabControl.ItemContainerStyle>

</TabControl.ItemContainerStyle>


Остался один штрих – сопоставить RetweetsViewModel соответствующий View. Но я говорил не писать RetweetsView. Потому что можно просто взять RecentView! Это особенная магия MVVM!
<DataTemplateDataType="{x:TypeviewModel:RetweetsViewModel}">
<view:RecentView />


Это все. Теперь все работает:
image
Если вам не нравится писать два очень похожих DataTemplate – можно сделать супер класс для ViewModel которые отображают твитты.
Упражнение – напишите страницу для списка follower & following.

Шаг 4. Отправка твиттов и адаптер для ICommand

Итак, мы сделали приложение для чтения твиттов. Это было бы достаточно для описания паттерна MVVM если бы не одна деталь – мы не делали обработчики событий вроде OnMouseClick. Для полноты картины надо исправить этот недостаток. Для демонстрациии, мы добавим возможность отправки твиттов. Добавьте TextBox для ввода твитта, а так же кнопку для отправки:
image
Если бы вы хотели чтобы обработка нажатия кнопки происходила в code behind то вы бы просто дважды кликнули на кнопку в дизайнере и в появившемя теле функции писали бы код – отправить EnterTweetTextBox.Text. Если вы придерживаетесь MVVM то все немного сложнее. Вам нужно создать сначала указать где в ViewModel хранить текст сообщения, зачем задать обработчик события нажатия кнопки Send. Далее нужно сообщить кнопке о том, что она должна использовать ваш метод для обработки соответствующего события.
Итак, начнем с того что укажем EnterTweetTextBox куда ему сохранять введенный пользователем текст. Для простоты сделаем это в SimpleTwitterClientViewModel – создадим там свойство Message. Далее сделаем биндинг для свойства Text:
<TextBoxName="EnterTweetTextBox"
Text="{BindingMessage}"/>

Теперь добавим метод SendTweet в класс SimpleTwitterClientViewModel:
private void SendTweet()
{
var twitter = FluentTwitter.CreateRequest();
twitter.AuthenticateWith(
Settings.Default.ConsumerKey,
Settings.Default.ConsumerSecret,
OAuthHandler.Token,
OAuthHandler.TokenSecret);
twitter.Statuses().Update(Message);

var response = twitter.Request();
//you can verify the response here
}

Здес возникает вопрос – как сказать кнопке чтобы она использовала наш метод для обработки? Добайте разбираться – у Button есть свойство Command, которое задает команду которая исполняется когда кнопка нажата. Но комманда должна реализовывать интерфейс ICommand. Само собой наша SendMessage, будучи функцией, этот интерфейс поддерживать не может. Возникает вопрос как сделать так чтобы это было возможно? Решение – разработать класс-adapter который бы адаптировал функции к интерфейсу ICommand. К счастью такой класс уже сделан и даже описан в статье Josh Smith:
internal class RelayCommand : ICommand
{
#region Fields

readonly Action _execute;
readonly Func _canExecute;

#endregion

#region Constructors

public RelayCommand(Action execute)
: this(execute, null)
{
}

public RelayCommand(Action execute, Func canExecute)
{
if (execute == null)
throw new ArgumentNullException("execute");

_execute = execute;
_canExecute = canExecute;
}

#endregion // Constructors

#region ICommand Members

[DebuggerStepThrough]
public bool CanExecute(object parameter)
{
return _canExecute == null ? true : _canExecute();
}

public event EventHandler CanExecuteChanged
{
add
{
if (_canExecute != null)
CommandManager.RequerySuggested += value;
}
remove
{
if (_canExecute != null)
CommandManager.RequerySuggested -= value;
}
}

public void Execute(object parameter)
{
_execute();
}

#endregion // ICommand Members
}

Все что делает класс RelayCoommand это адаптирует делигат к интерфейсу ICommand. Чтобы его использовать надо создать свойство, которое бы обертывало функцию SendTweet ICommand:
RelayCommand _sendCommand;
public ICommand SendCommand
{
get
{
if (_sendCommand == null)
{
_sendCommand = new RelayCommand(() => this.SendTweet());
}
return _sendCommand;
}
}

Наконец, скажем SendButton о том, что нужно использовать именно SendCommand:
<Button Name="SendTweetButton"
Command="{Binding SendCommand}"/>

Вот и все не так уж сложно, не так ли? Так в итоге выглядит твиттер клиент:
image
Чтобы он походил на существующие твиттер клиенты, я добавил стилистический эффект – за текстом твитта отображается сколько символов еще можно вводить.
В качестве упражнения, попробуйте реализовать следующую feature: если пользователь кликает на userpicture, то TabItem меняется. Новый TabItem отображает твитты пользователя, чей аватар был нажат.
Share post

Similar posts

Comments 24

    +1
    Очень интересная статья. Как по-мне для новичков — находка.
    Для себя тоже нашел кое что интересное, автору спасибо!
      +7
      Удели внимание форматированию. Требуется оформление!
        0
        Знал бы как. Я замучился с ним. Я писал вокруг каждого фрагмента кода тег и ожидал увидеть выделение кода a-la codepoject (там примерно так же обрамляется код тегом code). Но нет. Как сделать красивое форматирование кода здесь?
    0
    спасибо за статью, с удовольствием прочитал!
    • UFO just landed and posted this here
        +1
        Подправьте форматирование плиз!
          0
          Скажите как или ссылку дайте. Я ожидаю что когда я код обрамляю тегом <code /> код будет подсвечен как на codeproject. Но нет — так не произошло. Вместо это съехали пробелы, а в середине вообще кошмар.
      0
      только я вижу тут тавталогию?
        0
        покаления

        опечатку заметил…

        В чистом виде этот паттерн хорош для небольших приложений. В серьезных LoB-аппликейшенах начинаются трудности. Большое количество разнотипных свойств, каждое из которых может быть представлено на разных «View» по-разному. Плюс большое количество асинхронных запросов. В итоге добавление большого количества «data-binding» приводит в созданию по крайне мере видимой тормознутости софтины. Особенно когда менеджеры на стороне заказчика пускают слюни на красоты WPF.
          0
          Для больших приложений, я так думаю, предлагается использовать Prism, а не встроенный механизм DataTemplates. Хотя опять таки я вот например уже второй месяц кручу в голове как использовать призму в одном своем проекте, и все больше мне кажется что этот паттерн заточен для классических DDD приложений. то-есть, где основной задачей работы приложения есть ворочение данных, это и классические POS терминалы, киоски итд… А вот как этот паттерн прикрутить к векторному графическому редактору(немного расширенному) я что-то немогу представить:(
            +2
            PRISM и прочие — это хорошо. Но! Фреймворки не заменяют понимание паттерна. Мне хотелось сделать статью о MVVM начального уровня. Те без лишних деталей, которые отнюдь не упрощают понимание кода. Когда я сам разбирался с этим паттерном постоянно натыкался на overengineered toy примеры кода, в которых было всего до полна, но задачи не из реальной жизни.
              0
              Согласен, проблема MVC в том, что обещают, что он будет незаменим когда приложение станет сложным, а обьясняют на примерах что и нафик он там не нужен.
          0
          Как это сделать? Здесь тонкий но важный момент – во первых, мы должны установить Content на RecentPage:
          />


          парсер съел, что тут должно быть?
            0
            Разобрался сам :)
            <TabItem Content="{Binding ElementName=MainWindowInstance, Path=ViewModel.RecentPage}" />
              0
              Блин, туплю, мы же задали контекст, можно просто
              <TabItem Content="{Binding Path=RecentPage}" />
              
            0
            Отформатируйте, наконец-то, статью и код (
              0
              Статья хорошая, но обилие грамматических ошибок и не отформатированный код…
                0
                Я не знаю как ее отформатировать. На мой взгляд код не верно парсился. Потому что верстка страницы такая же как в codeproject — там работает, здесь нет. Я пару часов убил на разбирание с тем как движок хабра парсит это несчастье и сдался. Если у вас есть желание помочь — я могу вам прислать и потом запостить отформатированный код. Напишу во вступлении «спасибо за помощь в редактировании статьи такому-то». А ну и я патологический не грамотен в плане русского
              0
              Отличная статья, правда вначале возникла проблема с DataContext'ом. Ибо кусок кода:

              <Window x:Class="SimpleTwitterClient.MainWindow"
              x:Name="MainWindowInstance"
              xmlns:view="clr-namespace:SimpleTwitterClient.View"
              xmlns:viewModel="clr-namespace:SimpleTwitterClient.ViewModel"
              DataContext="{BindingViewModel,ElementName=MainWindowInstance}"> <!-- Именно здесь. так и не понял что такое  MainWindowInstance -->
              


              ни в какую не хотел работать. Проблема разрешилась вот таким образом:

              <Window x:Class="SimpleTwitterClient.MainWindow"
                      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                      xmlns:view="clr-namespace:SimpleTwitterClient.View"
                      xmlns:viewModel="clr-namespace:SimpleTwitterClient.ViewModel"
                      Title="MainWindow" Height="430" Width="310" ResizeMode="NoResize">
                  <!-- Default data context -->
                  <Window.DataContext>
                      <viewModel:SimpleTwitterClientViewModel/>
                  </Window.DataContext>
              


              Да, и в оригинале читать было удобней. Поскольку форматирование этой статьи, мягко сказать не очень.
                0
                Я ступил. Ответ нашелся
                x:Name="MainWindowInstance"
                

              Only users with full accounts can post comments. Log in, please.