суббота, 10 сентября 2011 г.

Model-View-ViewModel

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

 Итак, начнем. Одна из важнейших задач, которые стоят при разработке программ, это сделать так, чтобы их (или их компоненты) можно было использовать повторно, легко сопровождать, легко модифицировать. Для этого применяется модульный/компонентный подход, в рамках которого система разбивается на отдельные подсистемы со слабыми связями между ними. В интернетах про этот подход написано достаточно много, можно например прочитать про модульность и компонентно-ориентированный подход в википедии.
Так вот, одним из паттернов который предлагает вариант такого разбиения является Model-view-controller, который зарекомендовал себя очень хорошо. Когда в Microsoft разрабатывали WPF и Silverlight, они про этот паттерн конечно же знали, но т.к. эти платформы появились, уже после того, как MVC стал общемировой признанной практикой, видимо было принято решение, прямо на уровне фреймворков реализовать поддержку этого паттерна. Это привело к тому, что часть идеологии применения MVC была упразднена, и заменена другими, более удобными в рамках этих фреймворков парадигмами. Так, видимо, и появился паттерн MVVM.
Ладно, от лирики, перейдем к реализации. Для этого смотрим на картинку:
Стрелки указывают на то, что, в каком направлении из частей программы друг о друге знает. О знании поговорим чуть ниже. Сначала что за части.
View - это то, что видет пользователь. Пишется на XAML. Хоть картинку делал и я, и написал на ней, что "Полностью реализуется средствами XAML". Это не всегда так. В 90% случаев в файле cs UserControl-а, который реализует View действительно, кроме вызова конструктора ничего нет. Но иногда бывает необходимость реализовать сложное взаимодействие с пользователем, которое нельзя сделать XAML, вот тут и появится код на C#. Но! Еще раз! Только для реализации взаимодействия с пользователем. Никакой обработки данных, работы с БД или принятия решений на основе данных о доступности тех или иных компонентов на этом уровне не принимается.
Model - содержит классы отвечающие за описание предметной области и, как правило, за работу с БД. В Silverlight в качестве Model может выступать связка EntityModel и WCF RIA Services (что это за зверь такой, можно посмотреть на techdays). Здесь же могут быть реализованы и валидаторы, проверяющие, правильность данных передаваемых модель.
ViewModel - отвечает за связку модели и того, что пользователь видит на экране.
Теперь о том, как это все между собой взаимодействует. Model ничего не знает про ViewModel, ни про View. Т.е. один раз сделав уровень доступа к данным, вы его можете смело применять в разных проектах вообще не правя его код. Т.е. делаете серверную модель данных, сервис доступа к ним, клиентскую библиотеку, отвечающую за доступ к сервису и... Во всех остальных клиентских приложения просто делаете ссылку на эту клиентскую библиотеку.
View - сказать что он ничего не знает про ViewModel, это все таки соврать. Знает. Но так мало и так опосредованно, что отделить даже использовать один и тот же View с разными ViewModel очень легко. Например, мы создали универсальный компонент для показа списка неких сущностей предметной области, с панелью команд, механизмами фильтрации поиска и группировки и используем его для показа любых списков в рамках модели. Т.е. один и тот же визуальный компонент показывает список сотрудников и кнопки обеспечивающие доступ к функциям по работе с сотрудниками и, например, список почтовых реестров и, соответственно, кнопки для доступа к функциям по работе с почтовым реестром. Да, про незнание сказал, в чем же заключается знание? Как видно из рисунка во View используется DataBinding к полям и командам. Привязка к полям используется для того, чтобы показать пользователю значения свойств модели (ну или изменить через View значение этих свойств). Привязка к командам служит для того, чтобы View озадачил ViewModel выполнением некоторых действий по обработке.
Перед тем, как показать пример использования этого всего хозяйства, нужно пару слов сказать об этих самых командах. Под командой подразумевают поле во ViewModel тип которого либо непосредственно ICommand, либо класс реализующий данный интерфейс. Собственно в интерфейсе всего два метода и одно событие. Имена методов говорящие: Сделать! МогуСделать? а событие: "МогуСделатьПоменялось". Работу с командами в Silverlight поддерживают кнопки и гиперссылки. Вместо обработчика Click, мы определяем для них команды. В самом простом случае достаточно чтобы метод CanExecute возвращал всегда true.
Например:

    public class SimpleCommand : ICommand
    {

        private Action _action;

        public SimpleCommand(Action p_action)
        {
            _action = p_action;
        }

        public bool CanExecute(object parameter)
        {
            return true;
        }

        public event EventHandler CanExecuteChanged;

        public void Execute(object parameter)
        {
            if (_action != null)
            {
                _action();
            }
        }
    }

