Pull to refresh

MVVM: полное понимание (+WPF) Часть 1

Reading time 8 min
Views 279K
В настоящей статье задействован мой опыт доведения некоторого числа студентов до полного и окончательного понимания паттерна MVVM и реализации его в WPF. Паттерн описывается на примерах возрастающей сложности. Сначала теоретическая часть, которая может использоваться безотносительно конкретного языка, затем практическая часть, в которой показано несколько вариантов реализации коммуникации между слоями с использованием WPF и, немножко, Prism.

Зачем вообще нужно использовать паттерн MVVM? Это ведь лишний код! Написать тоже самое можно гораздо понятнее и прямолинейнее.

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


Изображение 1: код без MVVM.


Изображение 2: код с MVVM.

В первом случае программист, со словами «Мне нужно просто соединить вот этот порт и этот, зачем мне все эти хомутики и лейблики?», просто соединяет патчкордом пару слотов. Во втором случае использует некоторый шаблонный подход.

Рассмотрение паттерна на примере №1: Сложение двух чисел с выводом результата


Методика:

Методика написания программы используя подход «ModelFirst».

  • 1. Разработать модель программы.
  • 2. Нарисовать интерфейс программы.
  • 3. Соединить интерфейс и модель прослойкой VM.

Итак, наше задание — написать программу сложения двух чисел с выводом результата. Выполняем первый пункт: пишем модель. Модель — это место в программе, которое может требовать некоторое творческое усилие или задействие своих творческих способностей.

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

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

 static class MathFuncs {
    public static int GetSumOf(int a, int b) => a + b;
  }

Следующий шаг — (см. методику «ModelFirst») — создать View или, проще — нарисовать интерфейс. Это тоже часть, которая может содержать творчество. Но, опять же, не стоить с ним перебарщивать. Пользователь не должен быть шокирован неожиданностями интерфейса. Интерфейс должен быть интуитивен. Наша View будет содержать три текстовых поля, которые можно снабдить лейблами: число номер один, число номер два, сумма.

Заключительный шаг — соединение View и модели через VM. VM — это такое место, которое вообще не должно содержать творческого элемента. Т.е. эта часть паттерна железно обуславливается View и не должна содержать в себе НИКАКОЙ «бизнес логики». Что значит обусловленность от View? Это значит, что если у нас во View есть три текстовых поля, или три места, которые должны вводить/выводить данные — следовательно в VM (своего рода подложке) должны быть минимум три свойства, которые эти данные принимают/предоставляют.

Следовательно два свойства принимают из View число номер один и два, а третье свойство — вызывает нашу модель для выполнения бизнес-логики нашей программы. VM ни в коем случае не выполняет сложение чисел самостоятельно, оно для этого действия только вызывает модель! В этом и состоит функция VM — соединять View (которое тоже ничем иным, кроме как приема ввода от пользователя и предоставления ему вывода не занимается) и Модель, в которой происходит все вычисление. Если нарисовать картинку нашей задачки, то получиться нечто такое:


Изображение 3: Схема Примера №1

Зеленое — это View, три зеленые точки в которой — это наши три текстовые поля. Синее — это VM, к которой эти три зеленых точки железно прибиты (прибиндены), ну а красное облачко — это модель, которая занимается вычислением.

Реализация Примера №1 в WPF


Конкретно в WPF реализована «аппаратная поддержка» паттерна MVVM. View реализуется в XAML. Т.е. зеленый слой (View) будет написана на XAML. Зеленые точки — это будут текстовые поля. А зеленые линии, соединяющиеся с синими — будут реализованы через механизм Binding. Зеленая пунктирная линия — связь всей View и VM осуществляется, когда мы создаем объект VM и присваиванием его свойству DataContext View.

Рисуем View:

<Window ....
xmlns:local "clr-namespace: MyNamespace">  <!-- Это пространство имен с нашей VM -->
<Window.DataContext>
  <local:MainVM/>  <!-- Создаем новый VM и соединяем его со View -->
