Иерархические модели, вложенные модели, коллекции моделей, правильность понимания паттерна

Ja_Dim

Здравствуйте.

Задался целью обучиться использованию паттерна MVVM. Стал разбираться с материалами, примерами, в первую очередь с простыми проектами (1 Модель, 1 Представление, 1 Модель представление), тут несколько запутался.

Вопрос №1. Верные подходы реализации приложения с помощью паттерна MVVM (1 модель, 1 представление, 1 модель представление)

Верно ли я понимаю, что в Модели я просто определяю её структуру, грубо говоря, создаю класс и наполняю, предположим, несколькими свойствами (int, string и т.п.), например:

Person.cs

class Person { public String Name { get; set; } public Int32 Age { get; set; } }

Далее создаю Модель представление, применяю интерфес INotifyPropertyChanged (реализация части работы с которым была помещенна в отдельный класс - PropertyChangedNotification, от которого мы и наследуемся) и в нем отпределяю те же самые свойства, но также указываю, что об их изменениях свойств следует уведомлять Представление, а так же в конструкторе Модели представления в качестве входного параметра задаю объект модели, в данном случае это Person, продолжаем пример:

PersonViewModel.cs

class PersonViewModel : PropertyChangedNotification { public String Name { get { return GetValue(() => Name ); } set { SetValue(() => Name , value); } } public Int32 Age { get { return GetValue(() => Age ); } set { SetValue(() => Age , value); } } public PersonViewModel(Person person) { Name = person.Name; Age = person.Age; } }

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

PersonView.xaml

<usercontrol ...="" xmlns:views="clr-namespace:TestMVVM.Views"> <grid> <grid.rowdefinitions> <rowdefinition height="25"> <rowdefinition height="25"> </rowdefinition></rowdefinition></grid.rowdefinitions> <grid.columndefinitions> <columndefinition width="Auto"> <columndefinition width="250"> </columndefinition></columndefinition></grid.columndefinitions> <!-- Text blocks--> <textblock text="Name: " grid.column="0" grid.row="0"> <textblock text="Age: " grid.column="0" grid.row="1"> <!-- TextBox with binding --> <textbox text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" grid.column="1" grid.row="0"> <textbox text="{Binding Age, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" grid.column="1" grid.row="1"> </textbox></textbox></textblock></textblock></grid>
</usercontrol>

Далее мы подключаем данный контрол в основном Представлении:

MainView.xaml

<window ...="" xmlns:views="clr-namespace:TestMVVM.Views" title="MainView" height="500" width="700"> <grid> <views:personview datacontext="{Binding}"> </views:personview></grid>
</window>

Ну и, естественно, запитываем DataContex нашего Представления, я делал это, прописав метод загрузки в CodeBehind App.xaml.cs, код метода:

private void OnStartup(object sender, StartupEventArgs e) { Person person = new Person(); person.Name = "Иван"; person.Age = 20; MainView view = new MainView(); PersonViewModel personViewModel = new PersonViewModel(person); view.DataContext = personViewModel; view.Show();
}

В итоге: в большинстве примеров описан именно такой подход, как мой пример, т.е.

Модель - просто класс со свойствами

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

Представление - шаблон данных или кастомный контрол, который также наполняется привязками (Binding) к тем самым свойствам из Модели представления.

Подобный подход используется, например, здесь:

Пример 1

Пример 2

Но зачастую я вижу примеры, в которых:

Модель - класс со свойствами, которые могут оповещать в случае их изменения, т.е. применен интерфейс INotifyPropertyChanged.

Модель представление - больше похоже на класс проводника, в нем создается объект модели, и вся работа идет с этим объектом через модель представление, что, как мне кажется, не совсем соответсвует концепии MVVM - все должно быть разделено.

Представление - шаблон данных или кастомный контрол, который также наполняется привязками (Binding) к тем самым свойствам из Модели, которая сидит в Модели представления. Вот те самые примеры:

Пример 1

Пример 2

Вопрос №2. Реализация проекта в рамках паттерна MVVM с использованием Иерархической модели, т.е. модели, которая состоит из свойств обычных типов данных, объектов и коллекций объектов)

