Паттерн MVVM

185

MVVM (Model-View-ViewModel) - это шаблон, который появился для обхода ограничений паттернов MVC и MVP, и объединяющий некоторые из их сильных сторон. Эта модель впервые появилась в составе фреймворка Small Talk в 80-х, и была позднее улучшена с учетом обновленной модели презентаций (MVP).

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

На следующем рисунке представлена диаграмма, которая показывает, как реализовать шаблон MVVM. Конечно, это общая реализация:

Структура приложения MVVM

Если вы планируете работать с WPF или Silverlight, вы должны воспользоваться механизмом привязок (binding), предоставляемым этими технологиями. Для этого, ваша модель-представление должна реализовать некоторые конкретные интерфейсы, необходимые модулю привязки из WPF и Silverlight. Одним из них является INotifyPropertyChanged, введенный в .NET Framework начиная с версии 2 и выше. Этот интерфейс реализует систему уведомлений которая активируется, когда значение свойства изменяется. Это требуется в модели-представления, чтобы сделать механизм привязки пользовательского интерфейса XAML динамическим.

Другой настройкой являются команды, предоставляемые интерфейсом ICommand, которые доступны для WPF и Silverlight. Команды могут привязываться к определенному XAML-элементу и определять поведение данного элемента при определенных действиях.

Третьим компонентом в показанной выше структуре является шаблон данных DataTemplate, который определяет как структурировать конкретную модель-представления или особое состояние модели-представления.

Пример приложения MVVM

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

Структура приложения MVVM

Как видите представление теперь пустое (содержит только пользовательский интерфейс на XAML), вся логика строится на отношениях между моделью и модель-представлением.

Структура View Models

Нам понадобится два вида моделей для этого приложения. Каждая модель-представления имеет свои собственные задачи в представлении. Использование нескольких вложенных моделей называется иерархическим модель-представлением (hierarchical view model).

Модель-представление ProjectsViewModel в нашем приложении будет содержать состояние и логику представления ProjectsView, это показано на следующем рисунке:

Взаимосвязь View Model и View

Свойства привязки и их назначение описаны ниже:

Второе модель-представление ProjectViewModel будет содержать состояние представления и логику для управления деталями в нашем представлении (фактическую и сметную стоимости):

Еще одна модель-представление

Структура этого модель-представления очень похожа на базовый класс Project потому что оно реализует интерфейс IProject. (Напомню, если вы еще этого не сделали, необходимо добавить в проект ссылку на сборку ProjectBilling.DataAccess.dll, которую мы создали в теме RAD & Monolitic и представляющую код доступа к данным - базовый класс Project, интерфейс IProject и несколько вспомогательных методов для извлечения данных.)

Model

Модель в нашем приложении представлена классом ProjectsModel реализующем интерфейс IProjectsModel. Интерфейс я добавил для возможного расширения приложения путем внедрения зависимостей и облегчения тестирования, он включает следующие члены:

Для начала нам нужно определить несколько вспомогательных классов и интерфейс IProjectsModel. Класс Notifier реализует интерфейс INotifyPropertyChanged и будет использоваться в модели и модели-представлении для уведомлений об изменениях. Фактически он является оболочкой для инкапсуляции INotifyPropertyChanged. Класс ProjectsEventArgs просто добавляет аргументы (в виде свойств) в обработчик события IProjectsModel.ProjectUpdated (в нашем примере будем передавать только ссылку на прототип IProject). Добавьте следующий код в файл модели, например BillingProject.Model.cs:

using System;
using System.ComponentModel;
using System.Collections.ObjectModel;
using System.Linq;
using ProjectBilling.DataAccess;

namespace ProjectBilling.Application
{
    public class Notifier : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged = delegate { };

        protected void NotifyPropertyChanged(
            string propertyName)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }

    public interface IProjectsModel
    {
        ObservableCollection<Project> Projects { get; set; }
        event EventHandler<ProjectEventArgs> ProjectUpdated;
        void UpdateProject(IProject updatedProject);
    }
    
    public class ProjectEventArgs : EventArgs
    {
        public IProject Project { get; set; }
        public ProjectEventArgs(IProject project)
        {
            Project = project;
        }
    }
    
    public class ProjectsModel : IProjectsModel
    {
    
    }
}

Структура класса ProjectsModel выглядит следующим образом:

