Шаблоны

Приложения WPF с шаблоном проектирования модель-представление-модель представления

Джош Смит (Josh Smith)

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

Загружаемый файл с кодом доступен в коллекции кода MSDN
Обзор кода в интерактивном режиме

Cодержание

Порядок против хаоса
Эволюция шаблона модель-представление-модель представления
Почему разработчики WPF так любят MVVM
Пример приложения
Пересылка логики команд
Иерархия классов модели представления
Класс ViewModelBase
Класс CommandViewModel
Класс MainWindowViewModel
Применение представления к модели представления
Модель данных и репозиторий
Новая форма ввода данных о клиенте
Представление всех клиентов
Заключение

Разработка интерфейса пользователя для профессионального приложения — это нелегко. Она может быть густой смесью данных, проектирования взаимодействий, визуального проектирования, возможностей подключения, многопоточности, безопасности, интернационализации, проверки допустимости, модульного тестирования и впридачу капелька «живой воды». Учитывая, что пользовательский интерфейс представляет нижележащую систему и должен удовлетворять непредсказуемым стилистическим требованиям пользователей, он может быть наиболее непостоянной частью многих приложений.

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

Не всегда виноваты шаблоны проектирования. Иногда мы используем сложные шаблоны проектирования, для которых нужно писать много кода, потому что используемая платформа пользовательского интерфейса не совместима с более простым шаблоном. Нужна платформа, позволяющая создавать пользовательские интерфейсы с помощью простых, проверенных временем и одобренных программистами шаблонов проектирования. К счатью, Windows Presentation Foundation (WPF) обеспечивает именно это.

По мере того, как WPF получает всё большее признание в мире программистов и пользователей, сообщество WPF разрабатывает собственную «экосистему» шаблонов и практических рекомендаций. В этой статье я рассмотрю некоторые из этих практических рекомендаций по проектированию и реализации клиентских приложений в WPF. Используя основные возможности WPF вместе с шаблоном проектирования «модель-представление-модель представления» (MVVM), я разберу пример программы, показывающий, что «правильно» сделать приложение WPF совсем нетрудно.

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

Порядок против Хаоса

Использовать шаблоны проектирования в простой программе типа «Здравствуй, мир!» не нужно и даже вредно. Любой квалифицированный разработчик сразу поймет пару строк кода. Но по мере того, как увеличивается число возможностей программы, увеличивается и число строк кода и движущихся частей. В какой-то момент сложности системы и присущие ей проблемы вынуждают разработчиков организовывать код так, чтобы его было проще понимать, обсуждать, расширять и устранять неполадки. Мы уменьшаем когнитивный хаос сложной системы, называя известными именами определенные элементы исходного кода. Имя, присваиваемое фрагменту кода, мы определяем по его функциональной роли в системе.

Разработчики часто намеренно структурируют код в соответствии с шаблоном проектирования, не позволяя шаблонам возникать естесственным путем. Оба подхода хороши, но в этой статье я расскажу о преимуществах непосредственного использования MVVM в качестве архитектуры приложения WPF. Названия некоторых классов включают известные термины из шаблона MVVM, например окончание «ViewModel», если класс — абстракция представления. Этот подход помогает избегать упомянутого выше когнитивного хаоса. Вместо этого вы можете долго и счастливо пребывать в состоянии контролируемого хаоса, этого естественного состояния дел в большинстве проектов по разработке профессионального программного обеспечения!

Эволюция шаблона «модель-представление-модель представления»

Популярные шаблоны проектирования упрощали людям жизнь с первых шагов создания пользовательских интерфейсов программ. Например, шаблон модель-представление-презентатор (MVP) была популярна на различных платформах программирования пользовательских интерфейсов. MVP — это разновидность шаблона модель-представление-контроллер, которому уже несколько десятков лет. Если вам никогда не приходилось использовать шаблон MVP, ниже приведено его краткое описание. То, что видно на экране, — это представление; данные, которые там отображены — это модель, а презентатор объединяет их вместе. Представление нуждается в презентаторе для заполнения данными модели, реакции на ввод пользователя, предоставления проверки ввода (в том числе за счет передачи этой функции модели) и других подобных задач. Если нужны дополнительные сведения о шаблоне «модель-представление-презентатор», я советую прочесть статью Жана Поля Буду (Jean-Paul Boodhoo) «Шаблоны проектирования» за август 2006 года.

В 2004 Мартин Фаулер (Martin Fowler) опубликовал статью о шаблоне под названием Модель презентации (PM). Шаблон «модель презентации» похож на MVP в том плане. что он отделяет представление от его поведения и состояния. Любопытная часть шаблона PM в том, что создается абстракция представления, которая называется моделью презентации. Представление, таким образом, становится просто результатом обработки модели презентации. Согласно Фаулеру, модель презентации постоянно обновляет свое представление, поэтому они остаются синхронизированными друг с другом. Эта логика синхронизации существует в виде кода в классах модели презентации.

В 2005 году Джон Госсман (John Gossman), сейчас один из архитекторов WPF и Silverlight в корпорации Microsoft, рассказал в своем блоге о шаблоне модель-представление-модель представления (MVVM). MVVM совпадает с моделью презентации Фаулера в том плане. что оба шаблона содержат абстракцию представления, содержащую состояние и поведение представления. Фаулер ввел модель презентации как способ создания независимого от платформы пользовательского интерфейса абстракции представления, а Госсман предложил MVVM как стандартизированный способ использовать основные функции WPF для упрощения создания пользовательских интерфейсов. В этом смысле я считаю MVVM частным вариантом более общего шаблона PM, приспособленным для платформ WPF и Silverlight.