Как видим все просто. В конструктор передаем метод, который вызывается по Execute, при этом CanExecute всегда возвращает true, а раз так,. то и вызывать CanExecuteChanged смысла тоже нет.
Ладно, теперь к примеру, как все это будет работать. Структура папок проекта будет иметь вид:
Класс описывающий человека, будет иметь всего одно свойство:

    public class Person : DependencyObject
    {
        public string LastName
        {
            get { return (string)GetValue(LastNameProperty); }
            set { SetValue(LastNameProperty, value); }
        }

        // Using a DependencyProperty as the backing store for LastName.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty LastNameProperty =
            DependencyProperty.Register("LastName", typeof(string), typeof(Person), new PropertyMetadata(""));
    }

EditPerson - компонент с кнопками принять, отмена и полем для ввода фамилии. В связи с его простотой, здесь описывать не буду.
PersonList представляет собой компонент для показа списка людей и кнопки добавить. Обратите внимание на привязку команды, которая ничем не отличается от привязки свойства:

    <Grid x:Name="LayoutRoot" Background="White">
        <Grid.RowDefinitions>
            <RowDefinition Height="auto" />
            <RowDefinition />
        Grid.RowDefinitions>
        <StackPanel Orientation="Horizontal">
            <Button Content="Добавить" Margin="5" Command="{Binding AddPerson}" />
        StackPanel>
        <ListBox ItemsSource="{Binding Persons}" Grid.Row="1" DisplayMemberPath="LastName" />
    Grid>
 Как видим View хочет, чтобы в ViewModel который будет лежать у него в контексте была команда с именем AddPerson и список с именем Persons. У элементов списка должно быть поле LastName. Все. Больше ничего View не навязывает.
Давайте его не разочаруем и реализуем что требуется. В результате получим вот такой PersonListViewModel:
    public class PersonListViewModel : DependencyObject
    {
        public ObservableCollection<Person> Persons
        {
            get { return (ObservableCollection<Person>)GetValue(PersonsProperty); }
            set { SetValue(PersonsProperty, value); }
        }

        // Using a DependencyProperty as the backing store for Perons.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty PersonsProperty =
            DependencyProperty.Register("Persons", typeof(ObservableCollection<Person>), typeof(PersonListViewModel), new PropertyMetadata(null));

        public ICommand AddPerson
        {
            get { return (ICommand)GetValue(AddPersonProperty); }
            set { SetValue(AddPersonProperty, value); }
        }

        // Using a DependencyProperty as the backing store for AddPerson.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty AddPersonProperty =
            DependencyProperty.Register("AddPerson", typeof(ICommand), typeof(PersonListViewModel), new PropertyMetadata(null));
       
        public PersonListViewModel()
        {
            Persons = new ObservableCollection<Person>();
            Persons.Add(new Person() { LastName = "Иванов" });
            Persons.Add(new Person() { LastName = "Петров" });
            Persons.Add(new Person() { LastName = "Сидоров" });
            AddPerson = new SimpleCommand(CreateNewPerson);
        }

        private void CreateNewPerson()
        {
            ChildWindow add = new ChildWindow();
            add.Content = new EditPerson();
            add.DataContext = new Person();
            add.Closed += new EventHandler(add_Closed);
            add.Show();
           
        }

        void add_Closed(object sender, EventArgs e)
        {
            ChildWindow dialogWindow = (ChildWindow)sender;
            if (dialogWindow.DialogResult.Value)
            {
                Persons.Add((Person)dialogWindow.DataContext);
            }
        }
    }
Все. Нам осталось только показать пользователю разработанный View и поместить в его DataContext только что написанный ViewModel.
Для этого в MainPage добавлякм обработчик события загрузки компонента:

        private void UserControl_Loaded(object sender, RoutedEventArgs e)
        {
            PersonList list = new PersonList();
            list.DataContext = new PersonListViewModel();
            this.Content = list;
        }

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

4 комментария:

  1. Алексей, а как передавать параметры в ICommand. В простой работе там в методах передача параметров. А тут как? Или например выбранное значение в ComboBox передать при нажатии кнопки. И такой вопрос: при выходе из UserControl как будет запускаться команда на запись в базу? Всё равно будет метод UnLoaded? А как передавать в конструктор значения?

    ОтветитьУдалить
    Ответы
    1. У команды тоже есть возможность передавать параметры, но как правило это не нужно. Выбранный элемент в ComboBox у вас через Binding уже должен быть прокинут во ViewModel и команда будет работать с ним.
      Что значит выход из контрола? у вас ведь для выхода пользователь что-то делает? Кнопку какую-нибудь нажимает? или что? если да, то в команде связанной с этой кнопкой и вызывайте сохранение.

      Удалить
  2. Алексей, а как то можно обойтись без метода в коде
    private void UserControl_Loaded(object sender, RoutedEventArgs e)
    {
    PersonList list = new PersonList();
    list.DataContext = new PersonListViewModel();
    this.Content = list;
    }
    Может как то в XAML
    Loaded={Binding ...}
    Или такой код выше в C# обязателен получается в любой форме. Иначе никак не привязать ViewModel к DataContext?
    Спасибо

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