В поисках решения данного вопроса я нашел следующие примеры:

Очень хороший пример с полным кодом и комментариями от автора.

Пример, который опять же дает понять, как реализовать подобное. Здесь, как мне показалось, крайне предпочтительно использовать выше описанный подход, когда Модель - это просто класс со свойствами, а Модель представление - класс, использующий те же свойства и разбирающий значения объекта модели в своем конструкторе, но с возможностью отслеживать изменения, т.е. Примеры 1 и 2.

Вопрос заключается скорее в том, что я применил данную структуру к своему проекту, вот сорцы:мой тестовый проект, но возникла проблема с ListBox и командами.

Как это представлял я:- имеется ListBox, в нем есть элементы (BoreViewModel они же - скважины), у каждого из элементов ListBox имеется 2 кнопки - редактировать и удалить, соотвественно по нажатии на одной из них должно выполняться одноименное действие.

В модели представления, скважины (BoreViewModel) прописал свойство - SelectedBore, в которое по команде EditBoreCommand редактирования передается BoreViewModel, в котором идет обращение к команде.

Во время отладки вижу, что по нажатии на кнопку в SelectedBore передается модель представления BoreViewModel, в которой и была выполнена команда.

По идее, здесь views:BoreInfoEditView DataContext="{Binding Bores.SelectedBore}" (MainView.xaml -> TabItem Header="Bore Info" ) связка написана верно, но не срабатывает, и в итоге во вкладке "Bore Info" никаких данных о редактируемой BoreViewModel нету, хотя во время отладки Bores.SelectedBore не пустое и содержит в себе BoreViewModel, которая была выбрана.

Отсюда же возникает вопрос, есть ли еще способы получить эту BoreViewModel в данном представлении, чтобы менять ее, и как быть с остальными вложенными коллекциями?

Изначально предполагал, что SelectedBore должна лежать в JournalViewModel и там забирать это модель представленем, да и с коллекцией проще работать, чтобы удалить BoreViewModel, можно просто воспользоваться методом remove в коллекции, а таким образом, как сейчас, я могу лишь свойства внутри объекта очистить, но не удалить сам обект из коллекции.

Если кто знает более простой способ реализации, пожалуйста, направьте в нужное русло...

Решил задать вопросы более конкретно, но предысторию все же оставлю.

  1. Как правильно? Модель без PropertyChanged, а в Модели представления часть свойст из Модели, а также PropertyChanged, и разбор входного объекта Модели или Модель с PropertyChanged, а в Модели представления использовать сам объект Модели как свойство и работать с ним.

  2. Есть ли другие архитектурные решения для иерархии классов, кроме как: Для каждой Модели должно быть Модель представление, и в случае если мы используем, например, в модели работников дома, в котором есть свойство ObservableCollection<Person>, то в модели представления для этой модели работников свойста должно быть - ObservableCollection<PersonViewModel>, как в этих примерах: Пример 1 Пример 2

  3. Я работаю с проектом, в котором использую именно такую структуру, как описал в пункте 2, правильно ли это?

  4. Как правильно прибиндить к представлению выбранный элемент коллекции посредством команды?

  5. Прошу, подскажите, как можно адекватно сделать тот самый Binding на команду редактирования и удаления BoreViewModel из коллекции, находящейся в JournalViewModel, то бишь ObservableCollection<BoreViewModel>. Пытался ставить SelectedBore в JournalViewModel, а также команды на изменение, но по нажатии кнопки ничего не происходит, ибо ListBox забинден от коллекции Bores и это же прописано в xaml? Отсюда и проблемы с биндингом на команду, которая сидит в другой модели представления.

  6. К сожалению, придется задать ещё один вопрос. Как я понял, внутренняя команда EditCommand из BoreViewModel, "протаскивается" во внешнюю JournalViewModel, которая забиндена на DataContex самого MainView.xaml. Это и дает возможность получить объект BoreViewModel, и допустим кинуть его на внешнее свойство для того чтобы "подпитать" вкладку BoreInfo. Но при этом мы используем еще один параметр в конструкторе BoreViewModel. Ну раз мы применили такой подход, то вывод - он не выходит из концепции MVVM, но выходит за рамки удобства. Учитывая что вложений подобного рода у меня минимум 3 картинка структуры, то и параметры для еще двух вложений, приходится тащить через конструкторы каждого ViewModel. В итоге единственно более/менее подходящее решение этого вопроса - создать класс в котором описать все вложения в качестве свойств, а далее передавать его через конструкторы и "запитываться" им, т.е.

    class TracertCommands
    { public ICommand EditBoreCommand { get; set; } public ICommand RemoveBoreCommand { get; set; } public ICommand EditLayerCommand { get; set; } public ICommand RemoveLayerCommand { get; set; } public ICommand RemoveSampleCommand { get; set; }
    }

    и использовать объект этого класса в JournalViewModel , заполнив значениями типа:

    public void JournalViewModel
    { TracertCommands tracCom = new TracertCommands { EditBoreCommand = new RelayCommand(e => ActivateBore((BoreViewModel) e)), RemoveBoreCommand = new RelayCommand(r => RemoveBore((BoreViewModel) r)), ... };
    }

    Но все равно возникает вопрос, а есть ли другие решения, более лаконичные? Есть вариант вложенных TabControl, но он неудобен еще больше, если других вариантов кроме как имеющегося нет, значит нет, но все равно интересно