public class ProjectsModel : IProjectsModel
{
        public ObservableCollection<Project> Projects { get; set; }
        public event EventHandler<ProjectEventArgs> ProjectUpdated = delegate { };

        public ProjectsModel(IDataService dataService)
        {
            Projects = new ObservableCollection<Project>();
            foreach (Project project in dataService.GetProjects())
            {
                Projects.Add(project);
            }
        }

        public void UpdateProject(IProject updatedProject)
        {
            GetProject(updatedProject.ID).Update(updatedProject);
            ProjectUpdated(this,
                new ProjectEventArgs(updatedProject));
        }

        private Project GetProject(int projectId)
        {
            return Projects.FirstOrDefault(
                project => project.ID == projectId);
        }
}

В конструкторе класса ProjectsModel передается ссылка на прототип IDataService, который объявлен в коде доступа к данным (ProjectBilling.DataAccess.dll). С помощью вспомогательного метода IDataService.GetProjects() инициализируется коллекция Projects. UpdateProjects() - метод, который сначала обновляет коллекцию IProjectsModel.Projects, а затем вызывает событие ProjectUpdated, чтобы уведомить все модели-представления о произошедших изменениях.

ProjectViewModel

Теперь добавим класс ProjectViewModel, реализующий одно из двух модель-представление:

using ProjectBilling.DataAccess;

namespace ProjectBilling.Application
{
    public interface IProjectViewModel : IProject
    {
        Status EstimateStatus { get; set; }
    }

    public class ProjectViewModel : Notifier, IProjectViewModel
    {
        private int _id;
        private string _name;
        private double _estimate;
        private double _actual;
        private Status _estimateStatus = Status.None;
        
        public int ID
        {
            get { return _id; }
            set
            {
                _id = value;
                NotifyPropertyChanged("Id");
            }
        }

        public string Name
        {
            get { return _name; }
            set
            {
                _name = value;
                NotifyPropertyChanged("Name");
            }
        }
        
        public double Estimate
        {
            get { return _estimate; }
            set
            {
                _estimate = value;
                NotifyPropertyChanged("Estimate");
            }
        }
        
        public double Actual
        {
            get { return _actual; }
            set
            {
                _actual = value;
                UpdateEstimateStatus();
                NotifyPropertyChanged("Actual");
            }
        }
        
        public Status EstimateStatus
        {
            get { return _estimateStatus; }
            set
            {
                _estimateStatus = value;
                NotifyPropertyChanged("EstimateStatus");
            }
        }
    }
}

Перечисление Status содержит три значения, которые соответствуют простой логике сравнения фактической и сметной стоимостей в нашем приложении (это перечисление будет объявлено в ProjectsViewModel, поэтому не обращайте внимание на ошибки, которые покажет Visual Studio при отсутствии объявления Status).

Интерфейс IProjectViewModel обязует добавить ссылку на это перечисление. Сам класс ProjectViewModel является довольно простым, он объявляет несколько закрытых переменных (инкапсулированных в общедоступных свойствах), которые соответствуют сигнатуре базового класса Project. ProjectViewModel унаследован от Notifier, поэтому в общедоступных свойствах включена поддержка уведомлений о изменении.

Добавим также в этот класс два конструктора и несколько вспомогательных методов:

public ProjectViewModel()
{}

public ProjectViewModel(IProject project)
{
            if (project == null)
                return;
            ID = project.ID;
            Update(project);
}

public void Update(IProject project)
{
            ID = project.ID;
            Name = project.Name;
            Estimate = project.Estimate;
            Actual = project.Actual;
}

private void UpdateEstimateStatus()
{
            if (Actual == 0)
                EstimateStatus = Status.None;
            else if (Actual <= Estimate)
                EstimateStatus = Status.Good;
            else
                EstimateStatus = Status.Bad;
}

Здесь мы добавили конструктор, в который передается прототип интерфейса IProject и в котором происходит обновление за счет вызова метода Update(), который легко обновляет ProjectViewModel и детали проекта в представлении. Вспомогательный метод UpdateEstimateStatus() реализует дополнительную логику сравнения фактической стоимости проекта со сметной.

ProjectsViewModel

Первым делом объявим перечисление Status, которое содержит три возможных значения: None - фактическая стоимость равна нулю, Good - фактическая стоимость меньше или равна сметной стоимости, Bad - фактическая стоимость соответственно превышает сметную:

public enum Status { None, Good, Bad }

Теперь добавим интерфейс IProjectsViewModel, реализующий INotifyPropertyChanged, содержащий ссылку на выбранный объект модели-представления IProjectViewModel и метод UpdateProject, обновляющий детали представления:

public interface IProjectsViewModel : INotifyPropertyChanged
{
        IProjectViewModel SelectedProject { get; set; }
        void UpdateProject();
}

Теперь добавим сам класс модель-представления ProjectsViewModel:

using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Windows.Input;
using ProjectBilling.DataAccess;

namespace ProjectBilling.Application
{
    public class ProjectsViewModel : Notifier, IProjectsViewModel
    {
        public const string SELECTED_PROJECT_PROPERRTY_NAME
            = "SelectedProject";

        private readonly IProjectsModel _model;
        private IProjectViewModel _selectedProject;
        private Status _detailsEstimateStatus
            = Status.None;
        private bool _detailsEnabled;
        private readonly ICommand _updateCommand;


        public ObservableCollection<Project>
            Projects { get { return _model.Projects; } }

        public int? SelectedValue
        {
            set
            {
                if (value == null)
                    return;
                Project project = GetProject((int)value);
                if (SelectedProject == null)
                {
                    SelectedProject
                        = new ProjectViewModel(project);
                }
                else
                {
                    SelectedProject.Update(project);
                }
                DetailsEstimateStatus =
                    SelectedProject.EstimateStatus;
            }
        }

        public IProjectViewModel SelectedProject
        {
            get { return _selectedProject; }
            set
            {
                if (value == null)
                {
                    _selectedProject = value;
                    DetailsEnabled = false;
                }
                else
                {
                    if (_selectedProject == null)
                    {
                        _selectedProject =
                            new ProjectViewModel(value);
                    }
                    _selectedProject.Update(value);
                    DetailsEstimateStatus =
                        _selectedProject.EstimateStatus;
                    DetailsEnabled = true;
                    NotifyPropertyChanged(
                        SELECTED_PROJECT_PROPERRTY_NAME);
                }
            }
        }

        public Status DetailsEstimateStatus
        {
            get { return _detailsEstimateStatus; }
            set
            {
                _detailsEstimateStatus = value;
                NotifyPropertyChanged("DetailsEstimateStatus");
            }
        }

        public bool DetailsEnabled
        {
            get { return _detailsEnabled; }
            set
            {
                _detailsEnabled = value;
                NotifyPropertyChanged("DetailsEnabled");
            }
        }

        public ICommand UpdateCommand
        {
            get { return _updateCommand; }
        }

        public ProjectsViewModel(IProjectsModel projectModel)
        {
            _model = projectModel;
            _model.ProjectUpdated +=
                model_ProjectUpdated;
            _updateCommand = new UpdateCommand(this);
        }

        public void UpdateProject()
        {
            DetailsEstimateStatus =
                SelectedProject.EstimateStatus;
            _model.UpdateProject(SelectedProject);
        }

        private void model_ProjectUpdated(object sender,
                                          ProjectEventArgs e)
        {
            GetProject(e.Project.ID).Update(e.Project);
            if (SelectedProject != null
                && e.Project.ID == SelectedProject.ID)
            {
                SelectedProject.Update(e.Project);
                DetailsEstimateStatus =
                    SelectedProject.EstimateStatus;
            }
        }

        private Project GetProject(int projectId)
        {
            return (from p in Projects
                    where p.ID == projectId
                    select p).FirstOrDefault();
        }
    }
}

Итак, давайте разберем по-порядку этот код. ProjectsViewModel унаследован от Notifier, это добавляет поддержку интерфейса INotifyPropertyChanged (без его явной реализации), включая уведомления о изменениях.

Переменная _model содержит ссылку на модель ProjectsModel, при этом будет создана только одна модель, общая для всех модель-представлений. Коллекция Projects просто содержит ссылку на IProjectsModel.Projects. Свойство SelectedValue привязывается в представлении к номеру выбранного элемента в ComboBox, т.е. ComboBox.SelectedIndex. При этом в самом свойстве обновляется другое свойство SelectedProject и обновляется статус через свойство DetailsEstimateStatus.