</Window.DataContext>
<StackPanel>
  <!--Binding, собственно, соединяет текстовое поле со свойством в VM -->
  <!--UpdateSourceTrigger, в данном случае, выполняет передачу значение в VM в момент ввода -->
  <TextBox Width="30" Text="{Binding Number1, UpdateSourceTrigger=PropertyChanged}">
  <TextBox Width="30" Text="{Binding Number2, UpdateSourceTrigger=PropertyChanged}">
  <!--Mode=OneWay необходим для призязки свойства только для чтения -->
  <TextBox Width="30" Text="{Binding Number3, Mode=OneWay}" IsReadOnly="True">
</StackPanel>

Теперь выполняем последний пункт методики — реализуем VM. Чтобы наша VM «автоматически» обновляла View, требуется реализовать интерфейс INotifyPropertyChange. Именно посредством него View получает уведомления, что во VM что-то изменилось и требуется обновить данные.

Делается это следующим образом:

public class MainVM : INotifyPropertyChange
{
  public event PropertyChangedEventHandler PropertyChanged;
  protected virtual void OnPropertyChanged(string propertyName) {
      PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
  }
}

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

private int _number1;
public int Number1 { get {return _number1;}
  set { _number1 = value;
    OnPropertyChanged("Number3"); // уведомление View о том, что изменилась сумма
  }
}

private int _number2;
public int Number2 { get {return _number2;}
  set { _number1 = value; OnPropertyChanged("Number3"); } }

Последнее свойство — это линия пунктирная синяя линия связи VM и модели:

//свойство только для чтения, оно считывается View каждый раз, когда обновляется Number1 или Number2
public int Number3 { get; } => MathFuncs.GetSumOf(Number1, Number2);

Мы реализовали полноценное приложение с применением паттерна MVVM.

Рассмотрение паттерна на примере №2:

Теперь усложним наше задание. В программе будет текстовое поле для ввода числа. Будет ListBox с коллекцией значений. Кнопка «Добавить», по нажатию на которую число в текстовом поле будет добавлено в коллекцию значений. Кнопка удалить, по нажатию на которую выделенное в ListBox'е число будет удалено из коллекции. И текстовое поле с суммой всех значений в коллекции.


Изображение 4: Интерфейс для Примера №2

Согласно методике — необходимо сначала разработать модель. Теперь модель не может быть stateless и должна хранить состояние. Значит в модели будет коллекция элементов. Это раз. Затем — операция добавление некоторого числа в коллекцию — это обязанность модели. VM не может залезать во внутренность модели и самостоятельно добавлять в коллекцию модели число, она обязана просить сделать это саму модель. В противном случае это будет нарушение принципа инкапсуляции. Это как если бы водитель не заливал, как положено, топливо в бензобак и т.д. — а лез бы под капот и впрыскивал топливо непосредственно в цилиндр. То есть будет метод «добавить число в коллекцию». Это два. И третье: модель будет предоставлять сумму значений коллекции и точно также уведомлять об ее изменении через интерфейс INotifyPropertyChanged. Не будем разводить споры о чистоте модели, а будем просто использовать уведомления.

Давайте сразу реализуем модель:

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

Кроме того, так так мы далее все равно подключим Prism для DelegateCommand, то давайте сразу использовать BindableBase вместо самостоятельной реализации INotifyPropertyChange. Для этого надо подключить через NuGet библиотек Prism.Wpf (на момент написания 6.3.0). Соответственно OnPropertyChanged() измениться на RaisePropertyChanged().

public class MyMathModel : BindableBase
{
  private readonly ObservableCollection<int> _myValues = new ObservableCollection<int>();
  public readonly ReadOnlyObservableCollection<int> MyPublicValues;
  public MyMathModel() {
    MyPublicValues = new ReadOnlyObservableCollection<int>(_myValues);
  }
  //добавление в коллекцию числа и уведомление об изменении суммы
  public void AddValue(int value) {
    _myValues.Add(value);
    RaisePropertyChanged("Sum");
  }
  //проверка на валидность, удаление из коллекции и уведомление об изменении суммы
  public void RemoveValue(int index) {
      //проверка на валидность удаления из коллекции - обязанность модели
    if (index >= 0 && index < _myValues.Count) _myValues.RemoveAt(index);
    RaisePropertyChanged("Sum");
  }
  public int Sum => MyPublicValues.Sum(); //сумма
}

