Шаблон MVVM в WinRT

76

Одним из основных правил при выборе структуры программного продукта является разделение обязанностей. Чтобы разработка, отладка и сопровождение крупного приложения проходили наиболее эффективно, приложение следует разбить на специализированные уровни. В высокоинтерактивных графических средах очевидная линия разделения проходит между представлением и содержанием (контентом). К уровню представления относится часть программы, отображающая элементы управления (и другую графику) и взаимодействующая с пользователем. Ниже уровня представления располагаются бизнес-логика и поставщики данных.

Для упрощения концептуального определения и реализации разделения обязанностей были разработаны специальные архитектурные шаблоны (patterns). В средах программирования на базе XML чрезвычайной популярностью пользуется шаблон MVVM (Model-View-ViewModel). Он особенно хорошо приспособлен для реализации уровня представления в XAML и подключения бизнес-логики через привязки данных и команды.

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

Тем не менее механизмы привязки данных и передачи команд являются важной частью Windows Runtime. Будет полезно посмотреть, как они используются в реализации архитектуры MVVM.

Как следует из самого названия, приложение, использующее шаблон Model-View-ViewModel, делится на три уровня:

Модель (Model)

Уровень, работающий с данными и физическим содержанием; часто задействуется в получении данных из файлов и от веб-служб, а также их последующем сопровождении.

Представление (View)

Уровень отображения элементов управления и графики, обычно реализуемый в разметке XAML.

Промежуточная модель представления (ViewModel)

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

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

Если все взаимодействия между тремя уровнями организуются посредством вызова методов, то иерархия вызовов выглядит так:

Представление → Модель представления → Модель

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

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

Привязки данных и команды обеспечивают возможность выполнения трех видов взаимодействий:

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

Оповещения привязок данных

Ранее мы рассмотрели привязки данных, которые выглядели примерно так:

<TextBlock Text="{Binding ElementName=slider, Path=Value}" />

Привязка создается между двумя элементами, производными от FrameworkElement. Ее приемником является свойство Text элемента TextBlock, а источником - свойство Value элемента Slider, определяемого именем slider. Оба свойства - источник и приемник - поддерживаются свойствами зависимости. Это требование обязательно для приемника привязки, но не для источника (как вы вскоре убедитесь).

Каждый раз, когда свойство Value элемента Slider изменяется, текст в элементе TextBlock изменяется соответствующим образом. Как работает эта связь? Когда источник привязки является свойством зависимости, фактически используется внутренний механизм Windows Runtime. Разумеется, взаимодействия основаны на событиях. Объект Binding назначает обработчик события, оповещающего об изменении свойства Value элемента Slider, и он же задает измененное значение свойству Text элемента TextBlock; в процессе значение double преобразуется в string. Здесь особых тайн не видно - элемент Slider поддерживает открытое событие ValueChanged, которое также инициируется при изменении свойства Value.

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

Источник привязки не обязан поддерживаться свойством зависимости. Но для того, чтобы привязка успешно работала, он должен реализовать некоторую разновидность механизма оповещений, который будет информировать объект Binding об изменении свойства. Оповещения не происходят автоматически; они должны быть явно реализованы посредством событий.

Стандартный способ использования модели представления как источника привязки основан на реализации интерфейса INotifyPropertyChanged, определенного в пространстве имен System.ComponentModel. Определение интерфейса выглядит предельно просто:

public interface INotifyPropertyChanged
{
    event PropertyChangedEventHandler PropertyChanged;
}

Делегат PropertyChangedEventHandler ассоциирован с классом PropertyChangedEventArgs, определяющим всего одно свойство: PropertyName типа string. Если класс реализует INotifyPropertyChanged, то при каждом изменении одного из его свойств инициируется событие PropertyChanged.

Ниже приведен простой пример класса, реализующего INotifyPropertyChanged. Свойство с именем TotalScore инициирует событие PropertyChanged при изменении свойства:

using System.ComponentModel;

namespace WinRTTestApp
{
    public class SimpleViewModel : INotifyPropertyChanged
    {
        double totalScore;
        public event PropertyChangedEventHandler PropertyChanged;

        public double TotalScore
        {
            get
            {
                return totalScore;
            }

            set
            {
                if (totalScore != value)
                {
                    totalScore = value;

                    if (PropertyChanged != null)
                        PropertyChanged(this, new PropertyChangedEventArgs("TotalScore"));
                }
            }
        }
    }
}

