![image](https://habrastorage.org/webt/59/d7/bb/59d7bb46ad379168242665.jpeg)
В этой статье в качестве примера у нас будет программа чуть посложнее, а именно — торговый автомат, реализация которого часто встречается в качестве тестового задания до собеседования. Будут рассмотрены взаимодействие нескольких View с одним VM и наоборот, будет показан подход «View first» и будет показан не итоговый код, с рассказом какая часть для чего нужна (ссылка для скачивания кстати Vending Machine (программный код), а будет продемонстрирован весь процесс создания и, самое главное, последовательный ход мысли.
Но перед этим я постараюсь еще раз ответить на вопрос, который обычно не задают люди, имеющие опыт отладки неструктурированных проектов, а именно: «Так зачем все-таки нужен паттерн MVVM?»
Если формально и коротко, то паттерн MVVM используется в первую очередь для разделения ответственности, для повышения читабельности, управляемости, поддерживаемости и тестируемости кода. Программный продукт состоит из модели (доменной модели и бизнес-логики) и инфраструктурного кода в соотношении, допустим, 20% на 80%. Инфраструктурный код должен быть простым, понятным, чуть ли не автоматным — как Scaffolding. А вот модель…
хорошо иметь не равномерно распределенную по инфраструктурному коду, а сгруппированную в одном месте. Тогда она читабельная — т.е. видна бизнес логика процессов предметной области, не размазанная по обработчикам событий кучи контролов вьюшек, пылящихся в разных местах. Она управляема — т.е. я могу, например, изменением модификатора доступа в одном месте — запретить клиентскому коду изменять определенный коэффициент во всей программе. Она поддерживаема и расширяема, — т.е. можно легко исправлять программу, согласно новым требованиям, и вносить новую функциональность. А повышенная тестируемость позволяет нам покрыть модель юнит- и интеграционными тестами, чтобы, когда мы эту самую новую функциональность вносили, у нас не отвалилась функциональность старая. А если и отвалилась, то заметили бы мы это сразу, а не на приемке у заказчика. Люди, проводившие за отладкой за три дня перед дедлайном долгие задумчивые вечера, не задают вопрос о пользе всего вышеперечисленного.
Конкретно MVVM, а не, скажеи MVP или MVC, в WPF используется потому, что MVVM «аппаратно» поддерживается WPF. View понимает и INotifyPropertyChange, и Observable и др. — не надо ничего руками обновлять через презентер и т.д. Например MVP в WinForm's требовал больше инфраструктурного кода, причем ручного, и там преимущества разделения ответственности омрачалось бОльшим объемом черновой работы.
Задача
Вернемся к задаче. У неё вот такая формулировка: создать программу, эмулирующую взаимодействие человека с автоматом по продаже снеков/напитков.
Интерфейс программы должен отображать:
- Содержимое бумажника пользователя (изначально по 10 купюр/монет одного номинала) и его покупки.
- Содержимое деньгохранилища автомата (изначально по 100 купюр/монет одного номинала)
- Список возможных для покупки в автомате продуктов (в автомате изначально по 100 единиц каждого наименования)
- Текущий кредит в автомате (то, сколько денег туда вложил пользователь)
Интерфейс программы должен позволять:
- Внесение денежные средств пользователем в автомат
- Совершение покупок продуктов в автомате
- Требовать и получать, иногда, сдачу
Плюсом будет:
- Список товаров с ценами не задается жестко в коде
- Номиналы монет/купюр не задаются жестко в коде
- Соблюдение формального паттерна MVVM
- Настройка минимального доступа к полям и свойствам классов модели
- Красивый дизайнъ!
Последнее мы с вами сделаем вряд ли, а вот всё остальное — вполне.
В первой части мы использовали методику «Model first»:
- Разработать модель программы.
- Нарисовать интерфейс программы.
- Соединить интерфейс и модель прослойкой VM.
Особенность такого подхода состоит в том, что мы должны заранее четко представлять модель, ее возможности. То, какие свойства и методы она будет предоставлять наружу, как будет устроено ее взаимодействие с интерфейсом. Но на первом этапе разработки мы даже не знаем, нужно ли будет то или иное взаимодействие. Нам нужны дополнительные точки опоры, в дополнение к описанному поведению в ТЗ. Такие точки опоры может нам предоставить интерфейс, т.е. View и VM к нему. В VM мы могли бы сформулировать клиентский код, т.е. тот код общего доступа (public), который мы бы хотели видеть в модели. Т.е. методика такая:
Методика «View first»:
- Нарисовать интерфейс программы — View
- Разработать VM к этим View, и сформировать клиентский код (код вызова модели)
- Имея интерфейс взаимодействия модели, реализовать её структуру и внутреннюю логику
Утром скетчи, вечером модель.
В создании интерфейса пользователя мало 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 (программный код)