Свойство SelectedProject является экземпляром IProjectViewModel и обеспечивает состояние представления в зависимости от различных условий. Свойство DetailsEstimateStatus возвращает текущий статус проекта, используя перечисление Status. DetailsEnabled - логическое свойство, определяющее будут ли включены детали проекта в представлении (два текстовых поля и кнопка Update). DetailsEstimateStatus и DetailsEnabled используются при объявлении SelectedProject, при этом, если в SelectedProject передается null детали блокируются.

UpdateCommand - свойство, реализующее интерфейс ICommand, связывающее событие клика по кнопке Update в представлении с обновлением модели.

В конструкторе класса ProjectsViewModel инициализируется модель через переменную _model, присоединяется обработчик события IProjectsModel.ProjectUpdated и создается экземпляр класса UpdateCommand, который будет показан чуть позже.

Метод UpdateProject() используется в классе UpdateCommand для обновления модели. model_ProjectUpdated() - обработчик событий, который будет вызываться в ответ на событие IProjectsModel.ProjectUpdated. Этот обработчик сначала обновляет коллекцию Projects, а затем будет изменять свойство SelectedProject, если его ID такой же, как и у обновленного проекта.

Давайте добавим класс UpdateCommand:

internal class UpdateCommand : ICommand
{
        private const int ARE_EQUAL = 0;
        private const int NONE_SELECTED = -1;
        private IProjectsViewModel _vm;

        public UpdateCommand(IProjectsViewModel viewModel)
        {
            _vm = viewModel;
            _vm.PropertyChanged += vm_PropertyChanged;
        }

        private void vm_PropertyChanged(object sender,
            PropertyChangedEventArgs e)
        {
            if (string.Compare(e.PropertyName,
                               ProjectsViewModel.
                               SELECTED_PROJECT_PROPERRTY_NAME)
                == ARE_EQUAL)
            {
                CanExecuteChanged(this, new EventArgs());
            }
        }

        public bool CanExecute(object parameter)
        {
            if (_vm.SelectedProject == null)
                return false;
            return ((ProjectViewModel)_vm.SelectedProject).ID
                   > NONE_SELECTED;
        }

        public event EventHandler CanExecuteChanged
            = delegate { };

        public void Execute(object parameter)
        {
            _vm.UpdateProject();
        }
}

В конструкторе класса UpdateCommand инициализируется переменная _vm содержащая ссылку на IProjectsViewModel и добавляется обработчик события vm_PropertyChanged. Здесь проверяется источник, вызвавший команду, IComand.CanExecuteChanged укажет источник команды и вызовет ICommand.CanExecute().

CanExecute() - метод, реализацию которого требует ICommand, вызывается по команде от источника, чтобы определить, может ли он в данный момент выполнить нужную команду. Представление обычно отключает себя, когда ICommand.CanExecute() возвращает false, а в нашем случае это используется, чтобы контролировать, когда UpdateButton будет включена или отключена.

Событие CanExecuteChanged и метод Execute() также являются частью реализации ICommand. В Execute() мы просто инициализируем вызов ProjectsViewModel.UpdateProject.

Графический интерфейс

Теперь мы создадим графический интерфейс приложения WPF, использующего эту модель и модель-представления. Мы будем использовать главное окно приложения (в моем случае MainWindow.xaml) как фабрику, для запуска нескольких дополнительных окон, которые и реализуют представление. Добавьте в проект окно ProjectsView.xaml:

<Window x:Class="ProjectBilling.UI.WPF.ProjectsView"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Projects" MinHeight="180" Height="220"
        MinWidth="250" Width="300" Padding="5"
        FocusManager.FocusedElement="{Binding ElementName=ProjectsComboBox}">
    <Window.Resources>
        <Style x:Key="EstimateStyle" TargetType="{x:Type TextBox}">
            <Style.Triggers>
                <DataTrigger Binding="{Binding DetailsEstimateStatus}" Value="None">
                    <Setter Property="Foreground" Value="Black" />
                </DataTrigger>
                <DataTrigger Binding="{Binding DetailsEstimateStatus}" Value="Good">
                    <Setter Property="Foreground" Value="Green" />
                </DataTrigger>
                <DataTrigger Binding="{Binding DetailsEstimateStatus}" Value="Bad">
                    <Setter Property="Foreground" Value="Red" />
                </DataTrigger>
            </Style.Triggers>
        </Style>
    </Window.Resources>
    <Grid Margin="5">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition />
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition />
            <ColumnDefinition />
        </Grid.ColumnDefinitions>

        <Label Content="Проекты:" />
        <ComboBox Margin="5" Grid.Column="1" SelectedValue="{Binding Path=SelectedValue, Mode=OneWayToSource}"
                  ItemsSource="{Binding Path=Projects}" DisplayMemberPath="Name" SelectedValuePath="ID" />
        <GroupBox Grid.Row="1" Grid.ColumnSpan="2" Grid.RowSpan="3" Header="Детали">
            <Grid>
                <Grid.RowDefinitions>
                    <RowDefinition />
                    <RowDefinition />
                    <RowDefinition />
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition />
                    <ColumnDefinition />
                </Grid.ColumnDefinitions>

                <Label Content="Сметная стоимость:" />
                <TextBox Margin="5" Grid.Column="1" 
                         IsEnabled="{Binding Path=DetailsEnabled}" 
                         Text="{Binding Path=SelectedProject.Estimate}" 
                         Style="{StaticResource EstimateStyle}" />
                <Label Content="Фактическая стоимость:" Grid.Row="1" />
                <TextBox Margin="5" Grid.Row="1"  Grid.Column="1" 
                         IsEnabled="{Binding Path=DetailsEnabled}" 
                         Text="{Binding Path=SelectedProject.Actual}" />
                <Button Content="Update" Margin="5" Grid.Row="2" Grid.ColumnSpan="2"
                        Command="{Binding Path=UpdateCommand}" />
            </Grid>
        </GroupBox>

    </Grid>
</Window>
using System.Windows;

namespace ProjectBilling.UI.WPF
{
    public partial class ProjectsView : Window
    {
        public ProjectsView()
        {
            InitializeComponent();
        }
    }
}

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

Давайте рассмотрим структуру этого окна подробнее, разбив ее на ключевые элементы:

Обратите внимание, что в окне также добавлена некоторая логика для представления, реализованного в наборе триггеров стиля EstimateStyle. Условия триггеров привязываются к возможным значениям перечисления Status и задают различный цвет шрифта в текстовых полях, в зависимости от значений фактической и сметной стоимостей.

Теперь добавим небольшую логику в окне MainWindow.xaml для фабричного создания окон ProjectsView:

<StackPanel>
        <Button Content="обновить проекты"  Margin="5"
                Click="ShowProjectsButton_Click" />
</StackPanel>
public partial class MainWindow : Window
{
        private IProjectsModel _projectModel;

        public MainWindow()
        {
            InitializeComponent();
            _projectModel = new ProjectsModel(
                new DataServiceStub());
        }

        private void ShowProjectsButton_Click(object sender,
            RoutedEventArgs e)
        {
            ProjectsView view = new ProjectsView();
            view.DataContext
                = new ProjectsViewModel(_projectModel);
            view.Owner = this;
            view.Show();
        }
}

Этот код непосредственно не относится к реализации MVVM, а просто служит для вспомогательных целей.

Запустив наше приложение и открыв несколько окон ProjectsView, выберите в них одинаковый проект, например "Jones", установите для него фактическую стоимость и нажмите кнопку Update. Вы увидите, что обновятся данные и в других окнах:

Запуск приложения MVVM

Данное поведение достигается тем, что обновление одного представления вызывает обновление модели, которая, в свою очередь, обновляет все другие представления.

Преимущества MVVM

Итак, рассмотрев довольно большой пример приложения MVVM, возникает резонный вопрос, а зачем этот паттерн вообще использовать, если того же результата можно было добиться "малой кровью"? Я приведу несколько доводов в пользу MVVM:

Тестируемость MVVM-приложений

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

Меньшее количество кода

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

Улучшенное проектирование приложений

Разработчики и дизайнеры могут самостоятельно работать над разными частями приложения. Как вы видели на примере, представление генерируется в XAML-разметке и использует базовый синтаксис привязок и команд, для взаимодействия с модель-представлением. Вы можете создать модель-представление, которое предоставляет необходимые точки входа для связывания с представлением (например, общедоступные свойства), которые в конечном представлении можно будет легко привязать. Это позволяет дизайнерам работать над внешним видом приложения, а программистам над бизнес-логикой приложения.

Легкость понимания логики представления

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

Пройди тесты
Лучший чат для C# программистов