Свойство TotalScore поддерживается полем totalScore. Обратите внимание: свойство TotalScore сравнивает значение, переданное set-методу, с текущим значением totalScore и инициирует событие PropertyChanged только в случае фактического изменения свойства. Не пропускайте эту проверку только для того, чтобы сделать set-методы чуть короче! Событие должно оповещать именно об изменении свойства, а не о том, что оно могло измениться.

Также следует учесть, что класс может реализовать INotifyPropertyChanged без инициирования событий PropertyChanged, но такое поведение считается крайне нежелательным.

Если класс содержит более двух-трех свойств, в нем стоит определить защищенный метод OnPropertyChanged, который будет инициировать фактическое событие. Такие классы можно частично автоматизировать, как вы вскоре увидите.

В ходе проектирования представления и модели представления элементы управления удобно рассматривать как визуальные «воплощения» типов данных. Через привязки данных элементы представления связываются со свойствами этих типов в модели представления. Например, Slider соответствует типу double, TextBox - типу string, CheckBox или ToggleSwitch - типу bool, а группа элементов RadioButton - перечислению.

Модель представления для ColorScroll

Ранее мы создали программу ColorScroll, где было продемонстрировано использование привязки данных для обновления TextBlock в соответствии с изменением свойства значения элемента управления Slider.

Программа ColorScroll

Программа запущена в режиме Snap View, я использовал стандартные обои для рабочего стала при запуске в Windows Runtime. Вы можете загрузить обои на рабочий стол для стилизации внешнего вида приложения в режиме Snap View, тогда результат запуска этого примера у вас получится другим.

Задача определения привязки данных для изменения цвета на основании сразу трех значений Slider оказалась куда более сложной. Да и возможно ли это?

Да, возможно. Задача решается созданием отдельного класса, единственным предназначением которого является создание объекта Color по значениям свойств Red, Green и Blue. Любое изменение одного из трех свойств инициирует пересчет свойства Color. В файле XAML привязки соединяют элементы управления Slider со свойствами Red, Green и Blue, а свойство SolidColorBrush - со свойством Color.

Следующий класс RgbViewModel реализует интерфейс INotifyPropertyChanged, чтобы при изменении его свойств Red, Green, Blue или Color инициировалось событие PropertyChanged:

using System.ComponentModel;        // для INotifyPropertyChanged
using Windows.UI;                   // для Color

namespace WinRTTestApp
{
    public class RgbViewModel : INotifyPropertyChanged
    {
        double red, green, blue;
        Color color = Color.FromArgb(255, 0, 0, 0);

        public event PropertyChangedEventHandler PropertyChanged;

        public double Red
        {
            set
            {
                if (red != value)
                {
                    red = value;
                    OnPropertyChanged("Red");
                    Calculate();
                }
            }
            get
            {
                return red;
            }
        }

        public double Green
        {
            set
            {
                if (green != value)
                {
                    green = value;
                    OnPropertyChanged("Green");
                    Calculate();
                }
            }
            get
            {
                return green;
            }
        }

        public double Blue
        {
            set
            {
                if (blue != value)
                {
                    blue = value;
                    OnPropertyChanged("Blue");
                    Calculate();
                }
            }
            get
            {
                return blue;
            }
        }

        public Color Color
        {
            protected set
            {
                if (color != value)
                {
                    color = value;
                    OnPropertyChanged("Color");
                }
            }
            get
            {
                return color;
            }
        }

        private void Calculate()
        {
            this.Color = Color.FromArgb(255, (byte)this.Red, (byte)this.Green, (byte)this.Blue);
        }