В прекрасной статье Гленна Блока (Glenn Block) «Prism: шаблоны для создания составных приложений с помощью WPF» в выпуске за сентябрь 2008 года описано руководство Microsoft по составным приложениям для WPF. Термин ViewModel (модель представления) не используется. Для описания абстракции представления используется термин «модель презентации». Однако в этой статье я буду называть шаблон аббревиатурой MVVM, а абстракцию представления — моделью представления. В сообществах WPF и Silverlight такая терминология значительно более распространена.

В отличие от презентатора в MVP, модель представления не нуждается в ссылке на представление. Представление привязывается к свойствам модели представления, которая, в свою очередь, представляет данные в объектах модели и других состояниях, нужных для этого представления. Привязки между представлением и моделью представления создавать легко, потому что объект модели представления устанавливается как контекст DataContext представления. Если изменяются значения в модели представления, эти новые значения автоматически переходят в представление через привязку данных. Когда пользователь нажимает кнопку в представлении, для произведения нужного действия выполняется команда в модели представления. Все изменения данных модели всегда производит модель представления, а не представление.

Классы представления не знают о существовании классов модели, а модель представления и модель не знают о представлении. Модель на самом деле вообще не имеет представления о том, что существуют модель представления и представление. Это очень слабо связанная конструкция, и это дает ряд преимуществ, о которых я вскоре расскажу.

Почему разработчики WPF так любят MVVM

Если разработчик привык к WPF и MVVM, эти последние становится трудно различить. MVVM — своеобразный общепринятый язык разработчиков WPF, потому что он хорошо приспособлен для платформы WPF, а WPF создавался для упрощения сборки приложений с помощью шаблона MVVM (и других). В корпорации Майкрософт MVVM использовался для внутренних целей при разработке приложений WPF, например Microsoft Expression Blend, пока основная платформа WPF еще только создавалась. Многие части WPF, например модель контроля без просмотра и шаблоны данных, используют значительное разделение показа от состояния и поведения, применяемое в MVVM.

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

Еще две функции WPF, делающие этот шаблон таким полезным, – это шаблоны данных и система ресурсов. Шаблоны данных применяют представления к объектам модели представления, показанным в интерфейсе пользователя. Можно объявить шаблоны в коде XAML и позволить системе ресурсов за вас автоматически находить и применять эти шаблоны во время выполнения. Узнать о привязке и шаблонах данных подробнее можно в моей статье за июль 2008 года «Данные и WPF. Настройка отображения данных при помощи привязки данных и WPF».

Если бы в WPF не было поддержки команд, шаблон MVVM не был бы таким универсальным. В этой статье я покаже вам, как модель представления может предоставлять команды представлению, таким образом позволяя ему пользоваться своими функциями. Если вам не знакома система команд, советую прочесть обширную статью Брайана Нойса (Brian Noyes) «WPF для знатоков. Знакомство с маршрутизированными событиями и командами в WPF», в выпуске за сентябрь 2008.

Помимо функций WPF (и Silverlight 2), за счет которых MVVM становится естественным способом структурирования приложения, этот шаблон популярен еще и потому, что классы модели представления легко поддаются модульному тестированию. Если логика взаимодействия приложения находится в наборе классов модели представления, легко написать тестирующий ее код. В каком-то смысле представления и модульные тесты — разные типы потребителей модели представления. Набор тестов для модели представленияs приложения обеспечивает свободное и быстрое регрессионное тестирование, уменьшающее стоимость поддержки приложения в будущем.

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

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

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

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

Итак, я рассказал о истории и принципах работы MVVM. Я объяснил, почему этот шаблон так популярен у разработчиков WPF. Пришло время закатать рукава и посмотреть на шаблон в действии. В примере приложения, сопровождающем эту статью, MVVM используется несколькими способами. Это богатый источник примеров, позволяющий продемонстрировать принципы в осмысленном действии. Я создал пример приложения в Visual Studio 2008 SP1 на инфраструктуре Microsoft .NET Framework 3.5 SP1. Модульные тесты выполняются в системе модульного тестирования Visual Studio.

Приложение может содержать любое количество «рабочих областей», каждую из которых пользователь может открыть, перейдя по командной ссылке в области переходов слева. Все рабочие области находятся на вкладке TabControl основной зоны содержания. Пользователь может закрыть рабочую область, нажав кнопку «Close» на элементе вкладки этой рабочей области. У приложения есть две рабочих области: «All Customers» и «New Customer». После запуска приложения, если открыто несколько рабочих областей, пользовательский интерфейс выглядит примерно так, как на рис. 1.

fig01.gif

Рис. 1 Рабочие области

Одновременно можно открыть только один экземпляр рабочей области «All Customers», а рабочих областей «New Customer» можно открывать сколько угодно. Если пользователь хочет создать нового клиента, ему нужно заполнить форму ввода данных на рис. 2.

fig02.gif

Рис. 2 Форма ввода данных о новом клиенте

После заполнения формы ввода данных допустимыми значениями и нажатия кнопки «Save» в элементе вкладки появляется имя нового клиента, и этот клиент добавляется в список всех клиентов. Приложение не поддерживает удаление и изменение существующих клиентов, но эти функции, как и многие другие, легко добавить поверх существующей архитектуры приложения. Теперь вы знаете, что делает пример приложения; посмотрим, как оно было спроектировано и реализовано.

Пересылка логики команд

