пятница, 9 апреля 2010 г.

Шпаргалка по MVVM в WPF

Тема, конечно, неоднократно поднятая, но я все равно напишу шпаргалку на тот случай, если вдруг когда-нибудь забуду. А то с WCSF тоже вроде разобралась, а прошло два месяца со сдачи проекта, и уже ничего и не помню...

Сразу предупрежу, что статья предназначена для того, чтобы быстро въехать в MVVM или быстро его вспомнить. Так что описание будет предельно краткое и схематичное.

Итак, MVVM. Расшифровывается как Model-View-ViewModel. Рассмотрим на примере.

Пример

Для начала отмечу, что для упрощения нашей жизни с данным паттерном добрые люди разрабатывают MVVM Toolkit, включающий шаблон для Visual Studio:

WPF Model-View Application

Для данного проекта сразу создается следующая структура файлов:

Файлы WPF Model-View Application

Задача

Возьмем какую-нибудь каноничную задачу. Например, отображение списка книг читального зала. У книги есть:

  • Название
  • Автор
  • Доступное количество

Ну и хватит пока. Добавим немного интерактивности. Пусть к нам ходят читатели и то и дело берут книги почитать или возвращают их обратно. А нам надо в любой момент знать, сколько экземпляров той или иной книги у нас осталось и можем ли мы ее выдать.

На примере такой незатейливой задачи мы и рассмотрим MVVM

Model

Как несложно догадаться, Model — это сущности системы. У нас модель будет состоять из одного простого класса:

Book.cs

class Book
{
    public string Title { get; set; }
    public string Author { get; set; }
    public int Count { get; set; }

    public Book(string title, string author, int count)
    {
        this.Title = title;
        this.Author = author;
        this.Count = count;
    }
}

ViewModel

ViewModel — это, пожалуй, ключевой момент всей этой истории. Это такие специальные классы, которые:

  • Осуществляют связь между моделью и формой.
  • Отслеживают изменения в данных, произведенные пользователем.
  • Обрабатывают логику работы View (механизм команд)

В учетом датабиндинга в WPF всё это дает замечательный результат: в C#-коде формы становится совсем не надо ничего писать. Впрочем, обо все по порядку. Напишем ViewModel для нашей модели:

Book.cs

class BookViewModel : ViewModelBase
{
    public Book Book;

    public BookViewModel(Book book)
    {
        this.Book = book;
    }

    public string Title
    {
        get { return Book.Title; }
        set
        {
            Book.Title = value;
            OnPropertyChanged("Title");
        }
    }

    public string Author
    {
        get { return Book.Author; }
        set
        {
            Book.Author = value;
            OnPropertyChanged("Author");
        }
    }

    public int Count
    {
        get { return Book.Count; }
        set
        {
            Book.Count = value;
            OnPropertyChanged("Count");
        }
    }
}

BookViewModel унаследован от класса ViewModelBase, который заботливо сгенерил нам MVVM Toolkit. ViewModelBase же, в свою очередь, реализует интерфейс INotifyPropertyChanged и содержит функцию OnPropertyChanged. Все это нужно для того, чтобы всегда можно было вызвать событие "изменилось такое-то поле". Как видно в коде, при любом изменении поля мы такое событие вызываем и передаем в качестве параметра его название. Потом на форме биндинг может это событие обработать и, как следствие, интерфейс и ViewModel всегда будут друг с другом синхронизированы. Впрочем, это я опять забегаю вперед.

Помимо BookViewModel у нас есть еще класс MainViewModel, уже сгенерированный и даже связанный с формой. Добавим в него поле:

ObservableCollection<BookViewModel> BooksList { get; set; }

ObservableCollection — это такая специальная коллекция, которая умеет отслеживать изменения в себе. Также слегка изменим конструктор:

public MainViewModel(List<Book> books)
{
    BooksList = new ObservableCollection<BookViewModel>(books.Select(b => new BookViewModel(b)));
}

View

Это и есть наше окно, либо User Control. У любого FrameworkElement-а WPF есть такое поле DataContext. DataContext может быть любым object-ом, иметь какие угодно поля, а его главная задача — являться источником данных для Databinding-а. Форма у нас всего одна, DataContext для нее заполняется в методе OnStartup, что в App.xaml.cs. Немного модифицируем то, что сделал нам MVVM Toolkit, получится следующее:

App.xaml.cs

private void OnStartup(object sender, StartupEventArgs e)
{
    List<Book> books = new List<Book>()
    {
        new Book("Колобок", null, 3),
        new Book("CLR via C#", "Джеффри Рихтер", 1),
        new Book("Война и мир", "Л.Н. Толстой", 2)
    };
       
    MainView view = new MainView(); // создали View
    MainViewModel viewModel = new ViewModels.MainViewModel(books); // Создали ViewModel
    view.DataContext = viewModel; // положили ViewModel во View в качестве DataContext
    view.Show();
}

Осталось написать XAML-код формы. Он прост и незатейлив:

MainView.xaml

<Window x:Class="SampleMVVM.Views.MainView"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:c="clr-namespace:SampleMVVM.Commands"
    Title="Main Window" Height="400" Width="800">
   
    <ListView ItemsSource="{Binding BooksList}">
        <ListView.ItemTemplate>
            <DataTemplate>
                <Border BorderBrush="Bisque" BorderThickness="1" Margin="10">
                    <StackPanel Margin="10">
                        <TextBlock Text="{Binding Title}" FontWeight="Bold"/>
                        <TextBlock Text="{Binding Author}" />
                        <StackPanel Orientation="Horizontal">
                            <TextBlock Text="Осталось:" />
                            <TextBlock Text="{Binding Count}" FontWeight="Bold" Margin="10,0"/>
                            <TextBlock Text="шт" />
                        </StackPanel>
                    </StackPanel>
                </Border>
            </DataTemplate>
        </ListView.ItemTemplate>
    </ListView>
</Window>

Обратите внимание на конструкцию Binding в разметке формы. Именно таким образом можно привязывать поля объекта, находящегося в DataContext-е, к атрибутам контролов. Мы не написали ни строчки кода, но тем не менее при запуске получим следующее:

Отображение данных на форме

Редактирование

Сделаем так, что для выделенной в списке книги будет открываться редактор. Изменим XAML-разметку формы:

MainView.xaml

<Window x:Class="SampleMVVM.Views.MainView"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:c="clr-namespace:SampleMVVM.Commands"
    Title="Main Window" Height="400" Width="350">

    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition />
            <ColumnDefinition />
        </Grid.ColumnDefinitions>
       
        <ListView ItemsSource="{Binding BooksList}" IsSynchronizedWithCurrentItem="True">
            <ListView.ItemTemplate>
                <DataTemplate>
                    <Border BorderBrush="Bisque" BorderThickness="1" Margin="10">
                        <StackPanel Margin="10">
                            <TextBlock Text="{Binding Title}" FontWeight="Bold"/>
                            <TextBlock Text="{Binding Author}" />
                            <StackPanel Orientation="Horizontal">
                                <TextBlock Text="Осталось:" />
                                <TextBlock Text="{Binding Count}" FontWeight="Bold" Margin="10,0"/>
                                <TextBlock Text="шт" />
                            </StackPanel>
                        </StackPanel>
                    </Border>
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>
       
<ContentControl Grid.Column="1" Content="{Binding BooksList}"> <ContentControl.ContentTemplate> <DataTemplate> <Border BorderBrush="Bisque" BorderThickness="1" Margin="10"> <StackPanel Margin="10"> <TextBlock Text="Название:"/> <TextBox Text="{Binding Title, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10"/> <TextBlock Text="Автор:"/> <TextBox Text="{Binding Author, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10"/> </StackPanel> </Border> </DataTemplate> </ContentControl.ContentTemplate> </ContentControl>
</Grid> </Window>

Стоит обратить внимание на конструкцию UpdateSourceTrigger=PropertyChanged в строке биндинга. Это значит, что любое изменение, производимое в данном поле, будет немедленно отражаться на источнике. Это легко увидеть:

Редактирование данных

Если этого не написать, источник будет обновляться только по окончании редактирования (т.е. когда контрол будет терять фокус). Это может привести к следующей ошибке интерфейса: когда жмешь "Сохранить", сохраняется все, кроме только что измененного поля.

Команды

Добавим в приложение функциональности. Пусть некие читатели берут книги и возвращают. Соответственно, сделаем две кнопки — «Выдать» и «Забрать—, меняющие количество имеющихся в наличии книг. Если книг не осталось (Count = 0), кнопка «Выдать» должна дизаблиться.

