Я пытаюсь создать программу MindMap, используя WPF с MVVM. У меня есть модель под названием «NodeModel», в которой есть список дочерних NodeModel.
public class NodeModel
{
public string Content { get; set; }
public Vector2 Position { get; set; }
public NodeModel ?ParentNode { get; set; }
public List<NodeModel> ChildrenNodes { get; }
}
Теперь я хотел реализовать NodeViewModel, чтобы представление могло уведомляться о любых изменениях, внесенных Node.
public class NodeViewModel : ViewModelBase
{
private string _content;
public string Content
{
get
{
return _content;
}
set
{
_content = value;
OnPropertyChanged(nameof(Content));
}
}
private Vector2 _position;
public Vector2 Position
{
get
{
return _position;
}
set
{
_position = value;
OnPropertyChanged(nameof(Position));
}
}
private NodeModel? _parentNode;
public NodeModel ParentNode
{
get
{
return _parentNode;
}
set
{
_parentNode = value;
OnPropertyChanged(nameof(ParentNode));
}
}
public NodeViewModel(NodeModel node)
{
Content = node.Content;
Position = node.Position;
ParentNode = node.ParentNode;
}
}
Проблема, с которой я сейчас столкнулся, заключается в том, что я не знаю, как мне обращаться со списком, который я создал в NodeModel во ViewModel. Я не могу просто использовать список NodeModel, потому что тогда NodeModel не сможет уведомить представление. Но я не могу использовать NodeViewModels, потому что мой список поддерживает только NodeModels. Что я должен делать?
Мое решение — просто изменить тип списка в NodeModel на NodeViewModel. Но это кажется неправильным, потому что тогда модели не будут должным образом инкапсулированы из ViewModel.
«Модель» представляет собой «деревовидное» представление или иерархическое представление. Узел — это именно то.
Есть ли причина, по которой ваша модель не реализует INPC? Затем вы должны использовать HierarchicalDataTemplate, чтобы использовать его в виде дерева.
@Streeter Я думал об использовании Observable Collection, но все равно сталкивался с той же проблемой: иметь коллекцию моделей в классе Model и не знать, как преобразовать свойство в модель представления.
@XAMlMAX Я прочитал здесь и здесь, что только модели ViewModels должны реализовывать INotifyPropertyChanged. В противном случае это сломает MVVM, поскольку тогда Модель будет напрямую взаимодействовать с Представлением.
Модель должна уведомлять о своих изменениях, так работает WPF. Если у вас есть модель без INPC, это модель только для чтения. Никаких изменений в пользовательском интерфейсе не требуется. Для всего остального реализуйте INPC. Это также сделает ваш код более понятным. Если у вас есть коллекция, которая изменит свое содержимое, используйте ObservableCollection. Замечательно, что вы вкладываете много усилий в хорошие практики. Не расстраивайтесь, если этот вопрос будет закрыт. Я буду более чем рад помочь вам с вашим следующим вопросом.
Вопрос сводится к тому, как построить адаптивные модели представления, представляющие данные, существующие в модели, в древовидной иерархии. (К вашему сведению, мне нравится называть модель «моделью данных», чтобы не путать ее с моделью представления, поэтому я сделаю это ниже).
Я представлю три разных подхода с повышенной надежностью/гибкостью. Лучший подход будет зависеть от ваших требований.
Подход только для чтения
Самый простой способ сделать это, опираясь на существующий код/шаблон, — добавить свойство ChildrenNodes
к NodeViewModel
, которое отражает то же свойство NodeModel
:
private List<NodeViewModel> _childrenNodes;
public List<NodeViewModel> ChildrenNodes
{
get => _childrenNodes;
set
{
_childrenNodes = value;
OnPropertyChanged(nameof(ChildrenNodes));
}
}
И в конструкторе:
public NodeViewModel(NodeModel node)
{
Content = node.Content;
Position = node.Position;
ParentNode = node.ParentNode;
ChildrenNodes = new List<NodeViewModel>(
node.ChildrenNodes?.Select(n => new NodeViewModel(n))
?? Enumerable.Empty<NodeViewModel>());
}
Это заставит конструктор рекурсивно копировать в модели представления тот же шаблон иерархического дерева, который уже существует в модели данных. Теперь вы, например, можете использовать HierarchicalDataTemplate
с TreeView
и привязать дочерние элементы к свойству ChildrenNodes
модели представления, не раскрывая модель данных напрямую.
Это нормально для модели представления, доступной только для чтения, т. е. если узлы никогда не будут меняться. Однако, если вы хотите, чтобы модель представления обрабатывала ввод пользователя и в ответ манипулировала моделью данных, что типично, вам понадобится немного другой подход.
Подход чтения/записи
public class NodeViewModel : ViewModelBase
{
private NodeModel _data;
// For creating new empty nodes, if applicable
public NodeViewModel()
{
_data = new NodeModel();
}
// For representing existing nodes
public NodeViewModel(NodeModel node)
{
this._data = node;
}
ObservableCollection<NodeViewModel> _childrenNodes;
public ObservableCollection<NodeViewModel> ChildrenNodes
{
get
{
if (_childrenNodes == null)
{
_childrenNodes = new ObservableCollection<NodeViewModel>(
this._data.ChildrenNodes?.Select(n => new NodeViewModel(n))
?? Enumerable.Empty<NodeViewModel>());
// This allows us to synchronize the data model's
// child list to changes to the view model's
_childrenNodes.CollectionChanged += this.OnChildrenChanged;
}
return _childrenNodes;
}
}
public string Content
{
get
{
// Using the stored data model instance property essentially
// as the backing field
return this._data.Content;
}
set
{
this._data.Content = value;
OnPropertyChanged(nameof(Content));
}
}
// ...Other direct access properties...
private void OnChildrenChanged(
object? sender,
NotifyCollectionChangedEventArgs e)
{
// Keeps the data model children list in sync with
// the view model children in case the ObservableCollection
// is manipulated directly by the view.
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
var newNodes = e.NewItems?.OfType<NodeViewModel>()
?? Enumerable.Empty<NodeViewModel>();
foreach (var node in newNodes)
this._data.ChildrenNodes.Add(node._data);
break;
case NotifyCollectionChangedAction.Remove:
var removedNodes = e.OldItems?.OfType<NodeViewModel>()
?? Enumerable.Empty<NodeViewModel>();
foreach (var node in removedNodes)
this._data.ChildrenNodes.Remove(node._data);
break;
case NotifyCollectionChangedAction.Reset:
this._data.ChildrenNodes.Clear();
break;
// Handle Replace and Move if needed
}
}
}
Ключевое отличие здесь заключается в том, что модель представления теперь содержит частную ссылку на экземпляр объекта модели данных и использует свойства объекта данных по существу в качестве вспомогательных полей. При этом модель представления рассматривается не просто как копия данных (что более или менее делает ваш текущий код), а как «привратник» между данными и представлением. Что еще более важно, теперь объект данных можно изменять в ответ на действия пользовательского интерфейса, что раньше было невозможно.
В частности, для дочернего списка теперь вы можете добавлять или удалять дочерние узлы, просто изменяя коллекцию моделей представления, например:
internal void NewNode() =>
this.ChildrenNodes.Add(new NodeViewModel());
internal void RemoveNode(NodeViewModel node) =>
this.ChildrenNodes.Remove(node);
Эти методы могут быть инкапсулированы, например, в команды, связанные с кнопками, в шаблоне данных. Или вы можете привязать ItemsSource
элемента управления, такого как DataGrid
, который позволяет полностью добавлять/удалять элементы коллекции из пользовательского интерфейса непосредственно к свойству модели представления ChildrenNodes
. Поскольку мы обрабатываем CollectionChanged
так же, как и раньше, любые изменения, внесенные в дочернюю коллекцию модели представления, будут автоматически синхронизироваться со списком дочерних узлов модели данных без дополнительных усилий.
Это будет работать при условии, что все изменения объекта модели данных в течение жизненного цикла приложения — особенно изменения дочернего списка — должны быть инициированы из модели представления. Однако если код в другом месте может изменить дочерний список без использования свойств и методов модели представления, эти изменения не будут отражены в модели представления. Чтобы справиться с этой ситуацией, требуется еще третий подход.
Самое надежное решение – обман!
Однако есть и другой подход, который, как мне кажется, может вызвать некоторую ненависть за то, что его даже предложили, но меня волнует не столько чистота дизайна, сколько надежность и ценность. В конце концов, зачем следовать шаблону проектирования, если он вам не помогает? Так вот это - обман!
Новинка NodeModel
:
// ObservableObject is just a barebones INPC implementation
public class NodeModel : ObservableObject
{
private string _content;
public string Content
{
get
{
return _content;
}
set
{
if (_content == value)
return;
_content = value;
OnPropertyChanged();
}
}
private NodeModel _parentNode;
public NodeModel ParentNode
{
get
{
return _parentNode;
}
set
{
if (_parentNode == value)
return;
_parentNode = value;
OnPropertyChanged();
}
}
ObservableCollection<NodeModel> _childrenNodes;
public ObservableCollection<NodeModel> ChildrenNodes
{
get => _childrenNodes ?? (_childrenNodes = new ObservableCollection<NodeModel>());
}
// Other properties
}
Вместо нескольких NodeViewModel
мы используем один NodeTreeViewModel
:
public class NodeTreeViewModel : ViewModelBase
{
public NodeTreeViewModel(NodeModel rootNode)
{
this.Root = rootNode;
}
public NodeModel Root { get; }
// A couple example commands:
// Assuming a generic ICommand implementation with a typed parameter
private Command<NodeModel> _AddNodeCommand;
public ICommand AddNodeCommand
{
get
{
return _AddNodeCommand ?? (_AddNodeCommand = new Command<NodeModel>(
(parentNode) =>
{
parentNode?.ChildrenNodes?.Add(new NodeModel
{
ParentNode = parentNode
});
}));
}
}
private Command<NodeModel> _RemoveNodeCommand;
public ICommand RemoveNodeCommand
{
get
{
return _RemoveNodeCommand ?? (_RemoveNodeCommand = new Command<NodeModel>(
(removeNode) =>
{
removeNode?.ParentNode?.ChildrenNodes?.Remove(removeNode);
}));
}
}
}
Благодаря реализации объекта модели данных INotifyPropertyChanged
и использованию ObservableCollection
для дочернего списка, а не простого List
, теперь вы можете привязывать компоненты пользовательского интерфейса непосредственно к свойствам модели данных. Вот как вы могли бы создать рабочее представление этого, используя TreeView
(при условии, что TreeView.DataContext
— это NodeTreeViewModel
):
<TreeView ItemsSource = "{Binding Root.ChildrenNodes}">
<TreeView.ItemTemplate>
<HierarchicalDataTemplate ItemsSource = "{Binding ChildrenNodes}"
DataType = "{x:Type local:NodeModel}">
<StackPanel Orientation = "Horizontal">
<TextBlock Text = "{Binding Content}" />
<Button Content = "New"
Command = "{Binding
RelativeSource = {RelativeSource AncestorType=TreeView},
Path=DataContext.AddNodeCommand}"
CommandParameter = "{Binding}" />
<Button Content = "Remove"
Command = "{Binding
RelativeSource = {RelativeSource AncestorType=TreeView},
Path=DataContext.RemoveNodeCommand}"
CommandParameter = "{Binding}" />
</StackPanel>
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
Плюсом является то, что это проще и надежнее, чем заключать каждый узел в собственную модель представления. Любые изменения, внесенные в объекты модели данных, включая изменения списка узлов, будут отражены в пользовательском интерфейсе без какой-либо дополнительной обработки.
Обратной стороной является то, что, как вы заметили, это ослабляет разделение между слоями модели представления и модели данных и позволяет представлению напрямую обращаться к объекту NodeModel
. На что я говорю - и что?
Я не могу назвать в этом никаких недостатков, кроме философской чистоты. INotifyPropertyChanged
и ObservableCollection
— фундаментальные типы .NET, доступные во всех вариантах .NET; таким образом, ваша модель данных по-прежнему может находиться в сборке без каких-либо зависимостей от WPF или любой другой конкретной платформы графического интерфейса и будет нормально работать с другими платформами. Например, если бы вы использовали общий код модели данных между приложением WPF и серверным приложением ASP.NET Core, у серверного приложения не было бы никаких проблем с объектами модели данных, реализующими INPC — просто не было бы подписчиков на PropertyChanged
событие. Свойства модели данных также будут нормально сериализовать/десериализоваться, будь то JSON, EntityFramework и т. д.
Конечно, изменение объектов модели данных не всегда возможно. Это могут быть сторонние классы или области приложения, принадлежащие другим лицам, к которым вам не разрешено прикасаться. В этом случае нет другого выбора, кроме как обернуть объект модели данных в модель представления и следить за тем, чтобы изменения модели данных не происходили в течение жизненного цикла приложения, кроме как через «привратник» модели представления.
Но если вы проявляете гибкость в отношении объектов модели данных, нет ничего плохого в том, чтобы сделать их «совместимыми с MVVM», реализовав INotifyPropertyChanged
, используя ObservableCollection
и напрямую привязываясь к свойствам модели данных, когда это самый простой и надежный подход. Не существует заповеди, которая бы запрещала создавать DataTemplate
для чего-либо, кроме модели представления. В конце концов, это называется шаблоном данных.
Я понимаю, что это привносит в архитектуру больше субъективности, чем вы, вероятно, надеялись. Я предлагаю всегда стремиться следовать правилам разделения дизайна до тех пор, пока они не станут больше бременем, чем выгодой, а когда вам действительно нужно нарушить правила, делайте это только в минимально возможной степени. Например, обратите внимание, что я не поместил команды добавления/удаления в объект NodeModel
, хотя мог бы это сделать, потому что в этом не было необходимости и на мой вкус NodeModel
выглядел бы слишком похоже на модель представления. С другой стороны, это означало, что мне пришлось использовать привязки RelativeSource
в шаблоне узла, чтобы получить доступ к команде родительской модели представления (что, кстати, MAUI решает гораздо более элегантно, чем WPF), что несет в себе некоторые недостатки ее собственный.
В конечном счете, древовидные иерархии — одна из наиболее сложных задач, с которыми приходится иметь дело «чистым MVVM». Надеюсь, этот том даст вам несколько указаний, которые стоит попробовать, и вы найдете подход, который лучше всего подойдет вам.
Вы всегда можете использовать Observable Collection. ссылка