Каждое представление в приложении содержит пустой файл кода поддержки, содержащий только стандартный шаблон кода, вызывающий InitializeComponent в конструкторе класса. Можно просто удалить файлы кода поддержки представлений из проекта, а приложение все равно будет компилироваться и выполняться правильно. Несмотря на отстутствие методов обработки событий в представлениях, при нажатии пользователем кнопки приложение реагирует и выполняет требования пользователя. Это происходит потому, что созданы привязки свойства Command с элементами управления Hyperlink, Button и MenuItem, отображаемыми в пользовательском интерфейсе. Эти привязки обеспечивают предоставление объектов ICommand исполнением модели представления при воздействии пользователем на элементы управления. Можно смотреть на объект команды как на адаптер, позволяющий легко употребить функции модели представления из представления, объявленного в коде XAML.

В то время как модель представления предоставляет свойства экземпляра типа ICommand, в объекте команды для выполнения этой задачи обычно используется объект модели представления. Один из возможных шаблонов реализации — создать закрытый вложенный класс в классе модели представления, чтобы команда имела доступ к закрытым членам содержащей его модели представления и не засоряла пространство имен. Этот вложенный класс реализует интерфейс ICommand, а в его конструктор включена ссылка на содержащую его модель представления. Однако создание вложенного класса, реализующего ICommand для каждой команды, представленной моделью представления, может сделать класс модели представления слишком большим. Чем больше кода — тем больше может возникнуть ошибок.

В примере приложения класс RelayCommand разрешает эту проблему. RelayCommand позволяет вводить логику команды через делеаты, передаваемые в его конструктор. Этот подход позволяет сжато и четко реализовывать команды в классах модели представления. RelayCommand — это упрощенный вариант DelegateCommand, используемого в Библиотеке составных приложений Microsoft. Класс RelayCommand показан на рис. 3.

Рис. 3. Класс RelayCommand

public class RelayCommand : ICommand
{
    #region Fields

    readonly Action<object> _execute;
    readonly Predicate<object> _canExecute;        

    #endregion // Fields

    #region Constructors

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

    public RelayCommand(Action<object> execute, Predicate<object> 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(parameter);
    }

    public event EventHandler CanExecuteChanged
    {
        add { CommandManager.RequerySuggested += value; }
        remove { CommandManager.RequerySuggested -= value; }
    }

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

    #endregion // ICommand Members
}

Событие CanExecuteChanged, часть реализации интерфейса ICommand, имеет ряд интересных функций. Оно передает подписку на событие событию CommandManager.RequerySuggested. Это гарантирует, что командная инфраструктура WPF опрашивает все объекты RelayCommand, могут ли они совершать выполнение при любом обращении к встроенным командам. В следующем коде класса CustomerViewModel, о котором я подробнее расскажу позже, показано, как настроить RelayCommand при помощи лямбда-выражений:

RelayCommand _saveCommand;
public ICommand SaveCommand
{
    get
    {
        if (_saveCommand == null)
        {
            _saveCommand = new RelayCommand(param => this.Save(),
                param => this.CanSave );
        }
        return _saveCommand;
    }
}

Иерархия класса ViewModel

Большинству классов модели представления нужны одинаковые возможности. Им часто нужно реализовывать интерфейс InotifyPropertyChanged, обычно им нужно отображаемое название, удобное для чтения пользователем, и им нужна возможность закрываться (то есть удаляться из пользовательского интерфейса). Эта проблема естественным образом приспосабливается к созданию одного или двух базовых классов модели представления, чтобы новые классы модели представления могли наследовать все общие функции базового класса. Классы модели представления формируют иерархию наследования, показанную на рис. 4.

fig04.gif

Рис. 4 Иерархия наследования

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

Класс ViewModelBase

ViewModelBase — корневой класс иерархии, поэтому он реализаует общий интерфейс INotifyPropertyChanged и имеет свойство DisplayName. Интерфейс INotifyPropertyChanged содержит событие под названием PropertyChanged. Если у свойства объекта модели представления есть новое значение, она может создать событие PropertyChanged для уведомления системы привязки WPF о новом значении. После получения этого уведомления система привязки опрашивает свойство, и привязанное свойство какого-то элемента пользовательского интерфейса получит новое значение.

Для того, чтобы WPF знал, какое свойство объекта модели представления изменилось, класс PropertyChangedEventArgs предоставляет свойство PropertyName типа String. Следует внимательно следить за тем, чтобы передавать правильное название свойства в аргумент этого события; в противном случае WPF станет опрашивать на предмет нового значения неправильное свойство.

Один интересный аспект ViewModelBase состоит в том, что он предоставляет возможность проверить, что свойство с данным именем действительно существует в объекте модели представления. Это очень полезно при переработке кода, потому что при изменении имени свойства через функцию перерабткт кода Visual Studio 2008 не будут обновлены строки в исходном коде, которые содержат имя этого свойства (собсьтвенно, этого и не должно происходить). Возбуждение события PropertyChanged с неверным именем свойства в аргументе события может привести к тонким ошибкам, которые трудно найти, поэтому эта небольшая функция очень экономит время. Код ViewModelBase, добавляющий эту полезную поддержку, показан на рис. 5.

Рис. 5. Проверка свойства

// In ViewModelBase.cs
public event PropertyChangedEventHandler PropertyChanged;

protected virtual void OnPropertyChanged(string propertyName)
{
    this.VerifyPropertyName(propertyName);

    PropertyChangedEventHandler handler = this.PropertyChanged;
    if (handler != null)
    {
        var e = new PropertyChangedEventArgs(propertyName);
        handler(this, e);
    }
}

[Conditional("DEBUG")]
[DebuggerStepThrough]
public void VerifyPropertyName(string propertyName)
{
    // Verify that the property name matches a real,  
    // public, instance property on this object.
    if (TypeDescriptor.GetProperties(this)[propertyName] == null)
    {
        string msg = "Invalid property name: " + propertyName;

        if (this.ThrowOnInvalidPropertyName)
            throw new Exception(msg);
        else
            Debug.Fail(msg);
    }
}

Класс CommandViewModel

Простейший единый подкласс ViewModelBase — это CommandViewModel. Он предоставляет свойство Command типа ICommand. MainWindowViewModel предоставляет коллекцию этих объектов через свое свойство Commands. В области переходов на левой стороне главного окна отображается ссылка для каждого объекта CommandViewModel, предоставленного классом MainWindowViewModel, например «View all customers» и «Create new customer». Когда пользователь переходит по ссылке, выполняя одну из этих команд, на вкладке TabControl главного окна открывается рабочая область. Определение класса CommandViewModel показано здесь:

public class CommandViewModel : ViewModelBase
{
    public CommandViewModel(string displayName, ICommand command)
    {
        if (command == null)
            throw new ArgumentNullException("command");

        base.DisplayName = displayName;
        this.Command = command;
    }

    public ICommand Command { get; private set; }
}

В файле MainWindowResources.xaml содержится шаблон DataTemplate, ключ которого — «CommandsTemplate». MainWindow использует этот шаблон для сборки коллекции объектов CommandViewModel, упомянутой ранее. Шаблон просто вычисляет каждый объект CommandViewModel как ссылку в ItemsControl. Свойство Command каждой гиперссылки Hyperlink привязано к свойству Command CommandViewModel. Этот код XAML показан на рис. 6.

Рис. 6 Вычисление списка команд

<!-- In MainWindowResources.xaml -->
<!--
This template explains how to render the list of commands on 
the left side in the main window (the 'Control Panel' area).
-->
<DataTemplate x:Key="CommandsTemplate">
  <ItemsControl ItemsSource="{Binding Path=Commands}">
    <ItemsControl.ItemTemplate>
      <DataTemplate>
        <TextBlock Margin="2,6">
          <Hyperlink Command="{Binding Path=Command}">
            <TextBlock Text="{Binding Path=DisplayName}" />
          </Hyperlink>
        </TextBlock>
      </DataTemplate>
    </ItemsControl.ItemTemplate>
  </ItemsControl>
</DataTemplate>

Класс MainWindowViewModel

Как видно из приведенной выше схемы классов, класс WorkspaceViewModel является производным от ViewModelBase, и в нем добавлена возможность закрываться. Под словом «закрываться» я имею в виду, что что-то удаляет рабочую область из пользовательского интерфейса во время выполнения. От WorkspaceViewModel являются производными три класса: MainWindowViewModel, AllCustomersViewModel и CustomerViewModel. Запрос MainWindowViewModel на закрытие обрабатывается классом App, создающим MainWindow и его модель представления, как показано на рис. 7.

Рис. 7 Создание модели представления

// In App.xaml.cs
protected override void OnStartup(StartupEventArgs e)
{
    base.OnStartup(e);

    MainWindow window = new MainWindow();

    // Create the ViewModel to which 
    // the main window binds.
    string path = "Data/customers.xml";
    var viewModel = new MainWindowViewModel(path);

    // When the ViewModel asks to be closed, 
    // close the window.
    viewModel.RequestClose += delegate 
    { 
        window.Close(); 
    };

    // Allow all controls in the window to 
    // bind to the ViewModel by setting the 
    // DataContext, which propagates down 
    // the element tree.
    window.DataContext = viewModel;

    window.Show();
}

MainWindow содержит элемент меню, свойство Command которого привязано к свойству CloseCommand MainWindowViewModel. Когда пользователь щелкает этот элемент меню, класс App отвечает вызовом метода Close окна, вот так:

<!-- In MainWindow.xaml -->
<Menu>
  <MenuItem Header="_File">
    <MenuItem Header="_Exit" Command="{Binding Path=CloseCommand}" />
  </MenuItem>
  <MenuItem Header="_Edit" />
  <MenuItem Header="_Options" />
  <MenuItem Header="_Help" />
</Menu>

MainWindowViewModel содержит налюбдаемую коллекицю объектов WorkspaceViewModel, которая называется Workspace. Главное окно содержит вкладку TabControl, свойство ItemsSource которой привязано к этой коллекции. Каждый элемент вкладки имеет кнопку «Close», свойство Command которой привязано к CloseCommand соответствующего экземпляра WorkspaceViewModel. Сокращенная версия шаблона, настраивающего все элементы вкладки, показана в приведенном ниже коде. Код находится в MainWindowResources.xaml, а шаблон разъясняет, как собрать элемент вкладки с кнопкой «Close».

<DataTemplate x:Key="ClosableTabItemTemplate">
  <DockPanel Width="120">
    <Button
      Command="{Binding Path=CloseCommand}"
      Content="X"
      DockPanel.Dock="Right"
      Width="16" Height="16" 
      />
    <ContentPresenter Content="{Binding Path=DisplayName}" />
  </DockPanel>
</DataTemplate>

Когда пользователь нажимает кнопку «Close» в элементе вкладки, выполняется CloseCommand для этого WorkspaceViewModel, из-за этого создается его событие RequestClose. MainWindowViewModel наблюдает за событием RequestClose своих рабочих областей и по запросу удаляет рабочую область из коллекции Workspaces. Так как свойство ItemsSource вкладки TabControl объекта MainWindow привязано к наблюдаемойу коллекции объектов WorkspaceViewModel, удаление элемента набора приводит к удалению соответствующей рабочей области со вкладки TabControl. Эта логика из MainWindowViewModel показана на рис. 8.

Рис. 8 Удаление рабочей области из пользовательского интерфейса

// In MainWindowViewModel.cs

