Разработка → Часть 2: MVVM: полное понимание (+WPF)

oggr 6 октября в 20:40 12k
image

В этой статье в качестве примера у нас будет программа чуть посложнее, а именно — торговый автомат, реализация которого часто встречается в качестве тестового задания до собеседования. Будут рассмотрены взаимодействие нескольких View с одним VM и наоборот, будет показан подход «View first» и будет показан не итоговый код, с рассказом какая часть для чего нужна (ссылка для скачивания кстати Vending Machine (программный код), а будет продемонстрирован весь процесс создания и, самое главное, последовательный ход мысли.

Но перед этим я постараюсь еще раз ответить на вопрос, который обычно не задают люди, имеющие опыт отладки неструктурированных проектов, а именно: «Так зачем все-таки нужен паттерн MVVM?»

Если формально и коротко, то паттерн MVVM используется в первую очередь для разделения ответственности, для повышения читабельности, управляемости, поддерживаемости и тестируемости кода. Программный продукт состоит из модели (доменной модели и бизнес-логики) и инфраструктурного кода в соотношении, допустим, 20% на 80%. Инфраструктурный код должен быть простым, понятным, чуть ли не автоматным — как Scaffolding. А вот модель…

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

Конкретно MVVM, а не, скажеи MVP или MVC, в WPF используется потому, что MVVM «аппаратно» поддерживается WPF. View понимает и INotifyPropertyChange, и Observable и др. — не надо ничего руками обновлять через презентер и т.д. Например MVP в WinForm's требовал больше инфраструктурного кода, причем ручного, и там преимущества разделения ответственности омрачалось бОльшим объемом черновой работы.

Задача


Вернемся к задаче. У неё вот такая формулировка: создать программу, эмулирующую взаимодействие человека с автоматом по продаже снеков/напитков.

Интерфейс программы должен отображать:

  1. Содержимое бумажника пользователя (изначально по 10 купюр/монет одного номинала) и его покупки.
  2. Содержимое деньгохранилища автомата (изначально по 100 купюр/монет одного номинала)
  3. Список возможных для покупки в автомате продуктов (в автомате изначально по 100 единиц каждого наименования)
  4. Текущий кредит в автомате (то, сколько денег туда вложил пользователь)

Интерфейс программы должен позволять:

  1. Внесение денежные средств пользователем в автомат
  2. Совершение покупок продуктов в автомате
  3. Требовать и получать, иногда, сдачу

Плюсом будет:

  1. Список товаров с ценами не задается жестко в коде
  2. Номиналы монет/купюр не задаются жестко в коде
  3. Соблюдение формального паттерна MVVM
  4. Настройка минимального доступа к полям и свойствам классов модели
  5. Красивый дизайнъ!

Последнее мы с вами сделаем вряд ли, а вот всё остальное — вполне.

В первой части мы использовали методику «Model first»:

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

Особенность такого подхода состоит в том, что мы должны заранее четко представлять модель, ее возможности. То, какие свойства и методы она будет предоставлять наружу, как будет устроено ее взаимодействие с интерфейсом. Но на первом этапе разработки мы даже не знаем, нужно ли будет то или иное взаимодействие. Нам нужны дополнительные точки опоры, в дополнение к описанному поведению в ТЗ. Такие точки опоры может нам предоставить интерфейс, т.е. View и VM к нему. В VM мы могли бы сформулировать клиентский код, т.е. тот код общего доступа (public), который мы бы хотели видеть в модели. Т.е. методика такая:

Методика «View first»:

  1. Нарисовать интерфейс программы — View
  2. Разработать VM к этим View, и сформировать клиентский код (код вызова модели)
  3. Имея интерфейс взаимодействия модели, реализовать её структуру и внутреннюю логику

Утром скетчи, вечером модель.

В создании интерфейса пользователя мало MVVM-специфичного, но этот пункт не обойти, поэтому давайте приступим к пункту №1.

Создание интерфейса


В ТЗ читаем, что нам нужно отобразить бумажник пользователя и его покупки и интерфейс автомата. Давайте разделим интерфейс физически (т.е. по разным файлам) на две части: одна для пользователя, другая для автомата. Это нужно, чтобы XAML файлы были поменьше. Работать с большими XAML файлами — (лично мне) неудобно. Тем более такое разбиение нам не будет ниего стоить, в WPF это делать очень просто: создать пару UserControl'ов — UserView.xaml и AutomatView.xaml, и использовать их в главном View — MainView.xaml. А DataContext они (UserView.xaml и AutomatView.xaml) будут использовать из главной формы. Т.е. если им не указывать DataContext, они как бы поднимаются по логическому дереву и натыкаются на DataContext главной формы, в которой они расположены, и используют его.

Начнем с UserView.xaml. Нам нужно тут отобразить содержимое бумажника и покупки. Покупки — это однозначно ListBox. А бумажник — это всего лишь число? Сумма наличности? Нет. В ТЗ сказано, что у пользователя есть по 10 купюр каждого номинала. Т.е. это тоже ListBox разных купюр с указанием количества. Давайте его релизуем:

UserView.xaml:

<!-- Монеты/купюры -->
<ListBox ItemsSource="{Binding UserWallet}">
    <ListBox.ItemTemplate>
        <DataTemplate>
            <StackPanel Orientation="Horizontal">
                <Image Width="32" Height="32" Source="{Binding Icon}"></Image>
                <Label Content="{Binding Name}"/>
                <Label Content="{Binding Amount}"/>
            </StackPanel>
        </DataTemplate>
    </ListBox.ItemTemplate>
</ListBox>

Сам листбокс у нас биндится к несуществующему пока свойству UserWallet (кошелек пользователя), а его Item показывают также несуществующие Name («5 рублей» или «2 рубля», к примеру), Amount и Icon (иконку купюры, если это купюра или монеты соответственно). Icon — просто заведомо неудачная попытка выполнить дополнительнй пункт 5 из ТЗ: «Красивый дизайн». Кстати, добавьте в проект эту пару картинок в Каталог решения (Solution folder) «Images». В свойствах укажите Build action: resource. «Coin.png» и «Banknote.png» соответственно.

Листбокс с покупками принципиально отличаться не будет (разве что иконки добавлять не будем)

UserView.xaml:

<!--Покупки-->
<DockPanel>
  <Label DockPanel.Dock="Top" Content="Корзина пользователя"/>
  <ListBox ItemsSource="{Binding UserBuyings}">
    <ListBox.ItemTemplate>
      <DataTemplate>
        <StackPanel Orientation="Horizontal">
          <Label Content="{Binding Name}"/>
          <Label FontWeight="DemiBold" Content="{Binding Price}"/>
          <Label Content="{Binding Amount}"/>
        </StackPanel>
      </DataTemplate>
    </ListBox.ItemTemplate>
  </ListBox>
</DockPanel>

Давайте обрамим это, как и положено, в два столбца Grid'а и UserControl. И добавим сумму наличности пользователя:

UserView.xaml:

<UserControl ...>
  <Grid>
    <Grid.ColumnDefinitions>
      <ColumnDefinition/>
      <ColumnDefinition/>
    </Grid.ColumnDefinitions>
    <!--Кошелек-->
    <DockPanel>
      <Label DockPanel.Dock="Top" Content="Наличность пользователя"/>
      <!--Сумма-->
      <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal">
          <Label Content="Итоговая сумма:"/>
          <Label Content="{Binding UserSumm}"/>
      </StackPanel>
      <!-- Монеты/купюры -->
      <ListBox ... />
    </DockPanel>
    <!--Покупки-->
    <DockPanel Grid.Row="0" Grid.Column="1" .../>
  </Grid>
</UserControl>

Так, пользователь готов. Теперь приступим к реализации интерфейса для автомата. По ТЗ необходимо показывать деньгохранилище и возможные покупки — т.е. также, как и у пользователя. Следовательно попробуем вырезать эти DataTemplate'ы из файла UserView.xaml для переиспользования. Эти DataTemplate'ы можно выложить отдельными файлами и использовать как Merged Resource Dictionary, но мы просто рапсположим их в ресурсах в главном View.

MainView.xaml:

<Window ...>
<Window.Resources>
    <!-- Шаблон данных для продуктов в корзине/в наличии -->
    <!-- Обратите внимание на аттрибут DataType (о нём ниже) -->
    <DataTemplate DataType="{x:Type local:ProductVM}">
      <StackPanel Orientation="Horizontal">
        <Label Content="{Binding Name}"/>
        <Label FontWeight="DemiBold" Content="{Binding Price}"/>
        <Label Content="{Binding Amount}"/>
      </StackPanel>
    </DataTemplate>
    <!-- Шаблон данных для денег в кошельке/деньгохранилище -->
    <DataTemplate DataType="{x:Type local:MoneyVM}">
      <StackPanel Orientation="Horizontal">
        <Image Width="32" Height="32" Source="{Binding Icon}"></Image>
        <Label Content="{Binding Name}"/>
        <Label Content="{Binding Amount}"/>
      </StackPanel>
    </DataTemplate>
  </Window.Resources>
  <!-- Можно сразу подключить (и создать) нашу VM - MainViewVM.cs -->
  <Window.DataContext>
      <local:MainViewVM/>
  </Window.DataContext>
  <!-- Грид с двумя колонками, слева интерфейс пользователя, справа - интерфейс автомата (пока пустой)  -->
  <!-- В качестве DataContext и тот и другой будут использовать DataContext этого окна  -->
  <Grid>
    <Grid.ColumnDefinitions>
      <ColumnDefinition/>
      <ColumnDefinition/>
    </Grid.ColumnDefinitions>
    <local:UserView Margin="10" />
    <local:AutomatView Grid.Column="1" Margin="10"/>
  </Grid>
</Window>

Обратите внимание на DataType в DataTemplate'тах. Это такая хитрая штука в WPF, делающая следующее: когда в качестве контента какого-нибудь элемента (в данном случае ListBoxItem) назначается объект указанного типа (в данном случае ProductVM или MoneyVM), тогда этот объект становиться DataContext'ом этого элемента, а в качестве контента выступает этот шаблон. ProductVM или MoneyVM — это VM для этих шаблонов, которые мы пока еще не создали. Можно создать пока все три VM:

Файл MainViewVM.cs:

public class MainViewVM : BindableBase { }
public class ProductVM { }
public class MoneyVM { }

Да, подключите Prism (6.3.0, семерка под Wpf пока не работает) и отнаследуйте MainViewVM от BindableBase.

Т.е. еще раз, что проиходит: ListBox в качестве ItemsSource использует List например. Для каждого элемента в этом листе создается ListBoxItem и его содержимому присваивается этот объект типа ProductVM. WPF видит, что у него есть DataTemplate для типа ProductVM, и этот DataTemplate присваивает в качестве содержимого для этого ListBoxItem, а сам объект ProductVM используется в качестве DataContext и к нему осуществяется Binding. Если в качестве ItemsSource ListBox'a использовать массив, где лежат не только ProductVM, но еще и MoneyVM (если оба отнаследованны от общего базового класса, например BindableBase), то и DataTemplate'ы будут к ним применены разные!

Осталось реализовать AutomatView.xaml.

AutomatView.xaml:

<UserControl ...>
  <Grid>
    <Grid.ColumnDefinitions>
      <ColumnDefinition/>
      <ColumnDefinition/>
    </Grid.ColumnDefinitions>
    <!--Монетоприемник-->
    <DockPanel Grid.Row="0" Grid.Column="1">
      <Label DockPanel.Dock="Top" Content="Монетоприемник"/>
      <!--Кредит-->
      <StackPanel Orientation="Horizontal" DockPanel.Dock="Bottom">
        <Label Content="Кредит:"/>
        <Label Content="{Binding Credit}"/>
      </StackPanel>
      <!--Деньгохранилище-->
      <ListBox ItemsSource="{Binding AutomataBank}" />
    </DockPanel>
    <!--Товары автомата-->
    <DockPanel Grid.Row="0" Grid.Column="0">
      <Label DockPanel.Dock="Top" Content="Товары"/>
      <ListBox ItemsSource="{Binding ProductsInAutomata}"/>
    </DockPanel>
  </Grid>
</UserControl>

Читаем ТЗ далее: программа должна позволять… вносить деньги в автомат, совершать покупки и получать сдачу.

Можно рядом с каждым продуктом в ListBox'e «Товары автомата» приделать кнопочку, по нажатию на которую будет совершаться покупка.

Точно также в монетоприемнике, в ListBox'е «Деньгохранилище» можно к каждой купюре/монете приделать кнопку, по которой пользователь будет вносить деньги в автомат.

Чтобы эти кнопки не отображались в части интерфейса связанной с пользовтелем, надо задать необходимые свойства «Show...».

А рядом с текстовым полем, обозначающем кредит, можно создать кнопку «Вернуть сдачу».

Внесем необходимые изменения:

<!-- Шаблон данных для продуктов в корзине/в наличии -->
<DataTemplate DataType="{x:Type local:ProductVM}">
  <StackPanel Orientation="Horizontal">
    <Button Visibility="{Binding IsBuyVisible}" Command="{Binding BuyCommand}">+</Button>
...
<!-- Шаблон данных для денег в кошельке/деньгохранилище -->
<DataTemplate DataType="{x:Type local:MoneyVM}">
  <StackPanel Orientation="Horizontal">
    <Button Visibility="{Binding IsInsertVisible}" Command="{Binding InsertCommand}">+</Button>
...
<!--Кредит-->
  <StackPanel Orientation="Horizontal" DockPanel.Dock="Bottom">
      <Button Command="{Binding GetChange}" Margin="5">Вернуть сдачу</Button>
...

Все, этап №1 в целом окончен. Переходим к созданию VM.

Создание ViewModels


Мы уже создали (создайте, если еще не) классы MainViewVM, ProductVM и MoneyVM.
Если у вас есть ReSharper, и если вы добавите в файлы UserView.xaml и AutomatView.xaml в верхний грид такую строчку:

<Grid d:DataContext="{d:DesignInstance {x:Type local:MainViewVM}}">

которая укажет WPF редактору тип DataContext (но на runtime это не скажется никак), то через Alt+Enter можно добавить соответствующие поля в классы VM. Если ReSharper'a у вас нет, можно сделать это руками:

public class MainViewVM : BindableBase {
  public int UserSumm { get; }
  public ObservableCollection<MoneyVM> UserWallet { get; }
  public ObservableCollection<ProductVM> UserBuyings { get; }
  public DelegateCommand GetChange { get; }
  public int Credit { get; }
  public ReadOnlyObservableCollection<MoneyVM> AutomataBank { get; }
  public ReadOnlyObservableCollection<ProductVM> ProductsInAutomata { get; }
}
public class ProductVM {
  public Visibility IsBuyVisible { get; }
  public DelegateCommand BuyCommand { get; }
  public string Name { get; }
  public string Price { get; }
  public int Amount { get; }
}
public class MoneyVM {
  public Visibility IsInsertVisible { get; }
  public DelegateCommand InsertCommand { get; }
  public string Icon { get; }
  public string Name { get; }
  public int Amount { get; }
}

Видите, у нас почти автоматом создались три VM. Теперь можно их реализовывать последовательно, свойство за свойством. Мы вольны писать такой клиентский код, который бы нам хотелось чтобы был. Например: UserSumm => _user.UserSumm; Т.е. подразумеваеться, что есть некоторый объект _user класса модели User, у которого есть свойство UserSumm. Давайте даже создадим такой класс. У нас будет теперь появляться модель, а вернее — точки соприкосновения модели и VM, которые сформируют некоторые внешние границы модели.
Только теперь небольшое отступление.

В ТЗ указано, что мы должны обеспечивать "… минимально необходимый доступ к полям и свойствам классов модели" (из клиентского кода). Такое требование должно быть не только в этом ТЗ, а вообще занимать почетное место в принципах вашего software-строения. Клиентский код не должен случайно (или умышленно) вторгаться в модель, заставляя ее приходить в незапланированное состояние. Тем более, что вы сейчас разрабатываете код, связанный с анонимными денежными операциями. Представьте, что вы сейчас внесете в код ошибку, используя которую пользователи по всей стране выпьют бесплатно кофе на 6 млн рублей, и этот убыток через суд повесят на вас, вы будете принудительно работать на эту софтверную компанию и до конца жизни кодить на связке Delphi + 1C за печеньки.

В общем, модель наша будет состоять из нескольких классов. И надо сделать так, чтобы из одного класса 'A' модели можно было вызывать метод SomeMethod() другого класса 'B' модели, а из нашего клиентского кода этот метод B.SomeMethod() вызывать было бы нельзя.

Чтобы такого добиться, можно конечно сделать класс B внутренним приватным классом класса A, и реализовывать в нем интерфейс, который экспозить наружу… Но вообще для таких целей есть специально предусмотренное решение — модификатор доступа internal. Т.е. надо всего лишь выделить модель в отдельный проект в решении. Таким образом мы сможем воспользоваться модификатором internal, физически разнесем наш клиентский код от кода модели. Теперь эту отдельную модель можно легко использовать, например, в веб-решении.

Создадим проект библиотеку классов, назовем её VendingMachine.Model, добавим туда класс модели User и создадим у него свойство UserSumm

В MainViewVM объявим приватную переменную _user типа User и создадим ее в конструкторе:
Файл MainViewVM:

public class MainViewVM : BindableBase {
  public MainViewVM() {
    _user = new User();
  }
  public int UserSumm => _user.UserSumm;
  //...
  private User _user;
}

Следующим пунктом мы натыкаемся на деньги — UserWallet, который для нас коллекция MoneyVM. Читаем ТЗ: "… Номиналы монет/купюр не задаются жестко в коде".

Т.е. есть некоторый набор номиналов (рубль, два, пять, десять), который откуда-то приходит (из базы данных, из конфигурационного файла, из веб-сервиса и т.д.). Нам необходимо, чтобы у пользователя и у автомата был один и тот же набор номиналов, и чтобы нельзя было вдруг создать некоторый свой номинал (монету в 8 рублей, например). Если надо запретить создавать — тут хорошо подойдет приватный конструктор. Если все же некоторый набор номиналов нужен — то подойдет фабричный метод, возващающий такой лист номиналов. Или, если не планируется многопоточность (а она не планируется), можно использовать статический список. Давайте так и сделаем.

//структура, а не класс, чтобы сравнение была сразу по значению, а не по ссылке
public struct Banknote {
  //представим, что список пришел из базы данных
  public static readonly IReadOnlyList<Banknote> Banknotes = new[] {
    new Banknote("Рубль", 1, true),
    new Banknote("Два рубля", 2, true),
    new Banknote("Пять рублей", 5, true),
    new Banknote("Десять рублей", 10, false),
    new Banknote("Пятьдесят рублей", 50, false),
    new Banknote("Сто рублей", 100, false),
  };
  private Banknote(string name, int nominal, bool isCoin) {
    Name = name;
    Nominal = nominal;
    IsCoin = isCoin;
  }
  public string Name { get; }
  public int Nominal { get; }
  public bool IsCoin { get; } //монета ли это. Нужно для красоты
}

Теперь второе. У нас есть номинал, но UserWallet у нас — это такой массив из пар номинал/количество. Как стопки фишек в казино: стопочка по $1, стопочка фишек по $250 и т.д. Нам нужна как раз такая стопочка (Stack):

public class MoneyStack {
  public MoneyStack(Banknote banknote, int amount) {
      Banknote = banknote;
      Amount = amount;
  }
  public Banknote Banknote { get; }
  public int Amount { get; }
}

Мы могли использовать структуры типа Dictionary, но чутье программиста подсказывает нам, что понадобятся функции типа уменьшить количество, увеличить и т.д. Сейчас мы их не добавляем, т.к. нет клиентского кода, их вызывающего. Пока такого кода нет, мы эти функции не добавляем. Это как с событиями — мы не добавляем в разрабатываемый нами контрол события (например двойного клика мышкой), пока нет обработчика для него. Иначе мы можем создать десятки событий, из которых нам понадобятся два-три. Так же и с классом: мы можем создать много различных внешних функций, из которых нам понадобятся лишь пара, и то — не с той сигнатурой.

Теперь, соответственно, обновим класс User и добавим туда UserWallet. Как и положено, UserWallet будет ReadOnlyObservableCollection и для обеспечения это коллекции будет другая — приватная коллекция. Кроме того, по ТЗ пользователю положено при инициализации выдать по 10 купюр каждого достоинства. Сделаем это в конструкторе пользователя.

User.cs:

public class User {
  public User() {
    //кошелек пользователя
    _userWallet = new ObservableCollection<MoneyStack>
       (Banknote.Banknotes.Select(b => new MoneyStack(b, 10)));
    UserWallet = new ReadOnlyObservableCollection<MoneyStack>(_userWallet);
  }
  public ReadOnlyObservableCollection<MoneyStack> UserWallet { get; }
  private readonly ObservableCollection<MoneyStack> _userWallet;
...
}

Теперь обновим конструктор MainViewVM. Т.к. в User у нас коллекция объектов класса модели MoneyStack, а в MainViewVK коллекция классов VM — MoneyVM, то мы должны сделать некоторые преобразования. В MoneyVM создать конструктор, принимающий MoneyStack.

Затем сначала при инициализации, а потом при изменении коллекции мы должны добавлять соответствующую VM (изменения модели единичные, поэтому конструкция a.NewItems?.Count == 1 — работает):

public MainViewVM() {
  _user = new User();
//преобразовать коллекцию в конструкторе
  UserWallet = new ObservableCollection<MoneyVM>(_user.UserWallet.Select(ms => new MoneyVM(ms)));
//преобразовывать каждый добавленный или удаленный элемент из модели
  ((INotifyCollectionChanged) _user.UserWallet).CollectionChanged += (s, a) =>  {
    if(a.NewItems?.Count == 1) UserWallet.Add(new MoneyVM(a.NewItems[0] as MoneyStack));
    if (a.OldItems?.Count == 1) UserWallet.Remove(UserWallet.First(mv => mv.MoneyStack == a.OldItems[0]));
  };
}

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

public class MoneyVM {
  public MoneyStack MoneyStack { get; }
  public MoneyVM(MoneyStack moneyStack) {
      MoneyStack = moneyStack;
  }
  public Visibility IsInsertVisible => InsertCommand == null ? Visibility.Collapsed : Visibility.Visible;
  public DelegateCommand InsertCommand { get; }
  public string Icon => MoneyStack.Banknote.IsCoin ? "..\\Images\\coin.jpg" : "..\\Images\\banknote.png";
  public string Name => MoneyStack.Banknote.Name;
  public int Amount => MoneyStack.Amount;
}

Продолжаем реализовывать MainViewVM. На очереди ObservableCollection UserBuyings.

UserBuyings реализуется очень похоже на предыдущую конструкцию. Также создаем класс модели Product с закрытым конструктором. Там точно так же создаем коллекцию доступных в программе продуктов (типа из базы данных). Точно так же создаем ProductStack. И точно также преобразовываем из ProductStack в ProductVM.

Product.cs:

public class Product {
  //представим, что список посредством web service
  public static IReadOnlyList<Product> Products = new List<Product>()   {
    new Product("Кофе",12),
    new Product("Кофе подороже", 25),
    new Product("Чай",6),
    new Product("Чипсы",23),
    new Product("Батончик",19),
    new Product("Нечто",670),
  };
  private Product(string name, int price)   {
    Name = name;
    Price = price;
  }
  public string Name { get; }
  public int Price { get; }
}

ProductStack.cs:
public class ProductStack {
  public ProductStack(Product product, int amount) {
    Product = product;
    Amount = amount;
  }
  public Product Product { get; }
  public int Amount { get; }
}

В классе User создаем примерно такую же ReadOnlyObservableCollection. Разве что в конструкторе теперь не снабжаем пользователя всеми наименованиями товаров по 10 штук, т.к. это не задано в ТЗ:

public class User {
  public User() {
    ...
    //продукты пользователя
    UserBuyings = new ReadOnlyObservableCollection<ProductStack>(_userBuyings);
  }
  public ReadOnlyObservableCollection<ProductStack> UserBuyings { get; }
  private readonly ObservableCollection<ProductStack> _userBuyings = new ObservableCollection<ProductStack>();
  ...
}

Соответственным образом обновляем ProductVM:

public class ProductVM {
  public ProductStack ProductStack { get; }
  public ProductVM(ProductStack productStack) {
      ProductStack = productStack;
  }
  public Visibility IsBuyVisible => BuyCommand == null ? Visibility.Collapsed : Visibility.Visible;
  public DelegateCommand BuyCommand { get; }
  public string Name => ProductStack.Product.Name;
  public string Price => $"({ProductStack.Product.Price} руб.)";
  public Visibility IsAmountVisible => BuyCommand == null ? Visibility.Collapsed : Visibility.Visible;
  public int Amount => ProductStack.Amount;
}

И, наконец, конструктор в MainViewVM:

public MainViewVM() {
 ...
  //покупки пользователя
  UserBuyings = new ObservableCollection<ProductVM>(_user.UserBuyings.Select(ub => new ProductVM(ub)));
  ((INotifyCollectionChanged)_user.UserBuyings).CollectionChanged += (s, a) =>  {
      if (a.NewItems?.Count == 1) UserBuyings.Add(new ProductVM(a.NewItems[0] as ProductStack));
      if (a.OldItems?.Count == 1) UserBuyings.Remove(UserBuyings.First(ub => ub.ProductStack == a.OldItems[0]));
  };
}

Синхронизация модели и VM покупок пользователя — и модели и VM кошелька пользователя — практически идентичная. Мало того, на очереди такие же синхронизации в случае с деньгохранилищем и товарами внутри автомата. Поэтому, чтобы избежать дублирования кода, напишем такую функцию синхронизации:

private static void Watch<T, T2>
  (ReadOnlyObservableCollection<T> collToWatch, ObservableCollection<T2> collToUpdate, Func<T2, object> modelProperty) {
    ((INotifyCollectionChanged)collToWatch).CollectionChanged += (s, a) =>  {
      if (a.NewItems?.Count == 1) collToUpdate.Add((T2)Activator.CreateInstance(typeof(T2), (T) a.NewItems[0]));
      if (a.OldItems?.Count == 1) collToUpdate.Remove(collToUpdate.First(mv => modelProperty(mv) == a.OldItems[0]));
    };
  }

И будем использовать ее в конструкторе следующим образом:

Watch(_user.UserWallet, UserWallet, um => um.MoneyStack);
Watch(_user.UserBuyings, UserBuyings, ub => ub.ProductStack);

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

Далее: в классе MainViewVM, который мы последовательно реализуем, остались еще нереализованными свойства и команды, относящиеся к автомату. Давайте их реализуем, благо дело сейчас пойдет быстрее, т.к. для денег и продуктов модели мы уже создали. Как и в случае с классом User мы создадим класс модели Automata и в нем такие же две коллекции для продуктов и денег. Также реализуем в нем свойство Credit.

public class Automata  {
  public Automata()   {
    //деньгохранилище автомата
    _automataBank = new ObservableCollection<MoneyStack>
        (Banknote.Banknotes.Select(b => new MoneyStack(b, 100)));
    AutomataBank = new ReadOnlyObservableCollection<MoneyStack>(_automataBank);
    //продукты автомата
    _productsInAutomata =
        new ObservableCollection<ProductStack>(Product.Products.Select(p => new ProductStack(p, 100)));
    ProductsInAutomata = new ReadOnlyObservableCollection<ProductStack>(_productsInAutomata);
  }

  public ReadOnlyObservableCollection<MoneyStack> AutomataBank { get; }
  private readonly ObservableCollection<MoneyStack> _automataBank;
  public ReadOnlyObservableCollection<ProductStack> ProductsInAutomata { get; }
  private readonly ObservableCollection<ProductStack> _productsInAutomata;
  public int Credit { get; }
}

Соответственно в классе MainViewVM добавим приватное поле класса Automata и инициализируем в конструкторе коллекции:

public class MainViewVM : BindableBase {
  public MainViewVM() {
  ...
    _automata = new Automata();
    //деньги автомата
    AutomataBank = new ObservableCollection<MoneyVM>(_automata.AutomataBank.Select(a => new MoneyVM(a)));
    Watch(_automata.AutomataBank, AutomataBank, a => a.MoneyStack);
    //товары автомата
    ProductsInAutomata = new ObservableCollection<ProductVM>(_automata.ProductsInAutomata.Select(ap => new ProductVM(ap)));
    Watch(_automata.ProductsInAutomata, ProductsInAutomata, p => p.ProductStack);
  }
  ...
  private Automata _automata;
}

Поведение модели


Теперь нам осталось только реализовать поведение модели при вносе наличности, покупке и требовании сдачи.

Как видим, до настоящего момента мы практически не принимали проектных решений. Наша программа с неизбежностью произростала из разработанного нами интерфейса пользователя и нужд MVVM. В разработке интерфейса мы делали творческое усилие, а VM у нас получиласть практически автоматом. Заодно появились точки соприкосновения VM с моделью, которые стали опорными точками для дальнейшего построения модели. Такой подход (View first) может применяться и в реальных проектах, после того, как будет сделан эскиз или каркас разрабатываемого модуля, так сказать, широкими мазками.

Сейчас, для того, чтобы продолжить разработку, нам необходимо объединить пользователя и автомат в рамках одной сущности. Это объединение обычно диктуется этим предварительным эскизом. Мы же, в нашем случае — просто создадим жесткое соединение одного произвольного пользователя и одного автомата в рамках объекта класса, например, PurchaseManager. Соответственно, объекту именно этого класса мы будем адресовать наши, еще не реализованные, запросы на поведение модели.

Почему мы не можем остаться в рамках классов User и Automata? В принципе, конечно, можно. Смотрите: нам необходима возможность взять у пользователя некоторую сумму денег и внести эту сумму денег в автомат. Такую операцию в VM мы совершить не можем, т.к. это допустимо только в модели. Т.е. эту операцию должен осуществлять или класс User или класс Automata. Согласно принципу разделения Single responsibility, эту ответственность должна быть возложена на третий класс, осуществляющий их взаимодействие. Поэтому создадим класс PurchaseManager и отредактируем наш MainViewVM.cs на использование этого класса, вместо самостоятельного создания User и Automat.

PurchaseManager.cs:
public class PurchaseManager {
 public User User { get; } = new User();
 public Automata Automata { get; } = new Automata();
}
MainView.cs:
public class MainViewVM : BindableBase {
  private PurchaseManager _manager;
  public MainViewVM() {
    _manager = new PurchaseManager();
    _user = _manager.User;
    _automata = _manager.Automata;
    ...
  }
  ...
}

Теперь, внесение денег у нас осуществляется по нажатию на кнопку и последующему вызову DelegateCommand InsertCommand вьюмодели MoneyVM. Есть разные способы пробросить такую коммуникацию между VM и моделью. Можно передавать DelegateCommand в конструкторе VM. Можно передавать целиком модель(PurchaseManager), это вообще самый универсальный способ и мы можем делать это вполне безопасно, — устройство модели, благодаря инкапсуляции, нам это вполне позволяет. Внесем соответствующие правки:

Конструктор MoneyVM:

public MoneyVM(MoneyStack moneyStack, PurchaseManager manager = null) {
  MoneyStack = moneyStack;
  if (manager != null) //по умолчанию Null, если же нет, то тогда задаем DelegateCommand
    InsertCommand = new DelegateCommand(()=>{
      manager.InsertMoney(MoneyStack.Banknote);
    });
}

Соответственно поменяем и вызов функции Watch, что бы передавать модель в конструкторе класса MainViewVM. Но только для пользователя, для автомата такое делать не надо. (Хотя ничего страшного не случиться, даже появится незапланированная возможность внесения денег и в правой и в левой сторонах интерфейса).

Теперь необходимо реализовать функцию InsertMoney. Она должна извлечь из пользователя определенную банкноту, и, в случае успеха, занести ее в автомат. Функция извлечения банкноты из пользователя должна быть доступна из модели, но при этом недоступна из клиентского кода — в этом нам поможет уже упоминавшийся модификатор доступа internal.

PurchaseManager.cs:

public void InsertMoney(Banknote banknote) {
  if (User.GetBanknote(banknote))   //если у пользователя такую купюру получили,
      Automata.InsertBanknote(banknote);  //то сунуть ее в автомат
}

User.cs:

//если такой MoneyStack в наличии, то попробовать вытащить из него одну купюру/монету
//вернуть false в случае неудачи
internal bool GetBanknote(Banknote banknote) {
  if(_userWallet.FirstOrDefault(ms => ms.Banknote.Equals(banknote))?.PullOne() ?? false) {
    RaisePropertyChanged(nameof(UserSumm)); //обновилась сумма наличности пользователя!
    return true;
  }
  return false;
}
//сумма наличности пользователя
public int UserSumm { get { return
  _userWallet.Select(b => b.Banknote.Nominal * b.Amount).Sum(); } }

MoneyStack.cs:

internal bool PullOne() {
  if (Amount > 0) { --Amount;
    return true; }
  return false;
}

Automata.cs:

//поместить купюру в отделение для соответственной купюры
internal void InsertBanknote(Banknote banknote)    {
  _automataBank.First(ms => ms.Banknote.Equals(banknote)).PushOne();
  Credit += banknote.Nominal;
}
//кредит
private int credit;
public int Credit { get { return credit; }
    set  { SetProperty(ref credit, value); }}

и опять MoneyStack.cs:

internal void PushOne() => ++Amount;


Теперь надо вызывать INotifyPropertyChanged для уведомления View.

Соответственно MoneyStack наследуется от BindableBase и Amount делает уведомление:

public class MoneyStack : BindableBase {
...
  private int _amount;
  public int Amount {
    get { return _amount; }
    set { SetProperty(ref _amount, value); }
  }
}

и MoneyVM также наследуется от BindableBase и это уведомление пробрасывается — конструктор MoneyVM:

...
moneyStack.PropertyChanged += (s, a) => { RaisePropertyChanged(nameof(Amount)); };

не забудем получать уведомления и от изменения свойств UserSumm и Credit в конструкторе MainViewVM:

_user.PropertyChanged += (s, a) => { RaisePropertyChanged(nameof(UserSumm)); };
_automata.PropertyChanged += (s, a) => { RaisePropertyChanged(nameof(Credit)); };

Мы можем теперь уверенно вставлять купюры и монеты в купюро/монето- приемник! И даже увеличивается кредит в автомате. Давайте уже что-нибудь купим. Покупка будет осуществляться по точно такому же принципу, как и вставка купюр. Тоже будем в ProductVM передавать нашу модель и вызывать у нее соответственные методы.

конструктор ProductVM:

public ProductVM(ProductStack productStack, PurchaseManager manager = null)
{
  ProductStack = productStack;
  productStack.PropertyChanged
    += (s, a) => { RaisePropertyChanged(nameof(Amount)); };

  if (manager != null)
    BuyCommand = new DelegateCommand(() => {
        manager.BuyProduct(ProductStack.Product);
    });
}

PurchaseManager.cs:

public void BuyProduct(Product product) {
    if (Automata.BuyProduct(product))
        User.AddProduct(product);
}

Automata.cs:

internal bool BuyProduct(Product product) {
    if(Credit >= product.Price && _productsInAutomata.First(p=>p.Product.Equals(product)).PullOne()) {
        Credit -= product.Price;
        return true;
    }
    return false;
}

User.cs:

internal void AddProduct(Product product) {
   var stack = _userBuyings.FirstOrDefault(b => b.Product == product);
   if (stack == null)
       _userBuyings.Add(new ProductStack(product, 1));
   else
       stack.PushOne();
}

ProductStack.cs:

public int Amount {
    get { return _amount; }
    set { SetProperty(ref _amount, value); }
}

internal bool PullOne() {
    if (Amount > 0) {
        --Amount;
        return true;
    }
    return false;
}
internal void PushOne() => ++Amount;

INotifyPropertyChanged уведомления View в конструкторе ProductVM:

...
productStack.PropertyChanged += (s, a) => { RaisePropertyChanged(nameof(Amount)); };

Последняя функциональность — получение сдачи


Алгоритм очень простой — необходимо посмотреть, есть ли у автомата достаточно денег для сдачи, и если да — собрать набор купюр (сначала крупные, потом все мельче) и передать пользователю.

class PurchaseManager {
  ...
  public void GetChange()  {
    IEnumerable<MoneyStack> change;
    if (Automata.GetChange(out change))
        User.AppendMoney(change);
  }
}

//класс Automata
internal bool GetChange(out IEnumerable<MoneyStack> change) {
    change = new List<MoneyStack>();
    if (Credit == 0) return false;

    var creditToReturn = Credit;
    var toReturn = new List<MoneyStack>();
    foreach (var ms in _automataBank.OrderByDescending(m => m.Banknote.Nominal)) {
        if (creditToReturn >= ms.Banknote.Nominal) {
            toReturn.Add(new MoneyStack(ms.Banknote, creditToReturn / ms.Banknote.Nominal));
            creditToReturn -= (creditToReturn / ms.Banknote.Nominal) * ms.Banknote.Nominal;
        }
    }
    if (creditToReturn != 0) return false; //денег не набирается, ничего не возвращаем

    foreach (var ms in toReturn) //возвращаем
        for (int i = 0; i < ms.Amount; ++i)  //по одной монетке правда
        _automataBank.First(m => Equals(m.Banknote, ms.Banknote)).PullOne();
    change = toReturn;
    Credit = 0;
    return true;
}

//класс User
internal void AppendMoney(IEnumerable<MoneyStack> change)
{
  foreach (var ms in change)
    for(int i=0; i<ms.Amount;++i)
      UserWallet.First(m => Equals(m.Banknote.Nominal, ms.Banknote.Nominal)).PushOne();
  RaisePropertyChanged(nameof(UserSumm));
}

Все. Окончательный вариант можно взять отсюда: Vending Machine (программный код)
Проголосовать:
+8
Сохранить: