пятница, 25 ноября 2011 г.

Основы MVVM Pattern’а


Хочу описать процесс создания простенького WPF приложения по MVVM шаблону. Этот шаблон используется на проекте, на котором я работаю в текущее время. Такое желание у меня возникло после того, как я убедился в проблематичности его освоения на собственном опыте и на опыте наблюдения за окружающими программистами, которые впервые сталкиваются с этим шаблоном.

Проблема в том, что информации в интернете по нему очень много, но она вся как-то разбросана. И уходит очень много времени для того, чтобы сформировать в голове цельную картинку об этом шаблоне и о том как ему следовать при написании своих программ.

Этот шаблон очень распространен среди Silverlight приложений и приложений WPF.

MVVM удобно использовать вместо классического MVC и ему подобных в тех случаях, когда в платформе, на которой ведется разработка, присутствует «связывание данных».

В MVC/MVP изменения в пользовательском интерфейсе не влияют непосредственно на модель, а предварительно идут через Контроллер/Presenter. В таких технологиях как WPF и Silverlight есть концепция «связывание данных», позволяющая связывать данные с визуальными элементами в обе стороны. Следовательно при использовании этого приема применение паттерна MVC становится крайне неудобным из-за того, что привязка данных к представлению напрямую не укладывается в концепцию MVC/MVP.

Основная идея MVVM Pattern’а – отделить View, он же (UI) от логики, которая может происходить в рамках формы (View) и произвести «связывание данных» между этими двумя слоями. Это необходимо, так как позволяет изменять их отдельно друг от друга. Например, программист задает логику работы с данными, а дизайнер соответственно работает с пользовательским интерфейсом.

Схематично это выглядит так:



Паттерн MVVM делится на три части:
  • Модель (Model), так же, как в классическом паттерне MVC, Модель представляет собой фундаментальные данные, необходимые для работы приложения (классы, структуры).
  • Вид/Представление (View) так же, как в классическом паттерне MVC, Вид — это графический интерфейс, то есть окно, кнопки и.т.п.
  • Модель вида (ViewModel, что означает «Model of View») является с одной стороны абстракцией Вида, а с другой предоставляет обертку данных из Модели, которые подлежат связыванию. То есть она содержит Модель, которая преобразована к Виду, а так же содержит в себе команды, которыми может пользоваться Вид, чтобы влиять на Модель.

Здесь интересен момент – как наш UI может отобразить наши данные из наших моделей?

В двух словах «связывание данных»Binding говорит какому-либо свойству объекта на форме, из какого свойства DataContext’а взять данные.

DataContext – это свойство. Оно есть у каждого UI-объекта, те объекта имеющего визуальную составляющую. К этому свойству как раз и цепляют ViewModel. Таким образом вся эта цепочка начинает работать. При этом мы полностью отделяем всю логику приложения от его визуальной составляющей. Наша модель ничего не знает о том как она будет отображаться, а представление не знает что она будет отображать. Это свойство наследуется на все дочерние элементы. Поэтому его можно указать один раз на весь контрол и использовать на всей форме.
Теперь, чтобы было все понятно, давайте напишем простенькое WPF приложение по MVVM шаблону.

Сразу после создания пустого WPF проекта, дерево решения выглядит следующим образом:


Добавим в него 3 папки: View, Model и ViewModel.


В папке View у нас будут находиться только одни формы. Поэтому перенесем нашу главную форму в папку View.
Теперь нам надо изменить namespace у этой формы и имя класса, чтобы из него было понятно к какой группе принадлежит наш класс.


MainWindow.xaml
<Window x:Class="WpfApplication.View.MainWindowView"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="170" Width="400">
    <Grid>
 
    </Grid>
</Window>


MainWindow.xaml.cs
using System.Windows;
 
namespace WpfApplication.View
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindowView : Window
    {
        public MainWindowView()
        {
            InitializeComponent();
        }
    }
}


Дерево нашего решения примет следующий вид:



В файле App.xaml прописан путь к форме, которая запускается по умолчанию при старте программы. Так как мы перенесли нашу форму в другой namespace и в добавок к этому переименовали ее, то путь необходимо изменить:


App.xaml
<Application x:Class="WpfApplication.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             StartupUri="View/MainWindowView.xaml">
    <Application.Resources>
         
    </Application.Resources>
</Application>


Теперь, если мы запустим наше приложение, оно должно запуститься:



Теперь добавим в наш проект простенькую модель. Например, это будет класс человека:


PeopleModel.cs
namespace WpfApplication.Model
{
    class PeopleModel
    {
        #region Properties
 
        /// <summary>
        /// Get or set first name.
        /// </summary>
        public string FirstName { getset; }
 
 
        /// <summary>
        /// Get or set last name.
        /// </summary>
        public string LastName { getset; }
 
        #endregion
    }
}




Далее нам надо создать связующее звено между нашей формой и моделью. Это будет MainWindowViewModel, в котором будет свойство типа нашей модели:


MainWindowViewModel.cs
using System.Windows;
using System.Windows.Input;
using WpfApplication.Model;
 
namespace WpfApplication.ViewModel
{
    class MainViewModel
    {
        #region Constructor
 
        /// <summary>
        /// Constructor.
        /// </summary>
        public MainViewModel()
        {
            People = new PeopleModel
            {
                FirstName = "First name",
                LastName = "Last name"
            };
        }
 
        #endregion
 
 
        #region Properties
 
        /// <summary>
        /// Get or set people.
        /// </summary>
        public PeopleModel People { getset; }
 
        #endregion
    }
}




Теперь давайте набросаем на форме контролов, чтобы все это отображать:


MainWindowView.xaml
<Window x:Class="WpfApplication.View.MainWindowView"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="170" Width="400">
    <Grid>
        <TextBlock HorizontalAlignment="Left" VerticalAlignment="Top" Margin="12,12,0,0" 
                   Text="First name"/>
        <TextBlock HorizontalAlignment="Left" VerticalAlignment="Top" Margin="12,52,0,0" 
                   Text="Last name"/>
 
        <TextBox HorizontalAlignment="Left" VerticalAlignment="Top" Margin="90,9,0,0" Width="120" 
                 Text="{Binding Path=People.FirstName, Mode=TwoWay}"/>
        <TextBox HorizontalAlignment="Left" VerticalAlignment="Top" Margin="90,49,0,0" Width="120" 
                 Text="{Binding Path=People.LastName, Mode=TwoWay}"/>
 
        <TextBox HorizontalAlignment="Left" VerticalAlignment="Top" Margin="246,9,0,0" Width="120"
                 Text="{Binding Path=People.FirstName, Mode=TwoWay}" IsReadOnly="True" />
        <TextBox HorizontalAlignment="Left" VerticalAlignment="Top" Margin="246,49,0,0" Width="120"
                 Text="{Binding Path=People.LastName, Mode=TwoWay}" IsReadOnly="True" />
 
        <Button Content="Click me" HorizontalAlignment="Left" Margin="291,97,0,0" VerticalAlignment="Top" Width="75" />
    </Grid>
</Window>





Теперь, если мы запустим наше приложение, оно будет выглядеть так:



Теперь самое важное. Как наша форма будет брать данные из связующего звена – ViewModel?

Для этого мы должны вначале указать форме какой объект будет являться для нее ViewModel’лью. Ссылка на этот объект должна находиться в DataContext’е формы.

DataContext у формы должен задаваться при создании формы. Поэтому мы немного изменим App.xaml и App.xaml.cs следующим образом:


App.xaml
<Application x:Class="WpfApplication.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <Application.Resources>
         
    </Application.Resources>
</Application>


App.xaml.cs

using System.Windows;
using WpfApplication.View;
using WpfApplication.ViewModel;
 