ObservableCollection<WorkspaceViewModel> _workspaces;

public ObservableCollection<WorkspaceViewModel> Workspaces
{
    get
    {
        if (_workspaces == null)
        {
            _workspaces = new ObservableCollection<WorkspaceViewModel>();
            _workspaces.CollectionChanged += this.OnWorkspacesChanged;
        }
        return _workspaces;
    }
}

void OnWorkspacesChanged(object sender, NotifyCollectionChangedEventArgs e)
{
    if (e.NewItems != null && e.NewItems.Count != 0)
        foreach (WorkspaceViewModel workspace in e.NewItems)
            workspace.RequestClose += this.OnWorkspaceRequestClose;

    if (e.OldItems != null && e.OldItems.Count != 0)
        foreach (WorkspaceViewModel workspace in e.OldItems)
            workspace.RequestClose -= this.OnWorkspaceRequestClose;
}

void OnWorkspaceRequestClose(object sender, EventArgs e)
{
    this.Workspaces.Remove(sender as WorkspaceViewModel);
}

В проекте UnitTests файл MainWindowViewModelTests.cs содержит метод тестирования, проверяющий правильную работу этой функции. Простота, с которой можно создавать модульные тесты для классов модели представления, — большое преимущество шаблона MVVM, потому что он позволяет очень легко тестировать функции приложения без нужды писать код, затраивающий пользовательский интерфейс. Этот метод тестирования показан на рис. 9.

Рис. 9. Метод тестирования

// In MainWindowViewModelTests.cs
[TestMethod]
public void TestCloseAllCustomersWorkspace()
{
    // Create the MainWindowViewModel, but not the MainWindow.
    MainWindowViewModel target = 
        new MainWindowViewModel(Constants.CUSTOMER_DATA_FILE);

    Assert.AreEqual(0, target.Workspaces.Count, "Workspaces isn't empty.");

    // Find the command that opens the "All Customers" workspace.
    CommandViewModel commandVM = 
        target.Commands.First(cvm => cvm.DisplayName == "View all customers");

    // Open the "All Customers" workspace.
    commandVM.Command.Execute(null);
    Assert.AreEqual(1, target.Workspaces.Count, "Did not create viewmodel.");

    // Ensure the correct type of workspace was created.
    var allCustomersVM = target.Workspaces[0] as AllCustomersViewModel;
    Assert.IsNotNull(allCustomersVM, "Wrong viewmodel type created.");

    // Tell the "All Customers" workspace to close.
    allCustomersVM.CloseCommand.Execute(null);
    Assert.AreEqual(0, target.Workspaces.Count, "Did not close viewmodel.");
}

Применение представления к модели представления

MainWindowViewModel косвенно добавляет и удаляет объекты WorkspaceViewModel из TabControl главного окна. Опираясь на привязку данных, свойство Content TabItem получает для отображения объект, произошедший от ViewModelBase. ViewModelBase — не элемент пользовательского интерфейса, поэтому изначально не поддерживает самовычисление. По умолчанию в WPF невизуальный объект визуализуется с помощью отображения результатов вызова его метода ToString в блоке TextBlock. Вам нужно явно не это, если, конечно, ваши пользователи не мечтают увидеть имена типов классов модели представления!

Сообщить WPF, как собирать объект модели представления, можно с легкостью при помощи типизированных шаблонов DataTemplate. К типизированному шаблону DataTemplate не присоединено значение x:Key, но его свойство DataType установлено на экземлпяр класса Type. Если WPF пытается вычислить один из объектов модели представления, он проверит, есть ли у системы ресурсы в зоне досягаемости типизированный шаблон DataTemplate, тип DataType которого совпадает с типом (или является базовым классом для типа) вашего объекта модели представления. Если он таковой находит, он использует этот шаблон для сборки объекта модели представления, на который ссылается свойство Content элемента вкладки.

Файл MainWindowResources.xaml содержит ResourceDictionary. Этот словарь добавляется к иерархии ресурсов главного окна, то есть ресурсы, которые он содержит, находятся в пределах ресурсов окна. Когда содержимое элемента вкладки устанавливается на объект модели представления, типизированный шаблон DataTemplate из этого словаря предоставляет представление (то есть пользовательский элемент управления) для его визуализации, как на рис. 10.

Рис. 10 Предоставление представления

<!-- 
This resource dictionary is used by the MainWindow. 
-->
<ResourceDictionary
  xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:vm="clr-namespace:DemoApp.ViewModel"
  xmlns:vw="clr-namespace:DemoApp.View"
  >

  <!-- 
  This template applies an AllCustomersView to an instance 
  of the AllCustomersViewModel class shown in the main window.
  -->
  <DataTemplate DataType="{x:Type vm:AllCustomersViewModel}">
    <vw:AllCustomersView />
  </DataTemplate>

  <!-- 
  This template applies a CustomerView to an instance  
  of the CustomerViewModel class shown in the main window.
  -->
  <DataTemplate DataType="{x:Type vm:CustomerViewModel}">
    <vw:CustomerView />
  </DataTemplate>

 <!-- Other resources omitted for clarity... -->

</ResourceDictionary>

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

Модель данных и репозиторий

Вы знаете, как оболочка приложения загружает, отображает и закрывает объекты модели представления. Теперь, разобравшись с основными понятиями, можно рассмотреть подробности реализации, более специфические для этого типа приложений. Прежде чем погружаться глубже в две рабочих области приложения, «All Customers» и «New Customer», рассмотрим сперва модель данных и классы доступа к данным. Проектирование этих классов не имеет практически никакого отношения к шаблону MVVM, потому что можно создать класс модели представления, адаптирующий почти любой объект данных во что-то подходящее для WPF.