        protected void OnPropertyChanged(string propertyName)
        {
            if (PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

Метод OnPropertyChanged в конце класса непосредственно инициирует событие PropertyChanged с именем свойства.

Я определил события Red, Green и Blue с типом double, чтобы упростить привязки данных. Эти свойства поставляют входные данные модели представления и, вероятно, будут поступать от элементов управления (например, Slider), так что тип double в данном случае наиболее универсален.

Каждый из set-методов доступа свойств Red, Green и Blue инициирует событие PropertyChanged, а затем вызывает метод Calculate(), который задает новое значение Color, что приводит к инициированию нового события PropertyChanged для свойства Color. Само свойство Color содержит set-метод, который объявлен защищенным - это означает, что данный класс не рассчитан на вычисление значений Red, Green и Blue по новому значению Color (эта тема вскоре будет рассмотрена более подробно).

Объект RgbViewModel можно добавить в секцию Resources файла MainPage.xaml:

<Page ...
    xmlns:local="using:WinRTTestApp">
    
    <Page.Resources>
        <local:RgbViewModel x:Key="rgbViewModel" />
    </Page.Resources>

    ...
</Page>

Обратите внимание на префикс пространства имен local.

Определение модели представления в виде ресурса - один из двух основных способов получения доступа к объекту в файле XAML. Как было показано ранее, у классов, включенных в секцию Resources, создается всего один экземпляр, который совместно используется всеми ссылками StaticResource. Данное поведение исключительно важно для таких приложений, в которых все привязки должны относиться к одному объекту.

Элементы управления Slider в этом приложении похожи друг на друга, поэтому я приведу только один из них:

...
<!-- Красный ползунок -->
<TextBlock Text="R"
           Grid.Column="0"
           Grid.Row="0"
           Foreground="Red" />

<Slider Grid.Column="0"
        Grid.Row="1"
        Foreground="Red"
        Value="{Binding Source={StaticResource rgbViewModel},
                        Path=Red,
                        Mode=TwoWay}" />

<TextBlock Text="{Binding Source={StaticResource rgbViewModel},
                          Path=Red,
                          Converter={StaticResource hexConverter}}"
           Grid.Column="0"
           Grid.Row="2"
           Foreground="Red" />
...

Обратите внимание: из элемента Slider пропал атрибут Name, потому что на этот элемент нет ссылок ни в файле XAML, ни в файле отделенного кода. Обработчика события ValueChanged тоже нет, потому что он не нужен. Файл отделенного кода не содержит ничего, кроме вызова InitializeComponent.

Внимательно присмотритесь к привязке Slider:

<Slider ...
        Value="{Binding Source={StaticResource rgbViewModel},
                        Path=Red,
                        Mode=TwoWay}" />

Тег получился слишком длинным, поэтому я разбил его на три строки. Атрибут ElementName в нем не указывается, потому что ссылка в файле XAML указывает не на другой элемент, а на объект, экземпляр которого был создан как ресурс XAML, поэтому в данном случае следует использовать атрибут Source с расширением StaticResource. Синтаксис этой привязки подразумевает, что приемником привязки является свойство Value элемента Slider, а источником - свойство Red экземпляра RgbViewModel. Почему не наоборот? Разве не Slider поставляет значение для RgbViewModel?

Да, но экземпляр RgbViewModel должен быть источником привязки, а не приемником. Он не может быть приемником, потому что не имеет свойств зависимости. Хотя синтаксис вроде бы подразумевает, что Value является приемником привязки, на самом деле мы хотим, чтобы элемент Slider поставлял значение свойству Red. По этой причине свойству Mode объекта Binding задается значение TwoWay; это означает, что:

По умолчанию свойство Mode равно OneWay. Еще одно возможное значение - OneTime - означает, что приемник обновляется по свойству-источнику только при установлении привязки. В режиме OneTime при последующих изменениях свойства-источника обновление не происходит. Режим OneTime может использоваться в том случае, если источник не имеет механизма оповещений.

Также следует заметить, что элемент TextBlock, в котором выводится текущее значение, теперь объединен привязкой с объектом RgbViewModel:

<TextBlock Text="{Binding Source={StaticResource rgbViewModel},
                          Path=Red,
                          Converter={StaticResource hexConverter}}"
           ... />

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

Режим OneWay также подходит для привязки свойства Color объекта SolidColorBrush:

<Rectangle Grid.Column="3"
           Grid.Row="0"
           Grid.RowSpan="3">
     <Rectangle.Fill>
         <SolidColorBrush 
               Color="{Binding Source={StaticResource rgbViewModel}, Path=Color}" />
     </Rectangle.Fill>
</Rectangle>

Элемент SolidColorBrush уже не имеет атрибута x:Name, потому что в файле отделенного кода нет ни одной ссылки на него.

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

В классе RgbViewModel я объявил set-метод свойства Color защищенным, чтобы к нему можно было обращаться только из класса. Так ли это необходимо? Возможно, свойство Color можно определить таким образом, что внешнее изменение свойства приводит к вычислению новых значений свойств Red, Green и Blue:

public Color Color
{
    set
    {
        if (color != value)
        {
            color = value;
            OnPropertyChanged("Color");

            this.Red = color.R;
            this.Green = color.G;
            this.Blue = color.B;
        }
    }

    get
    {
        return color;
    }
}

На первый взгляд такое решение грозит неприятностями - оно приводит к рекурсивному изменению свойств и рекурсивным вызовам OnPropertyChanged. Однако этого не произойдет, потому что set-методы ничего не делают при отсутствии фактического изменения свойства, так что решение должно быть безопасным.

Все же в нем есть изъян. Допустим, свойство Color в настоящее время содержит значение RGB (0, 0, 0) и ему присваивается значение (255, 128, 0). Когда свойству Red в коде задается значение 255, инициируется событие PropertyChanged, но теперь Color (и color) содержит (255, 0, 0), и выполнение кода продолжается с нулевыми значениями свойств Green и Blue.

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

public Color Color
{
    set
    {
        if (SetProperty<Color>(ref color, value))
        {
            this.Red = color.R;
            this.Green = color.G;
            this.Blue = color.B;
        }
    }

    get
    {
        return color;
    }
}

В следующей версии программы я сделаю set-метод свойства Color открытым.

Сокращенный синтаксис

Возможно, вы заключили из кода RgbViewModel, что реализация INotifyPropertyChanged - дело хлопотное... и это правда. Чтобы немного упростить задачу программиста, Visual Studio создает класс BindableBase в папке Common проектов типа Grid App и Split App. Не путайте этот класс с классом BindingBase, производным от которого является Binding. (Этот класс добавляется в проектах для Windows Runtime 8.0, но не для 8.1).

В проектах типа Blank App Visual Studio класс BindableBase не создает. Но давайте присмотримся к нему и посмотрим, что полезного можно из него узнать.

Класс BindableBase определяется в пространстве имен, состоящем из имени проекта, точки и слова Common. Вот как он выглядит без комментариев и атрибутов:

public abstract class BindableBase : INotifyPropertyChanged
{
        public event PropertyChangedEventHandler PropertyChanged;

        protected bool SetProperty<T>(ref T storage, T value, 
            [CallerMemberName] String propertyName = null)
        {
            if (object.Equals(storage, value))
                return false;

            storage = value;
            this.OnPropertyChanged(propertyName);
            return true;
        }

        protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
        {
            var eventHandler = this.PropertyChanged;
            if (eventHandler != null)
                eventHandler(this, new PropertyChangedEventArgs(propertyName));
        }
}

Класс, производный от BindableBase, вызывает SetProperty() в set-методе своих определений свойств. Сигнатура метода SetProperty выглядит немного устрашающе, но пользоваться этим методом очень просто. Например, для свойства с именем Red типа double создается поддерживающее поле, которое определяется так:

double red;

Вызов SetProperty в set-методе выглядит следующим образом:

SetProperty<double>(ref red, value, "Red");

Обратите внимание на использование CallerMemberName в BindableBase. Этот атрибут, добавленный в .NET 4.5, может использоваться C# 5.0 для получения информации о коде, вызывающем конкретное свойство или метод; это означает, что SetProperty() можно вызывать без последнего аргумента. Если метод SetProperty() вызывается из set-метода свойства Red, имя будет предоставлено автоматически:

SetProperty<double>(ref red, value);

Метод SetProperty() возвращает true, если свойство действительно изменилось. Вероятно, оно будет использовано в логике, которая что-то делает с новым значением. В следующем проекте я создал альтернативную версию класса RgbViewModel, которая заимствует часть кода из BindableBase, а также определил для Color открытый set-метод:

public class RgbViewModel : INotifyPropertyChanged
{
        double red, green, blue;
        Color color = Color.FromArgb(255, 0, 0, 0);

        public event PropertyChangedEventHandler PropertyChanged;

        public double Red
        {
            set
            {
                if (SetProperty<double>(ref red, value, "Red"))
                    Calculate();
            }
            get
            {
                return red;
            }
        }

        public double Green
        {
            set
            {
                if (SetProperty<double>(ref green, value))
                    Calculate();
            }
            get
            {
                return green;
            }
        }

        public double Blue
        {
            set
            {
                if (SetProperty<double>(ref blue, value))
                    Calculate();
            }
            get
            {
                return blue;
            }
        }

        public Color Color
        {
            set
            {
                if (SetProperty<Color>(ref color, value))
                {
                    this.Red = value.R;
                    this.Green = value.G;
                    this.Blue = value.B;
                }
            }
            get
            {
                return color;
            }
        }

        private void Calculate()
        {
            this.Color = Color.FromArgb(255, (byte)this.Red, (byte)this.Green, (byte)this.Blue);
        }

        protected bool SetProperty<T>(ref T storage, T value,
                                      [CallerMemberName] string propertyName = null)
        {
            if (object.Equals(storage, value))
                return false;

            storage = value;
            OnPropertyChanged(propertyName);
            return true;
        }

        protected void OnPropertyChanged(string propertyName)
        {
            if (PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
}

Эта форма реализации INotifyPropertyChanged выглядит более элегантно и безусловно более компактна.

Свойство DataContext

Пока что мы видели три способа задания объекта-источника привязки: ElementName, RelativeSource и Source. ElementName идеально подходит для ссылок на именованный атрибут в XAML, a RelativeSource позволяет привязке ссылаться на свойство объекта-приемника. Третий способ - свойство Source, обычно используется в сочетании с StaticResource для обращения к объектам в коллекции Resources.

Также существует четвертый способ задания источника привязки: если ElementName, RelativeSource и Source содержат null, объект Binding проверяет свойство DataContext приемника.

Свойство DataContext определяется классом FrameworkElement и обладает замечательной (и исключительно важной) особенностью: оно распространяется по визуальному дереву. Лишь немногие свойства распространяются по визуальному дереву подобным образом - как, например, Foreground и все шрифтовые свойства, но других таких свойств очень мало. DataContext - одно из важных исключений из правила. Конструктор в файле отделенного кода может создать экземпляр модели представления и назначить этот экземпляр свойству DataContext страницы. Вот как это делается в файле MainPage.xaml.cs измененного проекта:

using Windows.UI.Xaml.Controls;
using Windows.UI.ViewManagement;

namespace WinRTTestApp
{
    public sealed partial class MainPage : Page
    {
        public MainPage()
        {
            this.InitializeComponent();
            this.DataContext = new RgbViewModel();

            // Инициализация цветом выделения
            (this.DataContext as RgbViewModel).Color =
                new UISettings().UIElementColor(UIElementType.Highlight);
        }
    }
}

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

Обратите внимание: я также не упустил возможностью проверить, правильно ли работает задание свойства Color, инициализировав его системным цветом выделения.

У решения с DataContext есть одно большое преимущество: упрощение привязок данных. Так как привязки не требуют настройки Source, они могут выглядеть так:

<Slider ...
        Value="{Binding Path=Red, Mode=TwoWay}" />

Более того, если свойство Path является первым в разметке привязки, часть "Path=" можно вообще удалить:

<Slider ...
        Value="{Binding Red, Mode=TwoWay}" />

Вот теперь синтаксис Binding стал действительно простым! Часть "Path=" можно удалить из любой спецификации привязки независимо от источника, но только в том случае, если Path стоит на первой позиции. Там, где я использую Source или ElementName, я предпочитаю ставить это свойство на первое место в спецификации Binding, и поэтому удаляю "Path=" только там, где используется DataContext.

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

...
<!-- Зеленый ползунок -->
<TextBlock Text="G"
           Grid.Column="1"
           Grid.Row="0"
           Foreground="Lime" />

<Slider Grid.Column="1"
        Grid.Row="1"
        Foreground="Lime"
        Value="{Binding Green, Mode=TwoWay}"/>

<TextBlock Text="{Binding Green, Converter={StaticResource hexConverter}}"
           Grid.Column="1"
           Grid.Row="2"
           Foreground="Lime" />

...

<!-- Результат -->
<Rectangle Grid.Column="3"
           Grid.Row="0"
           Grid.RowSpan="3">
    <Rectangle.Fill>
        <SolidColorBrush Color="{Binding Color}" />
    </Rectangle.Fill>
</Rectangle>

Возможно использование гибридного подхода. Например, экземпляр модели представления создается в коллекции Resources файла XAML:

<Page.Resources>
    <local:RgbViewModel x:Key="rgbViewModel" />
    ...
</Page.Resources>

А затем в ближайшем удобном месте визуального дерева задается свойство DataContext:

<Grid DataContext="{StaticResource rgbViewModel}" ...>

Или:

<Grid DataContext="{Binding Source={StaticResource rgbViewModel}}" ... >

Вторая форма особенно полезна, если вы хотите задать DataContext свойству модели представления.

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