namespace WpfApplication
{
    /// <summary>
    /// Interaction logic for App.xaml
    /// </summary>
    public partial class App : Application
    {
        public App()
        {
            var mw = new MainWindowView
            {
                DataContext = new MainViewModel()
            };
 
            mw.Show();
        }
    }
}

То есть мы тут убираем из разметки запускаемую по умолчанию форму и в коде, в конструкторе, сами ручками прописываем создание формы, задание у нее DataContext’а и непосредственно отображение.

Теперь мы должны привязать элементы на форме к конкретным свойствам модели. Для этого мы должны описать эти привязки в разметке. Например:


MainWindowView.xaml
<TextBox HorizontalAlignment="Left" VerticalAlignment="Top" Margin="90,9,0,0" Width="120" Text="{Binding Path=People.FirstName, Mode=TwoWay}"/>


Тут мы привязали у TextBox’а свойство Text к свойству People.FirstName у ViewModel. Так же мы указали настройку привязки. Мы сказали, что она будет двусторонняя. То есть, если мы изменяем на форме значение в TextBox’е, то это значение изменится и в свойстве модели. И наоборот.

Таким образом мы привязываем все элементы на нашей форме к модели через ViewModel:


MainWindowView.xaml
<Window x:Class="WpfApplication.View.MainWindowView"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="170" Width="400">
    <Grid>
        <TextBlock HorizontalAlignment="Left" VerticalAlignment="Top" Margin="12,12,0,0" 
                   Text="First name"/>
        <TextBlock HorizontalAlignment="Left" VerticalAlignment="Top" Margin="12,52,0,0" 
                   Text="Last name"/>
 
        <TextBox HorizontalAlignment="Left" VerticalAlignment="Top" Margin="90,9,0,0" Width="120" 
                 Text="{Binding Path=People.FirstName, Mode=TwoWay}"/>
        <TextBox HorizontalAlignment="Left" VerticalAlignment="Top" Margin="90,49,0,0" Width="120" 
                 Text="{Binding Path=People.LastName, Mode=TwoWay}"/>
 
        <TextBox HorizontalAlignment="Left" VerticalAlignment="Top" Margin="246,9,0,0" Width="120"
                 Text="{Binding Path=People.FirstName, Mode=TwoWay}" IsReadOnly="True" />
        <TextBox HorizontalAlignment="Left" VerticalAlignment="Top" Margin="246,49,0,0" Width="120"
                 Text="{Binding Path=People.LastName, Mode=TwoWay}" IsReadOnly="True" />
 
        <Button Content="Click me" HorizontalAlignment="Left" Margin="291,97,0,0" VerticalAlignment="Top" Width="75" />
    </Grid>
</Window>


Теперь, если мы запустим наше приложение, то мы увидим на форме наши данные, которые мы проинициализировали в конструкторе нашей MainViewModel.



Если вы измените текст в каком-либо из TextBox’ов первого столбика, то значение TextBox’а напротив тоже изменится:



Это происходит благодаря тому, что оба TextBox’а привязаны к одним и тем-же полям модели.

Теперь давайте сделаем привязку команды у кнопки к нашей MainViewModel.

Логика такая: Кнопка –> Команда –> Метод.

Для этого нам надо добавить свойство команды в MainViewModel. И написать собственно сам метод, который будет вызываться при срабатывании команды:


MainWindowViewModel.cs
using System.Windows;
using System.Windows.Input;
using WpfApplication.Model;
 
namespace WpfApplication.ViewModel
{
    class MainViewModel
    {
        #region Constructor
 
        /// <summary>
        /// Constructor.
        /// </summary>
        public MainViewModel()
        {
            People = new PeopleModel
            {
                FirstName = "First name",
                LastName = "Last name"
            };
        }
 
        #endregion
 
 
        #region Properties
 
        /// <summary>
        /// Get or set people.
        /// </summary>
        public PeopleModel People { getset; }
 
        #endregion
 
 
        #region Commands
 