Единственный класс модели в примере программы — это Customer. Этот класс содержит несколько свойств, представляющих информацию о клиенте компании, например имя, фамилию и адрес электронной почты. Он предоставляет сообщения проверки допустимости, реализуя стандартный интерфейс IDataErrorInfo, который куда старше WPF. В классе Customer нет ничего, что выдает его применение в архитектуре MVVM и даже в приложении WPF. Этот класс мог запросто прийти из устаревшей деловой библиотеки.

Данные должны откуда-то приходить и где-то храниться. В этом приложении экземпляр класса CustomerRepository загружает и хранит все объекты Customer. Так получилось, что он загружает данные клиентов из файла XML, но вообще тип внешнего источника данных несущественен. Данные могу поступать из базы данных, веб-службы, именованного канала, файла на диске или от почтовых голубей: это просто не имеет значения. Если у вас есть объект .NET с данными, независимо от того, откуда они, шаблон MVVM может вывести эти данные на экран.

Класс CustomerRepository представляет несколько методов, позволяющих получать все доступные объекты Customer, добавлять новый Customer в репозиторий и проверять, если ли уже Customer в репозитории. Так как приложение не позволяет пользователю удалять клиентов, репозиторий не дает вам возможности удалять клиента. При попадании нового Customer в CustomerRepository через метод AddCustomer запускается событие CustomerAdded.

Очевидно, что модель данных этого приложения очень мала по сравнению с тем, чего требуют настоязие бизнес-приложений, но это неважно. Важно понять, как классы модели представления используются Customer и CustomerRepository. Заметьте, что CustomerViewModel — это обертка для объекта Customer. Она представляет состояние Customer и другое состояние, используемое элементом управления CustomerView, через набор свойств. CustomerViewModel не копирует состояние Customer; он просто представляет его через делегирование, вот так:

public string FirstName
{
    get { return _customer.FirstName; }
    set
    {
        if (value == _customer.FirstName)
            return;
        _customer.FirstName = value;
        base.OnPropertyChanged("FirstName");
    }
}

Когда пользователь создает нового клиента и нажимает кнопку «Save» в элементе управления CustomerView, CustomerViewModel, связанный с этим представлением, добавит новый объект Customer к CustomerRepository. Из-за этого порождается событие CustomerAdded, дающее AllCustomersViewModel знать, что в набор AllCustomers следует добавить новый CustomerViewModel. С некой точки зрения, CustomerRepository действует как механизм синхронизации между разными моделями представлений, работающими с объектами Customer. Можно представить это себе как использование шаблона проектирования «посредник». В следующих разделах я подробнее расскажу о том, как это работает, а пока что можно посмотреть на схему на рис. 11, чтобы в целом представить, как соединяются все части.

fig11.gif

Рис. 11 Связи Customer

Форма ввода данных о новом клиенте

Когда пользователь переходит по ссылке «Create new customer», MainWindowViewModel добавляет новый объект CustomerViewModel к своему списку рабочих областей, а элемент CustomerView его отображает. После того, как пользователь вводит допустимые значения в поля ввода, кнопка «Save» входит в состояние «включено», чтобы пользователь мог сохранить сведения о новом клиенте. Ничего необычного, просто обычная форма ввода данных с проверкой ввода и кнопкой «Сохранить».

Класс Customer имеет встроенную поддержку проверки допустимости, доступную через реализацию интерфейса IDataErrorInfo. Эта проверка следит за тем, чтобы у клиента было имя, правильно оформленный адрес электронной почты и (если это человек) фамилия. Если свойство IsCompany Customer возвращает истину, свойство LastName не может иметь значения (смысл в том, что у компании не может быть фамилии). Эта логика проверки имеет смысл с точки зрения объекта Customer, но не соответствует нуждам пользовательского интерфейса. Пользовательский интерфейс требует, чтобы пользователь указал, является ли новый клиент человеком или компанией. Селектор типа клиента изначально имеет значение «(Not Specified)». Как пользовательскому интерфейсу сообщить пользователю, что тип клиента не указан, если свойство Customer IsCompany позволяет только два значения — истина и ложь?

Если у вас есть полный контроль над всей системой программного обеспечения, можно изменить тип свойства IsCompany на тип Nullable<bool>, который допускает значение «не выбрано». В реальном мире все не всегда так просто. Допустим, вы не можете изменить класс Customer, потому что он происходит из устаревшей библиотеки, принадлежащей другой группе в вашей компании. Что делать, если нет простого способа сохранить это значение «не выбрано» из-за существующей схемы базы данных? Что, если другие приложения уже используют класс Customer и им нужно, чтобы это свойство было обычным логическим значением. И опять модель представления приходит на помощь.

Метод тестирования на рис. 12 показывает, как эта функция работает в CustomerViewModel. CustomerViewModel предоставляет свойство CustomerTypeOptions так, что у селектора типа клиента есть для отображения три строки. Он также представляет свойство CustomerType, сохраняющее выбранную строку в селекторе. Когда установлен CustomerType, он сопоставляет строковое значение с логическим значением для свойства IsCompany нижележащего объекта Customer. На рис. 13 показаны эти два свойства.

Рис. 12. Метод тестирования