В MVVM не пишутся обработчики событий. Функции, которые нужно выполнять контролам, пишутся во ViewModel и биндятся к контролам точно так же, как поля. Только используется механизм команд.

Команда должна представлять из себя экземпляр класса, реализующего интерфейс ICommand. К счастью, MVVM Toolkit снова нам помог и сгенерил целых два таких класса — DelegateCommand для реализации команды без параметров и DelegateCommand<T> — для реализации команды с параметром типа T.

Мы параметры передавать не будем. Код во ViewModel будет таков:

BookViewModel.cs

#region Забрать

private DelegateCommand getItemCommand;

public ICommand GetItemCommand
{
    get
    {
        if (getItemCommand == null)
        {
            getItemCommand = new DelegateCommand(GetItem);
        }
        return getItemCommand;
    }
}

private void GetItem()
{
    Count++;
}

#endregion

#region Выдать

private DelegateCommand giveItemCommand;

public ICommand GiveItemCommand
{
    get
    {
        if (giveItemCommand == null)
        {
            giveItemCommand = new DelegateCommand(GiveItem, CanGiveItem);
        }
        return giveItemCommand;
    }
}

private void GiveItem()
{
    Count--;
}

private bool CanGiveItem()
{
    return Count > 0;
}

#endregion

Обратите внимание, что этот код добавляется в BookViewModel, а не в MainViewMode. Дело в том, что мы будем добавлять кнопки в ContentControl, DataContext-ом которого является именно BookViewModel.

С первой командой все ясно. Создали команду, и в назначили ей в качестве действия метод GetItem, который и будет вызываться при ее активации. Со второй немного интереснее, но тоже просто. Помимо того, что она выполняет некоторое действие, она еще и может проверять с помощью метода CanGiveItem(), может она выполняться или нет.

В XAML-разметку нашей формы добавим следующее

MainView.xaml

<ContentControl Grid.Column="1" Content="{Binding BooksList}">
    <ContentControl.ContentTemplate>
        <DataTemplate>
            <Border BorderBrush="Bisque" BorderThickness="1" Margin="10">
                <StackPanel Margin="10">
                    <TextBlock Text="Название:"/>
                    <TextBox Text="{Binding Title, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10"/>

                    <TextBlock Text="Автор:"/>
                    <TextBox Text="{Binding Author, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10"/>

<StackPanel Orientation="Horizontal"> <Button Content="Выдать" Command="{Binding GiveItemCommand}" Margin="10,0" /> <Button Content="Забрать" Command="{Binding GetItemCommand}" Margin="10,0" /> </StackPanel>
</StackPanel> </Border> </DataTemplate> </ContentControl.ContentTemplate> </ContentControl>

Вот и все. Мы получили требуемую функциональность. Количество экземпляров книги увеличивается и уменьшается, а когда их становится 0, кнопка «Выдать» дизаблится (благодаря упомянутому CanGiveItem).

Команды

Итого

В приведенном приложении все данные и реализация логики вынесены в отдельное место. В C#-коде формы мы не добавили ни строчки. XAML понятен и прекрасен. Благодаря паттерну MVVM в коде легко разобраться и легко сопровождать.

Исходники примера

Ссылки

26 коммент.:

Анонимный комментирует...

Вы -- молодец!

Анонимный комментирует...

Обрабатывать SelectionChanged не совсем правильно. На помощь приходит CollectionViewSorce с методом GetDefaultView. Он возвращает представление для коллекции, котоое реализует ICollectionView. А уж в ICollectionView есть все необходимое для работы (нотификация изменения текущей позиции, фильтры и прочие полезности).
Cлой ViewModel позволяет полностью описать бизнес-логику приложения (Мастер-детайлы, общие справочники и т.п.). Во View пишется только то управление для GUI, которое невозможно реализовать из ViewModel(т.к. VIewModel не должна ничего знать о своем представлении): управление фокусировками, хоткеи и т.п.

darja комментирует...

Спасибо за кейворды. Когда возникнет соответствующая задача, буду знать куда смотреть.
Еще бы знать, как на MVVM модальные окна правильно делать.

Анонимный комментирует...

Один из вариантов - пишется общая обертка для показа View в модальном режиме. Обертка представляет собой команду, которая показывает модальную форму (которая может содержать кнопки Ок, Применить, Отмена в различных комбинациях), передавая для нее датаконтекст и шаблон представления. Также указывается команда, которая будет выполнятся в случае "положительного" результата обработки формы.

Команда биндится в XAMLе, в cs-файле ни единой строчки по прежнему нет.

Это в общих чертах, там много тонкостей :)

darja комментирует...

Я в общих чертах так и делаю.
Проблема в том, что, чтобы модальное окно закрылось, надо задать ему DialogResult. И для этого приходится обрабатывать OnClick кнопки и в обработчике писать DialogResult = true.

darja комментирует...

Хотя DialogResult тоже забиндить можно...

Анонимный комментирует...

Некоторые вещи разумнее писать в коде. Стремление абсолютно все вынести в разметку может принести вред (наступал на эти грабли).

Кстати, OnClick кнопки - "невьюмодельно", нужно завести команду и прибиндить ее к кнопке.

Форму ведь необязательно по клику кнопки будут закрывать.

darja комментирует...

Вот это меня, собственно, и смущало. OnClick некрасиво и не вписывается в MVVM; биндить WindowResult можно, но он во ViewModel тоже не в тему совершенно, а окно по Ok закрыть хочется. Что же делать?

Анонимный комментирует...

"Что же делать?"

Cinch

darja комментирует...

Если Вы имели в виду вот этот Cinch, то меня несколько смущает отсутствие релизов. Не хочу завязываться в рабочем приложении на нестабильный фреймворк.

Андрей комментирует...

Здравствуйте Дарья! Спасибо Вам за Вашу статью! У меня в процессе её чтения возник ряд вопросов, без которых не получается понять весь объём представленной Вами информации до конца. Был бы весьма признателен Вам за ответы на них:

В XAML-разметке вашего класса MainView, присутствуют элементы ListView и ContentControl.

Для ListView назначен источник данных: ItemsSource="{Binding BooksList}".
Для ContentControl этот же объект назначен в качестве содержимого: Content="{Binding BooksList}".

В подобных ситуациях для ListView я пишу такую разметку:

А для ContentControl пишу так:

Приведённая мною выше разметка мне понятна: в одном элементе (ListView) я указываю источником данных тип ObservableCollection, а во втором (ContentControl) - отслеживаю значение текущего выбора, произведённого в первом элементе.

Ваша разметка более лаконична и так же работает. Однако я никак не могу понять почему... В элементе ContentControl в качестве источника данных Вы указали Content="{Binding BooksList}". Судя по поведению ContentControl, он автоматически привязался к Items.CurrentItem? Искал в MSDN информацию, в которой бы упоминалось такое поведение - не нашел (к сожалению).

Правильно ли я понимаю - если свойству Content объекта ContentControl назначать класс, реализующий интерфейс ItemsControl (как это сделано в Вашем примере), то на самом деле объект ContentControl автоматически будет искать данные в ItemsControl.Items.CurrentItem? Если "да", то касается ли это только ItemsControl?

Андрей комментирует...

п.с. в предыдущем сообщении текстовый редактор сайта затёр мой код:

В подобных ситуациях для ListView я пишу такую разметку:
ListView Name="myList" ItemsSource="{Binding BooksList}"

А для ContentControl пишу так:
ContentControl Grid.Column="1" Content="{Binding ElementName=myList, Path=SelectedItem}"

darja комментирует...

2 Андрей:
Все дело в атрибуте IsSynchronizedWithCurrentItem. Почитайте про него.

Андрей комментирует...

я читал и помню, что он синхронизирует выбранный Item с источником - ItemsControl.Items.CurrentItem в случае Вашего примера. Но мой вопрос не в этом, а в том, что объект ContentControl, получив в качестве источника набор данных, определяет, что следует искать не абы какое свойство (для представления данных), а именно ItemsControl.Items.CurrentItem...

Ведь если в качестве источника в примере рассматривался бы string[], то в этом случае ContentControl не смог бы пройтись по иерархии свойств и обратиться к CurrentItem, поскольку в string[] этого свойства нет.

Alexander Alexeychuk комментирует...

Дарья, этот Ваш текст с кусками кода можно отнести к категории интересного и полезного.

Спасибо Вам!

Анонимный комментирует...

