
В этой статье в качестве примера у нас будет программа чуть посложнее, а именно — торговый автомат, реализация которого часто встречается в качестве тестового задания до собеседования. Будут рассмотрены взаимодействие нескольких 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 (программный код)