// In CustomerViewModelTests.cs
[TestMethod]
public void TestCustomerType()
{
    Customer cust = Customer.CreateNewCustomer();
    CustomerRepository repos = new CustomerRepository(
        Constants.CUSTOMER_DATA_FILE);
    CustomerViewModel target = new CustomerViewModel(cust, repos);

    target.CustomerType = "Company"
    Assert.IsTrue(cust.IsCompany, "Should be a company");

    target.CustomerType = "Person";
    Assert.IsFalse(cust.IsCompany, "Should be a person");

    target.CustomerType = "(Not Specified)";
    string error = (target as IDataErrorInfo)["CustomerType"];
    Assert.IsFalse(String.IsNullOrEmpty(error), "Error message should 
        be returned");
}

Рис. 13 Свойства CustomerType

// In CustomerViewModel.cs

public string[] CustomerTypeOptions
{
    get
    {
        if (_customerTypeOptions == null)
        {
            _customerTypeOptions = new string[]
            {
                "(Not Specified)",
                "Person",
                "Company"
            };
        }
        return _customerTypeOptions;
    }
}
public string CustomerType
{
    get { return _customerType; }
    set
    {
        if (value == _customerType || 
            String.IsNullOrEmpty(value))
            return;

        _customerType = value;

        if (_customerType == "Company")
        {
            _customer.IsCompany = true;
        }
        else if (_customerType == "Person")
        {
            _customer.IsCompany = false;
        }

        base.OnPropertyChanged("CustomerType");
        base.OnPropertyChanged("LastName");
    }
}

Элемент управления CustomerView содержит ComboBox, привязанный к этим свойствам, как показано ниже.

<ComboBox 
  ItemsSource="{Binding CustomerTypeOptions}"
  SelectedItem="{Binding CustomerType, ValidatesOnDataErrors=True}"
  />

Когда изменяется выбранный элемент в этом ComboBox, интерфейс IDataErrorInfo источника данных опрашивается для проверки, действительно ли новое значение. Это происходит потому, что у привязки свойства SelectedItem ValidatesOnDataErrors установлено на значение «истина». Так как источник данных — объект CustomerViewModel, система привязки спрашивает у этого объекта CustomerViewModel об ошибке проверки допустимости для свойства CustomerType. Большинство времени CustomerViewModel делегирует все запросы об ошибках проверки допустимости входящему в него объекту Customer. Но так как Customer не знает о состоянии «не выбрано» для свойства IsCompany, класс CustomerViewModel должен обработать проверку заново выбранного элемента в элементе управления ComboBox. Этот код показан на рис. 14.

Рис. 14 Проверка объекта CustomerViewModel

// In CustomerViewModel.cs
string IDataErrorInfo.this[string propertyName]
{
    get
    {
        string error = null;

        if (propertyName == "CustomerType")
        {
            // The IsCompany property of the Customer class 
            // is Boolean, so it has no concept of being in
            // an "unselected" state. The CustomerViewModel
            // class handles this mapping and validation.
            error = this.ValidateCustomerType();
        }
        else
        {
            error = (_customer as IDataErrorInfo)[propertyName];
        }

        // Dirty the commands registered with CommandManager,
        // such as our Save command, so that they are queried
        // to see if they can execute now.
        CommandManager.InvalidateRequerySuggested();

        return error;
    }
}

string ValidateCustomerType()
{
    if (this.CustomerType == "Company" ||
       this.CustomerType == "Person")
        return null;

    return "Customer type must be selected";
}

Ключевой момент этого кода — то, что реализация IDataErrorInfo в объекте CustomerViewModel может обрабатывать запросы проверки допустимости свойства, характерные для модели представления, и делегировать другие запросы объекту Customer. Это позволяет использовать логику проверки в классах модели и иметь дополнительную проверку для свойств, которая имеет смысл только для классов модель представления.

Способность сохранять CustomerViewModel доступна для представления через свойство SaveCommand. Эта команда использует класс RelayCommand, описанный выше, что позволяет CustomerViewModel решать, можно ли ему сохранить себя и что делать, если ему сказано сохранить свое состояние. В этом приложении сохранение нового клиента значит просто добавление его в CustomerRepository. Для решения вопроса о том, можно ли сохранить нового клиента, нужно получить согласие от двух сторон. Следует спросить объект Customer, допустим ли он, а CustomerViewModel должен решить, допустим ли он. Это двойное решение нужно из-за свойств и проверки, хараеткрных для модели представления, о которых мы говорили выше. Логика сохранения CustomerViewModel показана на рис. 15.

Рис. 15 Логика сохранения CustomerViewModel

// In CustomerViewModel.cs
public ICommand SaveCommand
{
    get
    {
        if (_saveCommand == null)
        {
            _saveCommand = new RelayCommand(
                param => this.Save(),
                param => this.CanSave
                );
        }
        return _saveCommand;
    }
}

public void Save()
{
    if (!_customer.IsValid)
        throw new InvalidOperationException("...");

    if (this.IsNewCustomer)
        _customerRepository.AddCustomer(_customer);

    base.OnPropertyChanged("DisplayName");
}

bool IsNewCustomer
{
    get 
    { 
        return !_customerRepository.ContainsCustomer(_customer); 
    }
}

bool CanSave
{
    get 
    { 
        return 
            String.IsNullOrEmpty(this.ValidateCustomerType()) && 
            _customer.IsValid; 
    }
}

Использование здесь модели представления значительно упрощает создание представление, которое может отображать объект Customer и позволяет такие вещи, как состояние «не выбрано» логического свойства. Оно также позволяет с легкостью сообщить клиенту, что нужно сохранить его состояние. Если бы представление было привязано прямо к объекту Customer, представоение потребовало бы гораздо больше кода, чтобы все заработало правильно. В хорошо спроектированной архитектуре MVVM код поддержки для большинства представлений должен быть пуст или в крайнем случае содержать код, управляющий элементами управления и ресурсами, содержащимися в этом представлении. Иногда также необходимо в коде поддержки представления написать код, взаимодействующий с объектом модели представления, например, фиксацию события или вызов метода, вызвать который иначе (из самой модели представления) было бы очень трудно.

Представление «All Customers»

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

Пользовательский интерфейс — элемент управления AllCustomersView, собирающий объект AllCustomersViewModel. Каждый ListViewItem представляет объект CustomerViewModel в наборе AllCustomers, представленный объектом AllCustomerViewModel. В предыдущем разделе вы видели, как CustomerViewModel может собирать форму ввода данных, а теперь такой же объект CustomerViewModel собирается как элемент ListView. Класс CustomerViewModel не знает, какие визуальные элементы его отображают, поэтому его можно использовать повторно подобным образом.

AllCustomersView создает группы, которые видны в ListView. Этого он добивается, привязывая ItemsSource ListView к CollectionViewSource, настроенной, как на рис. 16.

Рис. 16 CollectionViewSource

<!-- In AllCustomersView.xaml -->
<CollectionViewSource
  x:Key="CustomerGroups" 
  Source="{Binding Path=AllCustomers}"
  >
  <CollectionViewSource.GroupDescriptions>
    <PropertyGroupDescription PropertyName="IsCompany" />
  </CollectionViewSource.GroupDescriptions>
  <CollectionViewSource.SortDescriptions>
    <!-- 
    Sort descending by IsCompany so that the ' True' values appear first,
    which means that companies will always be listed before people.
    -->
    <scm:SortDescription PropertyName="IsCompany" Direction="Descending" />
    <scm:SortDescription PropertyName="DisplayName" Direction="Ascending" />
  </CollectionViewSource.SortDescriptions>
</CollectionViewSource>

Связь между ListViewItem и объектом CustomerViewModel устанавливается за счет свойства ItemContainerStyle элемента ListView. Стиль, присвоенный этому свойству, применяется к каждому ListViewItem, что позволяет привязывать свойства ListViewItem к свойствам CustomerViewModel. Одна важная привязка в этом стиле создает связь между свойством IsSelected ListViewItem и свойством IsSelected CustomerViewModel, как показано ниже.

<Style x:Key="CustomerItemStyle" TargetType="{x:Type ListViewItem}">
  <!--   Stretch the content of each cell so that we can 
  right-align text in the Total Sales column.  -->
  <Setter Property="HorizontalContentAlignment" Value="Stretch" />
  <!-- 
  Bind the IsSelected property of a ListViewItem to the 
  IsSelected property of a CustomerViewModel object.
  -->
  <Setter Property="IsSelected" Value="{Binding Path=IsSelected, 
    Mode=TwoWay}" />