Спасибо, Дарья, за полезную шпаргалку!
Подскажите плиз, если кому не сложно, при такой модели как написать чтобы из MainViewModel закрывать приложение и вызывать стандартные команды окна (Close, Minimize, Move) ?

Dron247 комментирует...

Спасибо вам. Если б не вы еще бы пару дней втыкал длинную, не очень понятно написанную статью с мсдн, а так за 5 минут разобрался

Tekuto Koori комментирует...

Дарья, огромное Вам спасибо за UpdateSourceTrigger=PropertyChanged. Я долго искал, как добиться такого поведения TextBox'а

Анонимный комментирует...

Большое спасибо за шпаргалку!
Вопрос, как в рамках mvvm обработать следующий случай: в момент работы с вьюшкой произошли изменения с источником модели(допустим, у экземпляра Book по каким-то независимым причинам "изнутри" изменилось свойство Title). Как BookViewModel узнает об этом изменении?

Анонимный комментирует...

Андрей,
>>Но мой вопрос не в этом, а в том, что объект ContentControl, получив в качестве источника набор данных, определяет, что следует искать не абы какое свойство (для представления данных), а именно ItemsControl.Items.CurrentItem

если объект одноэлементного множества (в данном случае ContentControl) связан с представлением коллекции, он автоматически привязывается к CurrentItem представления

источник: http://msdn.microsoft.com/ru-ru/library/ms752347.aspx#data_conversion

Анонимный комментирует...

"Еще бы знать, как на MVVM модальные окна правильно делать." - посмотрите, может это как раз то что Вам нужно - http://www.silverlightshow.net/items/ModalDialogs-IEditableObject-and-MVVM-in-Silverlight-4.aspx

(правда, это SL, но нестрашно)

PS. Спасибо Вам за блог, очень приятно и полезно ))

netfantom комментирует...

Признаюсь, в голове некоторая каша.
Я вот пытаюсь понять как это применяется например во взаимодействии с EntityFramework. По идее сгенерированные объекты EntityFramework как-раз должны представлять прослойку Model, но как именно это реализуется пока не понимаю. Может есть какие-то примеры?

Роман комментирует...

Добрый день Дарья!
На моем блоге есть похожая тема(реализация Mvvm, только в silverlight), предлагаю нам обменяться ссылками. Я разместил вашу ссылку у себя на странице статьи: http://rio900.com/archives/156/mvvm-silverlight-%D0%BF%D1%80%D0%B0%D0%BA%D1%82%D0%B8%D0%BA%D0%B5/

ViDom комментирует...

А у меня этот пример не работает ((
Сделал пример до редактирования данных (только просмотр)..

Объект viewModel имеет данные, а ListView.Items.Count равняется нулю.. и в окне не показывает список книг..

Alex комментирует...

Отличная статья, но в корне не согласен с Вашим подходом к Model и ViewModel.

Model - действительно бизнес сущность системы и ее логика не зависит от ViewModel.
Т.е. если у книги, например, есть сводное служебное поле из фамилии автора и названия, то в Вашей версии в каждой из ViewModel (редакторов может быть несколько) Вам придется реализовать что-то вроде:
public string Author
{
set
{
Book.Author = value;
OnPropertyChanged("Author");
OnPropertyChanged("Summary"); // не забыть добавить во все
}

+ кроме того продублировать все эти свойства в каждой из ViewModel.
А это уже дублирование и явный повод для рефакторинга )
Итого, решение:
1. добавляем базовый класс для моделей с реализацией INotifyPropertyChanged.
2. Наследуем модель от п.1.
3. из ViewModel убираем все врапперы свойств модели с нотификациями.
4. из View биндимся Binding Book.Title. тем более что свойство Book у вас уже есть.

Аналогичная ситуация со внутренностями GiveItem() и CanGiveItem() расположенными во ViewModel. То что мы не можем выдать больше необходимого это опять же бизнес логика, а бизнес логике место в модели. В противном случае, если появится требование всегда оставлять в наличии книги Маркса в одном экземпляре, то есть вероятность что в одной из ViewModel можно этот ньюанс упустить.

в подкрепление моей версии ссылки на почитать и посмотреть:

http://archive.msdn.microsoft.com/mag200902MVVM

http://www.codeproject.com/KB/WPF/MVVMForDummies.aspx

Nes комментирует...

Согласен с вами Alex, на себе уже проверено - так действительно лучше.