Согласно методике — рисуем View. Перед этим несколько необходимых пояснений. Для того, чтобы создать связь кнопки и VM, необходимо использовать DelegateCommand. Использование для этого событий и кода формы, для чистого MVVM — непозволительно. Используемые события необходимо обрамлять в команды. Но в случае с кнопкой такого обрамления не требуется, т.к. существует специальное ее свойство Command.

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


Изображение 5: Схема для Примера №2

Здесь привязка на View происходит не вида View <=> ViewModel, а вида View <=> View. Для того, чтобы этого добиться используется второй вид биндинга, где указывается имя элемента и его свойства, к которому осуществляться привязка — "{Binding ElementName=TheNumber, Path=Text}".

<Window ...>
    <Window.DataContext>
        <local:MainVM/>   <!-- Устанавливаем DataContext -->
    </Window.DataContext>
    <DockPanel>
        <!-- Число для добавления в коллекцию -->
        <StackPanel DockPanel.Dock="Top" Orientation="Horizontal">
            <TextBox x:Name="TheNumber" Width="50" Margin="5"/>
            <Button Content="Add" Margin="5" Command="{Binding AddCommand}"
                    CommandParameter="{Binding ElementName=TheNumber, Path=Text}"/>
        </StackPanel>
        <!-- Сумма -->
        <TextBox DockPanel.Dock="Bottom" Text="{Binding Sum, Mode=OneWay}" Margin="5"/>
        <!-- Кнопка удаления из коллекции -->
        <Button DockPanel.Dock="Right" VerticalAlignment="Top" Content="Remove"
                Width="130" Margin="5"
                Command="{Binding RemoveCommand}"
                CommandParameter="{Binding ElementName=TheListBox, Path=SelectedIndex}"/>
        <!-- Коллекция -->
        <ListBox  x:Name="TheListBox" ItemsSource="{Binding MyValues}"/>
    </DockPanel>
</Window>

Теперь реализуем ViewModel:

public class MainVM : BindableBase
{
  readonly MyMathModel _model = new MyMathModel();
  public MainVM()
  {
    //таким нехитрым способом мы пробрасываем изменившиеся свойства модели во View
    _model.PropertyChanged += (s, e) => { RaisePropertyChanged(e.PropertyName); };
    AddCommand = new DelegateCommand<string>(str => {
      //проверка на валидность ввода - обязанность VM
      int ival;
      if (int.TryParse(str, out ival)) _model.AddValue(ival);
    });
    RemoveCommand = new DelegateCommand<int?>(i => {
        if(i.HasValue) _model.RemoveValue(i.Value);
    });
  }
  public DelegateCommand<string> AddCommand { get; }
  public DelegateCommand<int?> RemoveCommand { get; }
  public int Sum => _model.Sum;
  public ReadOnlyObservableCollection<int> MyValues => _model.MyPublicValues;
}

Внимание — важно! Касательно проброса уведомлений из модели. Уведомлять об изменении суммы самостоятельно VM не может, т.к. она не должна знать, что именно измениться в модели, после вызова ее методов и измениться ли вообще. Модель для VM должна быть черным ящиком. Т.е. она должна передавать ввод и действия пользователя в модель и если в модели что-то изменилось (о чем должна ее уведомлять сама модель), то только тогда уведомлять далее View.

Мы реализовали второе полноценное приложение с применением паттерна MVVM, познакомились с ObservableCollection, DelegateCommand, привязкой вида View <=> View и пробросом уведомлений во View.
Tags:
Hubs:
+7
Comments 53
Comments Comments 53

Articles