</Style>

Когда выбирается или снимается выбор CustomerViewModel, изменяется общая сумма продаж для всех выбранных клиентов. Класс AllCustomersViewModel ответственен за поддержание этого значения, чтобы ContentPresenter ниже элемента ListView мог показывать правильное число. На рис. 17 показано, как AllCustomersViewModel наблюдает за каждым клиентом, выбран ли он, и уведомленяет представление, если нужно обновить значение на экране.

Рис. 17 Наблюдение за состоянием «выбран» или «не выбран»

// In AllCustomersViewModel.cs
public double TotalSelectedSales
{
    get
    {
        return this.AllCustomers.Sum(
            custVM => custVM.IsSelected ? custVM.TotalSales : 0.0);
    }
}

void OnCustomerViewModelPropertyChanged(object sender, 
    PropertyChangedEventArgs e)
{
    string IsSelected = "IsSelected";

    // Make sure that the property name we're 
    // referencing is valid.  This is a debugging 
    // technique, and does not execute in a Release build.
    (sender as CustomerViewModel).VerifyPropertyName(IsSelected);

    // When a customer is selected or unselected, we must let the
    // world know that the TotalSelectedSales property has changed,
    // so that it will be queried again for a new value.
    if (e.PropertyName == IsSelected)
        this.OnPropertyChanged("TotalSelectedSales");
}

Пользовательский интерфейс привязывается к свойству TotalSelectedSales и применяет к значению форматирование валюты (денежное). Применять форматирование валюты мог бы объект модели представления вместо представления, возвращая значение типа String вместо значения типа Double из свойства TotalSelectedSales. Свойство ContentStringFormat класса ContentPresenter добавлено в .NET Framework 3.5 SP1, поэтому если нужно использовать более старую версию WPF, форматирование валюты придется применять в коде:

<!-- In AllCustomersView.xaml -->
<StackPanel Orientation="Horizontal">
  <TextBlock Text="Total selected sales: " />
  <ContentPresenter
    Content="{Binding Path=TotalSelectedSales}"
    ContentStringFormat="c"
  />
</StackPanel>

Заключение

WPF может предложить разработчикам приложений очень многое; нужно начать мыслить немного иначе, чтобы научиться пользоваться этими возможностями. Шаблон модель-представление-модель представления — простой и эффективный набор рекомендаций для проектирования и реализации приложения WPF. Он позволяет разделять данные, поведение и представление, а значит, контролировать тот хаос, который называется разработкой программного обеспечения.

Я хотел бы поблагодарить Джона Госсмана (John Gossman) за помощь с этой статьей.

Джош Смит (Josh Smith) полон энтузиазма по поводу использования WPF для обеспечения отличного обслуживания пользователей. Он удостоился звания Microsoft MVP за свою работу в сообществе WPF. Джош работает в группе проектирования обслуживания компании Infragistics. В свободное от сидения за компьютером время он увлекается игрой на пианино, чтением на исторические темы и изучением Нью-Йорка вместе со своей девушкой. Блог Джоша можно посетить по адресу joshsmithonwpf.wordpress.com.