Переделанный проект

2 ответа

Ja_Dim

1) По поводу смысла MVVM. Ваше описание («в модели определяется структура, а в VM она копируется и добавляется реализация INPC») показывает то, как в некоторых случаях определяется M и VM, но не говорит самое главное — почему. Дело в том, что механизм («как») может меняться от реализации к реализации, но идея («почему») остаётся та же.

Итак: модель есть полноценный объект/набор объектов, которые отображают («моделируют», отсюда и название) объекты внешнего мира различной степени абстракции. Пользователь системы, мотор автомобиля, план длинного вычисления, камера, снимающая видео, самолёт условного противника — всё это объекты.

Важно, что это полноценные объекты, которые живут сами по себе: самолёт летит сам, вычисление идёт само, камера снимает сама. Эти же объекты живут, то есть, сообщают нам о результатах своей жизнедеятельности. Самолёт меняет координаты и, если нужно, сообщает миру об изменениях. Для этого ему, возможно, придётся объявить event PositionChanged. Вычисление начинается и заканчивается, и обладает результатом. Для этого объект, представляющий собой вычисление, имеет смысл определить как Task<******>. Камера производит поток кадров (а значит, может выставить наружу свойство типа IObservable<Image>), и реализует интерфейс управления камерой (SetExposure, SetWhiteBalance, ...).

Модель пишется в отрыве от реализации MVVM, она должна лишь предоставлять возможности для управления собой, но никаких требований на реализацию модели не накладывается. Чаще всего, у вас и так есть модель (а обычно — много моделей), написанных другой командой (бэкэндщиками), которая не «заточена» под вашу программу, и написана довольно общим образом. Модель не знает ни о VM, ни тем более о View, и тем самым независима и может быть легко использована в другом проекте.

Теперь VM. Уровень VM — это уровень бизнес-логики (мне не нравится это слово, но оно устоялось) вашей программы. В отличие от модели, на неё накладываются требования. (Это во многом требования не MVVM, а скорее WPF.) Бизнес-логика бежит в одном потоке (модельные объекты могут бежать где им кажется правильным). Она должна описывать реальность не в таких терминах, как удобно модели, а в таких, которые интересны/важны для вашей конкретной программы. Например, модель может выставлять цвет в цветовых координатах HSV, а вашему приложению интересны координаты RGB. VM может группировать модельные объекты в один объект, добавлять новые свойства и убирать неважные (например, вместо объектов Engineer и Manager, на уровне VM может возникнуть объект EmployeeVM со свойством JobTitle).

Кроме того, VM должна выставлять свойства для View, причём таким образом, чтобы View было удобно с ними работать. Практически это означает реализацию INPC (или наследование от DependencyObject'а и реализацию свойств в виде dependency property). Также это означает, что нельзя выставлять наружу поля, и нельзя сообщать об изменениях через event'ы типа SpeedChanged. Это также может означать, например, фильтрацию чересчур часто приходящих сообщений от модели.

Поскольку в принципе каждому из модельных объектов необходимо «индивидуальное обращение», то часто (но далеко не всегда!) иерархии модельных и VM-объектов идут параллельно. Часто и правда VM-объекту удобно получать модельный объект в конструкторе. Но это не универсальное правило: например, VM-объект, отвечающий за соединение, может создавать модельный объект, когда реальное, физическое соединение нужно, и уничтожать его, когда необходимость в соединении отпадает.

Итак, к собственно вопросу:

Как правильно? Модель без PropertyChanged, а в Модели представления часть свойст из Модели а также PropertyChanged, и разбор входного объекта Модели или Модель с PropertyChanged, а в Модели представления использовать сам объект Модели как свойство и работать с ним.

Суммируя сказанное выше: модель ведёт себя так, как ей удобно. Если модель хочет предоставить наружу сведения об изменении своих свойств, она имеет право это делать наиболее удобным для неё образом, например, имплементируя INPC или как угодно иначе, например, требуя внешний поллинг (повторный опрос состояния время от времени или в «нужный» момент). Если модельный объект может сам в силу своей внутренней логики менять свои свойства, то лучше наверное сообщать об этом внешнему миру каким-то образом.

VM практически обязана выставлять наружу INPC (или равносильную возможность наблюдения за своими свойствами).

VM в принципе может выставить модельный объект как свойство, но лишь в том случае, если он удовлетворяет требованиям View: бежит в UI-потоке, реализует INPC или не изменяет свои свойства, выставляет данные в виде свойств, а не методов или открытых полей. (Даже в этом случае концептуально нехорошо, что View придётся знать о модели, а значит, подмена модели на другую затронет большую часть кода. Хотя братский паттерн MVC не заморачивается, и заставляет View читать данные именно из модельного объекта.) В случае сколько-нибудь сложной/нестандартной модели выставление её в качестве свойства VM-объекта не прокатит.

Создаёт ли VM-объект модельный объект или нет — это решать вам и только вам, паттерн MVVM этого не диктует. Делайте так, как считаете правильно в каждом конкретном случае; если нужно, делегируйте полномочия создания объектов тому, кто должен быть за это ответственен.

Короче: Модель делает как хочет, а VM старается упростить себе жизнь, если получается.

Возникает закономерный вопрос: а не ведёт ли это к дублированию кода в случае простого приложения, в котором не нужна повторно используемая модель? Ответ на это таков: если ваше приложение простое, и модель есть лишь повторение VM, возможно, ваше приложение ещё не доросло до «полного» паттерна MVVM, и вы можете отказаться от модели, реализовав модельную логику в VM. Но вы должны записать это себе в technical debt. Как только ваш VM-объект станет достаточно сложен, вам скорее всего придётся-таки отделить его в отдельную модель, чтобы инкапсулировать ненужные на VM-уровне детали.

Небольшое замечание сбоку: мне не сильно нравится паттерн наподобие

public string Name
{ get { return GetValue(() => Name ); } set { SetValue(() => Name , value); }
}

Основная его проблема в том, что синтаксис не соответствует семантике. SetValue логически никак не должно получать лямбду, возвращающую значение параметра. Не говоря уже о том, что «распаковка» лямбды — дорогая операция, и о том, что с лямбдой вы теряете проверку ошибок времени компиляции (попробуйте передать, например, () => "nonsence", и убедитесь, что компилятор это проглотит). На мой вкус, вот такой паттерн гораздо прямее, лучше и честнее:

string name;
public string Name
{ get { return name; } set { if (name != value) { name = value; RaisePropertyChanged(); } }
}
// в базовом классе
protected void RaisePropertyChanged([CallerMemberName] string propertyName = "")
{ // поскольку код VM однопоточный, можно обойтись без локальной копии if (PropertyChanged != null) PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}

2) Из сказанного выше следует и ответ на вопрос 2. Нет, модельные классы вовсе не обязаны однозначно соответствовать VM-классам. Модельные классы создаются исходя из нужд модели, не подстраиваясь под конкретные желания вашей программы. VM-классы же, наоборот, отражают семантику, нужную именно вашей программе. Поэтому один VM-класс может вполне представлять несколько различных модельных классов, и наоборот, один большой модельный класс может на VM-уровне распасться в несколько отдельных классов.

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

Если вашему VM-классу необходимо выставить наружу список других VM-классов (например, Команда выставляет список Работников), то у вас есть фактически две альтернативы. Если список будет меняться в процессе работы, ваша коллекция должна поддерживать INotifyCollectionChanged (не путать с INotifyPropertyChanged!). Для этого можно взять ObservableCollection<SubVM>, но, разумеется, не обязательно, любая другая реализация INotifyCollectionChanged тоже подойдёт. Если же список логически неизменен, вы можете выставить хоть обыкновенный IEnumerable<SubVM>.

Заметьте, что обычно сам список выделяется один раз и не исчезает, так что включать свойство, содержащее список, в логику INPC не обязательно. В любом случае действует правило: объект может меняться — поле участвует в INPC, объект есть коллекция, которая может меняться (добавление, удаление и подмена элементов) — коллекция должна реализовать INCC, поля элементов коллекции меняются — элемент коллекции реализует INPC и т д.

Это решение никак не зависит от того, что и как реализовано в модели. Модель, как уже было сказано, может делать что ей угодно и каким ей угодно способом.

3) Зависит исключительно от желаний архитектора проекта, то есть, вас. Хотите строить иерархию VM один к одному с иерархией модели — стройте, не хотите — никто вас не обязывает, вы в вашем проекте царь и бог. Если вы решите завести другую иерархию объектов в VM, это не будет противоречить MVVM.

А даже если бы и противоречило? Вы пишете так, как вы считаете правильным и удобным, MVVM, как и любой другой паттерн — не догма.

4, 5) Я таки добрался до компилятора, и посмотрел, что не так в проекте. Смотрите. Проблемы здесь на самом деле WPF-специфические.

Во-первых, по поводу SelectedBore. Selected в контексте ListView означает элемент, который выделен мышью/клавиатурой (один из нескольких, если включено свойство Multiselect), и был бы нужен в случае, если бы команда запускалась на уровне всего списка. В вашем же случае команда запускается на уровне BoreViewModel, то есть, знает сама, к какому VM-объекту она относится. Поэтому поле BoreViewModel.SelectedBore не нужно. С другой стороны, сам VM-объект BoreViewModel выполнить переход к редактированию не может, поэтому этим должен заняться внешний объект. Для того, чтобы уменьшить связность между объектами, передадим в BoreViewModel внешнюю вспомогательную команду. Результат выложил тут. (Сравните с тем, что было, перед тем, как читать дальше.)

Зачем понадобилось две команды, внутренняя и внешняя? Потому, что мы не имеем контроля над параметром, передаваемым в Execute внутренней команды, её запускает WPF. А вот над параметром для внешней команды как раз имеем. С другой стороны, мы могли бы не использовать внутреннюю команду вовсе, а выставить наружу внешнюю, добавив в BoresView.xaml к <Button Content="E" ... Command="{Binding EditBoreCommand}" ещё и CommandParameter="{Binding}", это уже вопрос вкуса, давать ли View так много контроля.

Кстати, привязка в MainView.xaml (Binding Bores.SelectedBore) не сработала бы, т. к. Bores есть ObservableCollection, и не имеет свойства SelectedBore. По факту, ObservableCollection не имеет понятия о том, что какой-то объект в ней выбран. Поэтому взаимодействие будем проводить по-другому.

Перейдём к реализации внешней команды. Она, понятно, принадлежит классу JournalViewModel. Что нам надо по сути сделать? Установить активный экземпляр BoreViewModel, и переключиться к View редактора. Для этого определяем два дополнительных свойства: BoreViewModel ActiveBore и bool IsInfoActive, добавляем функцию

public void ActivateBore(BoreViewModel vm)
{ ActiveBore = vm; IsInfoActive = true;
}

а в конструктор BoreViewModel передаём команду, которая будет вызывать эту функцию:

var editCommand = new RelayCommand(o => ActivateBore((BoreViewModel)o));
foreach (var bore in journal.Bores) Bores.Add(new BoreViewModel(bore, editCommand));

Результат тут.

Такой же трюк понадобится, судя по всему, и для RemoveBoreCommand.

Теперь View. Нам надо, чтобы таб "Bore Info" активизировался при установке IsInfoActive в true. Для этого привяжем его свойство IsSelected к этому самому IsInfoActive. DataContext в BoreInfoEditView необходимо привязать теперь просто к ActiveBore. Получится следующее:

<tabitem header="Bore Info" isselected="{Binding IsInfoActive, Mode=TwoWay}"> </tabitem>

Да, мне ещё понадобилось в DiametersView.xaml поменять Command="RemoveDiameterCommand" на Command="{Binding RemoveDiameterCommand}", иначе вылетало.

Надеюсь, это отвечает на пункты 4 и 5. Если остались ещё вопросы, задавайте!

Вот вам ещё один паттерн, полезный в случае, когда у вас коллекция VM с командами (идея позаимствована из Prism): CompositeCommand. (Исходник.) Эта команда инкапсулирует коллекцию других команд.

Такая команда полезна, например, вот в каком случае. У вас есть коллекция документов, и вы хотите выкатить команды Save (сохранить текущий активный документ) и SaveAll (сохранить все изменённые документы). Тогда пусть каждый документ выставляет наружу SaveCommand. В корне определяете CompositeCommand SaveActiveCommand и CompositeCommand SaveAllCommand:

// работает лишь команда, относящаяся к активному документу
SaveActiveCommand = new CompositeCommand(cmd => cmd == ActiveDocument.SaveCommand);
// работают все команды
SaveAllCommand = new CompositeCommand(cmd => true);

при добавлении нового документа выполняете

SaveActiveCommand.RegisterCommand(document.SaveCommand);
SaveAllCommand.RegisterCommand(document.SaveCommand);

Всё!

Обновление: Да, для BoreViewModel пришлось сделать ещё один параметр, чтобы связать его с «внешним миром». В принципе, можно передавать «знание» о внешнем мире и по-другому, например, используя какую-нибудь разновидность dependency injection. (Передача параметра через конструктор в общем-то тоже является простейшей разновидностью dependency injection.)

В принципе, если ваши «внешние» команды редактирования есть корень, уровень приложения, то их можно положить в Application, а к нему получать доступ из любой глубины как к синглтону ((App)Appliction.Current). Хотя и это решение получается не очень изящным, и страдает от стандартных недостатков синглтона (плохая тестируемость и расширяемость).

С другой стороны, вам, по идее, всё время нужно лишь протягивать команды объекта, лежащего на один уровень выше: для добавления/удаления LayerViewModel вам достаточно обрабатывать это на уровне BoreViewModel. Единственное затруднение — с протягиванием выбранного LayerViewModel в выбранном BoreViewModel на уровень таба, но это легко делается «длинной» привязкой:

<tabitem header="Layers"> </tabitem>
<tabitem header="Layer info"> </tabitem>
<tabitem header="Samples"> </tabitem>

и так далее.


Ja_Dim

https://bitbucket.org/xakpc/mvvm пример классичсекого приложения с использованием MVVM

А вообще рекомендую использовать MVVM Light Toolkit, данная библиотека, после ее установки через nuget, создается базовый каркас приложения. Также в ней имеется готовая реализация месенджера для передачи объектов из одного VM в другой, а также все подготовленно для использования DI и куча других плюшек, и ко всему этому есть документация и примеры на сайте разработчика.

licensed under cc by-sa 3.0 with attribution.