        /// <summary>
        /// Get or set ClickCommand.
        /// </summary>
        public ICommand ClickCommand { getset; }
 
        #endregion
 
 
        #region Methods
 
        /// <summary>
        /// Click method.
        /// </summary>
        private void ClickMethod()
        {
            MessageBox.Show("This is click command.");
        }
 
        #endregion
    }
}


Для того, чтобы нам привязать метод к команде, необходимо создать класс, реализующий интерфейс ICommand:


Command.cs
using System;
using System.Windows.Input;
 
namespace WpfApplication.ViewModel
{
    public class Command : ICommand
    {
        #region Constructor
 
        public Command(Action<object> action)
        {
            ExecuteDelegate = action;
        }
 
        #endregion
 
 
        #region Properties
 
        public Predicate<object> CanExecuteDelegate { getset; }
        public Action<object> ExecuteDelegate { getset; }
 
        #endregion
 
 
        #region ICommand Members
 
        public bool CanExecute(object parameter)
        {
            if (CanExecuteDelegate != null)
            {
                return CanExecuteDelegate(parameter);
            }
 
            return true;
        }
 
        public event EventHandler CanExecuteChanged
        {
            add { CommandManager.RequerySuggested += value; }
            remove { CommandManager.RequerySuggested -= value; }
        }
 
        public void Execute(object parameter)
        {
            if (ExecuteDelegate != null)
            {
                ExecuteDelegate(parameter);
            }
        }
 
        #endregion
    }
}




Суть этого класса в следующем. Когда мы нажимаем кнопку на форме, происходит попытка вызова метода Execute интерфейса ICommand. Поэтому мы должны сделать так, чтобы при вызове этого метода, внутри него происходил вызов нашего метода через делегат, который мы передаем в конструкторе класса. При необходимости этот класс легко расширить и сделать вызов любого количества пользовательских методов при срабатывании команды.

Теперь в конструкторе MainViewModel привяжем метод к команде:


MainWindowViewModel.cs
#region Constructor
 
/// <summary>
/// Constructor.
/// </summary>
public MainViewModel()
{
    ClickCommand = new Command(arg => ClickMethod());
 
    People = new PeopleModel
                 {
                    FirstName = "First name",
                    LastName = "Last name"
                 };
}
 
#endregion 
Теперь нам осталось привязать нашу команду к кнопке.


MainWindowView.xaml
<Button Content="Click me" HorizontalAlignment="Left" Margin="291,97,0,0" VerticalAlignment="Top" Width="75" Command="{Binding ClickCommand}"/>

И можно проверить, что все это работает.


Если нам необходимо, изменять значения свойств в коде. И, при этом, форма должна автоматически обновляться. То модель, значения свойств которой должны автоматически обноляться на форме, надо немного модернезировать.

Для этого эта модель должна реализовывать интерфейс INotifyPropertyChanged

Этот интерфейс описывает "механизм уведомления", всех привязанных к свойству "клиентов", о том, что значение свойства изменилось.

Работает это следующим образом.

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

Это "уведомление" происходит через вызов метода OnPropertyChanged реализация которого делает попытку вызова события PropertyChanged на которое подписываются все "клиенты".

Это событие и метод как раз описаны в интерфейсе INotifyPropertyChanged.

Реализация всего этого тут довольно простая:


PeopleModel.cs
class PeopleModel : INotifyPropertyChanged
    {
        #region Implement INotyfyPropertyChanged members
 
        public event PropertyChangedEventHandler PropertyChanged;
 
        protected virtual void OnPropertyChanged(string propertyName)
        {
            if (PropertyChanged != null)
            {
                PropertyChanged(thisnew PropertyChangedEventArgs(propertyName));
            }
        }
 
        #endregion
 
        #region Fields
 
        private string _FirstName;
        private string _LastName;
 
        #endregion
 
        #region Properties
 
        /// <summary>
        /// Get or set first name.
        /// </summary>
        public string FirstName
        {
            get { return _FirstName; }
            set
            {
                if (_FirstName != value)
                {
                    _FirstName = value;
                    OnPropertyChanged("FirstName");
                }
            }
        }
 
 
        /// <summary>
        /// Get or set last name.
        /// </summary>
        public string LastName
        {
            get { return _LastName; }
            set
            {
                if (_LastName != value)
                {
                    _LastName = value;
                    OnPropertyChanged("LastName");
                }
            }
        }
 
        #endregion
    }


Собственно это самое минимальное, что нам нужно сделать, чтобы написать WPF приложение, следуя шаблону MVVM.

На первый взгляд это кажется громоздким и не удобным, но в приложениях, где идет отображение и работа с большими объемами данных, это существенно упрощает код и снижает количество возможных ошибок, тк мы не занимаемся вопросами вывода данных, их считыванием и тд, а концентрируем наше внимание непосредственно на бизнес-логике разрабатываемого приложения.

У нас появляется возможность не перезагружая форму, а всего лишь заменой DataContext’а менять выводимые на форму данные. И много чего еще.

Лично у меня понимание всех преимуществ этого шаблона пришли не сразу, а с получением определенного опыта разработки с его использованием. Поэтому не спешите делать выводы. Почитайте еще, попишите на нем и вам станет ясно, – нравится или нет.

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

46 комментариев:

  1. С кликом кнопки через делегаты не очень понятно.
    А остальное - здорово. Большое спасибо.

    ОтветитьУдалить
  2. отличная статья, спасибо)

    ОтветитьУдалить
  3. Хорошо, коротко и ясно! Спасибо)

    ОтветитьУдалить
    Ответы
    1. Именно так оно и долно быть, когда все в первый раз. А дальше уже становится понятнее куда копать в новом направлении =)
      Пожалуйста!

      Удалить
  4. Действительно, отличная статья, спасибо) Я вплотную столкнулся с этим когда мы делали проект с использованием решения Xomega Xomega включает в себя очень мощный фрэимворк, который берет на себя всю рутину и плагин для Visual Studio, позволяющий генерировать объекты на основе модели. Кстати, недавно у них вышел новый релиз для VS2012. Посмотрим, что интересного добавилось.

    ОтветитьУдалить
    Ответы
    1. Спасибо!
      Интересно..Надо будет почитать что там такое =)

      Удалить
  5. "Теперь мы должны привязать элементы на форме к конкретным свойствам модели. Для этого мы должны описать эти привязки в разметке. Например: ..."

    там пример неправильный. А так статья в целом ничего ;)

    ОтветитьУдалить
    Ответы
    1. Действительно! =) Спасибо за замечание! Ща поправлю. =)

      Удалить
  6. Спасибо!
    А на основе этого приложения/примера можно продемонстрировать реализацию модального диалога OK-Cancel в рамках данной модели. Например так: на форму рядом с кнопкой "Click me" расположить кнопку "Edit" по которой показать модальный диалог, который покажет свойства объекта, позволит выполнять их редактирование и по желанию сохранять эти изменения или нет. т.е. будет содержать две поля редактирования и две кнопки.

    ОтветитьУдалить
    Ответы
    1. Интересный пример бы получился по демонстрации привязок.

      Удалить
    2. Вы можете такой "сотворить", все примеры в инете "мутные" и "перегруженные" даже то, что Вы уже показали

      Удалить
    3. Многие базовые архитектурные шаблоны "перегруженные". Но эта перегруженность рассеивается на больших (огромных) и очень долгоиграющих проектах (лет по 10). Вот тогда-то они и раскрывают всю свою прелесть. Когда кода тонны, огромное количество старого кода. Но это не мешает быстро вникнуть и начать что-то делать.

      Удалить
    4. По существу вопроса сделать пример сможете? Буду весьма признателен!

      Удалить
    5. К сожалению, в данный момент свободного времени нет.

      Удалить
  7. спасибо за очень понятный пример

    ОтветитьУдалить
  8. Подскажите, почему метод для кнопки (ClickMethod) остался в ViewModel, если ViewModel призвана связывать метод и кнопку? То есть, по логике метод должен находится в Model, или нет?

    ОтветитьУдалить
    Ответы
    1. Просто придумал такой пример.

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

      Код во ViewModel может влиять на UI через свойства, которые связаны с UI и рабоать с моделью. Те, по сути вся логика UI должна быть во ViewModel.

      В моем случае я показал диалоговое окошко с сообщением. Это работа с UI. Поэтому я разместил метод во ViewModel. Если бы я, например, при нажатии на кнопку вызывал метод из модели, который очищает все свойства объекта People, то тогда, впринципе, к команде можно было напрямую привязать этот метод из модели.

      Как-то так.

      Удалить
    2. Стало понятнее, спасибо. А то без написания более-менее серьезного приложения, сложно разобраться в этих шаблонах.

      Удалить
    3. Да. Большинство шаблонов полностью раскрывается только на больших и долгоиграющих проектах.

      Удалить
  9. Этот комментарий был удален автором.

    ОтветитьУдалить
  10. Этот комментарий был удален автором.

    ОтветитьУдалить
  11. Допустим в методе ClickMethod вместо MessageBox.Show("This is click command."); напишем People.FirstName = "Семен"; , то новое имя не отображается. Почему?

    ОтветитьУдалить
    Ответы
    1. Да, действительно. Щас дополню статью, и опишу, почему так не работает и как сделать, чтобы заработало. Пока прикложу к статье второй вариант примера в исходниках, где я уже доавил ваш пример.

      Удалить
  12. не прописан using для Command и в последнем листинге FirstName заменить на LastName. А так спасибо за статью.

    ОтветитьУдалить
    Ответы
    1. Это хорошо, что Вы увидели ошибку =) Значит разобрались! И спасибо за замечания!

      Удалить
  13. Большое спасибо, наконец-то понял что к чему

    ОтветитьУдалить
  14. Этот комментарий был удален автором.

    ОтветитьУдалить
  15. Одна из лучших статей на эту тему.

    ОтветитьУдалить
  16. Ищешь, ищешь и находишь! Спасибо! Вопрос только один, как сделать так, чтобы текст во второй форме изменялся сразу после введения текста в первую?

    ОтветитьУдалить
    Ответы
    1. Прямо сразу-сразу, чтобы как тока нажал 1 кнопку, так сразу во второй форме произошли изменения?
      Думаю это не совсем правильно, тк потянет за собой большую нагрузку на компьютер в процессе обработки такого большого количества изменений, пока в 1 форме будет происходить набор текста.
      А если нужно, чтобы при ОКОНЧАНИИ редактирования текста в 1 форме, эти изменение отражались и во 2 форме, то так и должно происходить, если все делать правильно.

      Удалить
    2. Этот комментарий был удален автором.

      Удалить
    3. Нужно добавить в приязку UpdateSourceTrigger=PropertyChanged
      < TextBox HorizontalAlignment="Left" VerticalAlignment="Top" Margin="246,9,0,0" Width="120"
      Text="{Binding Path=People.FirstName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/ >

      Удалить
  17. Нет культуры программирования.
    Я бы советовал почитать http://megadarja.blogspot.com.by/2010/04/mvvm-wpf.html

    ОтветитьУдалить
  18. Отличная статейка, автору респект !

    ОтветитьУдалить
  19. В классе MainViewModel при создании интерфейса ClickCommand = new Command(arg => ClickMethod()), это что за аргумент такой? Разъясните, пожалуйста. А так статья хорошая. Первая, которую я встретил, которая разжевана для новичков патерного проектирования.

    ОтветитьУдалить
  20. При создании Command.cs выдаёт ошибку на CommandManager "Имя не существует в текущем контексте". С чем это может быть связано?

    ОтветитьУдалить