MVVM同步集合

55

是否有一种标准化的方法在C#和WPF中同步一组Model对象与一组匹配的ModelView对象?我正在寻找某种类,它可以保持以下两个集合同步,假设我只有几个苹果并且我可以将它们全部保存在内存中。

换句话说,如果我向Apples集合添加一个Apple,则希望将一个AppleModelView添加到AppleModelViews集合中。我可以通过侦听每个集合的CollectionChanged事件来编写自己的代码。这似乎是一个常见的场景,有人比我更聪明地定义了“正确”的方法来处理它。

public class BasketModel
{
    public ObservableCollection<Apple> Apples { get; }
}

public class BasketModelView
{
    public ObservableCollection<AppleModelView> AppleModelViews { get; }
}

我不完全理解你的问题。今天可能有点慢,你可能需要重新陈述一下。 - Shaun Bowe
我在上方添加了一个新的段落,希望有所帮助。 - Jake Pearson
为什么要有单独的集合?Apple 可以是 AppleModelView 的子集,然后根据你获得 Apple 的方式只填充 AppleModelView 中相关的部分。一般来说,我会将模型完全从 WPF 中分离出来,仅保留 ViewModel。模型是数据库或其他实体。 - markmnl
在绑定过程中,您还可以使用值转换器将 Apple 转换为 AppleModelView,具体取决于您是否想在其他地方重用 AppleModelView。 - MikeT
11个回答

68

我使用惰性构建、自动更新的集合:

public class BasketModelView
{
    private readonly Lazy<ObservableCollection<AppleModelView>> _appleViews;

    public BasketModelView(BasketModel basket)
    {
        Func<AppleModel, AppleModelView> viewModelCreator = model => new AppleModelView(model);
        Func<ObservableCollection<AppleModelView>> collectionCreator =
            () => new ObservableViewModelCollection<AppleModelView, AppleModel>(basket.Apples, viewModelCreator);

        _appleViews = new Lazy<ObservableCollection<AppleModelView>>(collectionCreator);
    }

    public ObservableCollection<AppleModelView> Apples
    {
        get
        {
            return _appleViews.Value;
        }
    }
}

使用以下ObservableViewModelCollection<TViewModel, TModel>

namespace Client.UI
{
    using System;
    using System.Collections.Generic;
    using System.Collections.ObjectModel;
    using System.Collections.Specialized;
    using System.Diagnostics.Contracts;
    using System.Linq;

    public class ObservableViewModelCollection<TViewModel, TModel> : ObservableCollection<TViewModel>
    {
        private readonly ObservableCollection<TModel> _source;
        private readonly Func<TModel, TViewModel> _viewModelFactory;

        public ObservableViewModelCollection(ObservableCollection<TModel> source, Func<TModel, TViewModel> viewModelFactory)
            : base(source.Select(model => viewModelFactory(model)))
        {
            Contract.Requires(source != null);
            Contract.Requires(viewModelFactory != null);

            this._source = source;
            this._viewModelFactory = viewModelFactory;
            this._source.CollectionChanged += OnSourceCollectionChanged;
        }

        protected virtual TViewModel CreateViewModel(TModel model)
        {
            return _viewModelFactory(model);
        }

        private void OnSourceCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
        {
            switch (e.Action)
            {
            case NotifyCollectionChangedAction.Add:
                for (int i = 0; i < e.NewItems.Count; i++)
                {
                    this.Insert(e.NewStartingIndex + i, CreateViewModel((TModel)e.NewItems[i]));
                }
                break;

            case NotifyCollectionChangedAction.Move:
                if (e.OldItems.Count == 1)
                {
                    this.Move(e.OldStartingIndex, e.NewStartingIndex);
                }
                else
                {
                    List<TViewModel> items = this.Skip(e.OldStartingIndex).Take(e.OldItems.Count).ToList();
                    for (int i = 0; i < e.OldItems.Count; i++)
                        this.RemoveAt(e.OldStartingIndex);

                    for (int i = 0; i < items.Count; i++)
                        this.Insert(e.NewStartingIndex + i, items[i]);
                }
                break;

            case NotifyCollectionChangedAction.Remove:
                for (int i = 0; i < e.OldItems.Count; i++)
                    this.RemoveAt(e.OldStartingIndex);
                break;

            case NotifyCollectionChangedAction.Replace:
                // remove
                for (int i = 0; i < e.OldItems.Count; i++)
                    this.RemoveAt(e.OldStartingIndex);

                // add
                goto case NotifyCollectionChangedAction.Add;

            case NotifyCollectionChangedAction.Reset:
                Clear();
                for (int i = 0; i < e.NewItems.Count; i++)
                    this.Add(CreateViewModel((TModel)e.NewItems[i]));
                break;

            default:
                break;
            }
        }
    }
}

1
在您的代码中,如果NotifyCollectionChangedAction.Move的情况下,else语句存在一个bug。如果NewStartingIndex > OldStartingIndex,则可以通过添加此代码来修复:在删除和插入之间添加此代码:if(newIndex > e.OldStartingIndex) newIndex-=e. OldItems.Count; - João Portela
非常有帮助的答案。谢谢!如何实现IDisposable以修复内存泄漏问题 public void Dispose() { _source.CollectionChanged -= OnSourceCollectionChanged; } - bobah75
2
应从此代码中删除“Contracts”和“Lazy”的用法。它会给答案带来不必要的复杂性。 - Maxence
@JoãoPortela 看起来你正在尝试修复删除项目后 e.NewStartingIndex 超出范围的情况,但这种情况是不可能发生的。当移动项目时,e.NewStartingIndex 自然被限制为有效索引(e.NewStartingIndex + e.OldItems.Count 将始终小于或等于原始列表的大小)。你的代码实际上引入了一个 bug:例如,如果 e.OldStartingIndex==0,e.NewStartingIndex==1,而 e.OldItems.Count==5,则你的代码将 -4 分配给 newIndex。 - Collin K
看起来你是对的,所以 NewStartingIndex 被解释为已经被移除了。 - João Portela
显示剩余4条评论

11

我可能不完全理解您的要求,但我处理类似情况的方法是在ObservableCollection上使用CollectionChanged事件,并根据需要创建/销毁视图模型。

void OnApplesCollection_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{    
  // Only add/remove items if already populated. 
  if (!IsPopulated)
    return;

  Apple apple;

  switch (e.Action)
  {
    case NotifyCollectionChangedAction.Add:
      apple = e.NewItems[0] as Apple;
      if (apple != null)
        AddViewModel(asset);
      break;
    case NotifyCollectionChangedAction.Remove:
      apple = e.OldItems[0] as Apple;
      if (apple != null)
        RemoveViewModel(apple);
      break;
  }

}

在ListView中添加/删除大量项时可能会出现一些性能问题。

我们通过扩展ObservableCollection来解决这个问题,添加了AddRange、RemoveRange和BinaryInsert方法,并添加了通知其他人集合正在被更改的事件。与一个扩展的CollectionViewSource一起使用,当集合发生更改时临时断开源,它运行得很好。

希望对你有所帮助,

Dennis


4

首先,我认为没有一种“正确的方法”来解决这个问题。这完全取决于您的应用程序。有更正确的方法和不太正确的方法。

话虽如此,我想知道为什么您需要保持这些集合“同步”。您考虑的是什么情况会导致它们不同步?如果您查看Josh Smith在MSDN关于M-V-VM的文章中提供的示例代码,您会发现大部分时间,Models与ViewModels保持同步只是因为每次创建一个Model时,也会创建一个ViewModel。就像这样:

void CreateNewCustomer()
{
    Customer newCustomer = Customer.CreateNewCustomer();
    CustomerViewModel workspace = new CustomerViewModel(newCustomer, _customerRepository);
    this.Workspaces.Add(workspace);
    this.SetActiveWorkspace(workspace);
}

我在想,为什么你不能每次创建Apple时都创建一个AppleModelView呢?这似乎是保持这些集合“同步”的最简单方法,除非我误解了你的问题。


谢谢您的帖子。我可能在脑海中把它想得太难了。我会回去继续工作的。 - Jake Pearson

4

2

1
这是该文章的存档版本:https://web.archive.org/web/20110803113629/http://blog.notifychanged.com/2009/01/30/viewmodelling-lists/ - Jan
嗨,@Jan!非常感谢您的更新!我已根据您的更新更新了答案。 - Sergey Vyacheslavovich Brunov

1

虽然Sam Harwell's solution已经很好了,但还存在两个问题:

  1. 在此处注册的事件处理程序this._source.CollectionChanged += OnSourceCollectionChanged从未注销,即缺少this._source.CollectionChanged -= OnSourceCollectionChanged
  2. 如果将事件处理程序附加到由viewModelFactory生成的视图模型的事件上,则无法知道何时可以分离这些事件处理程序。 (或者通常来说:您无法为“销毁”准备生成的视图模型。)

因此,我提出了一个解决方案,修复了Sam Harwell方法的这两个问题(简短):

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Diagnostics.Contracts;
using System.Linq;

namespace Helpers
{
    public class ObservableViewModelCollection<TViewModel, TModel> : ObservableCollection<TViewModel>
    {
        private readonly Func<TModel, TViewModel> _viewModelFactory;
        private readonly Action<TViewModel> _viewModelRemoveHandler;
        private ObservableCollection<TModel> _source;

        public ObservableViewModelCollection(Func<TModel, TViewModel> viewModelFactory, Action<TViewModel> viewModelRemoveHandler = null)
        {
            Contract.Requires(viewModelFactory != null);

            _viewModelFactory = viewModelFactory;
            _viewModelRemoveHandler = viewModelRemoveHandler;
        }

        public ObservableCollection<TModel> Source
        {
            get { return _source; }
            set
            {
                if (_source == value)
                    return;

                this.ClearWithHandling();

                if (_source != null)
                    _source.CollectionChanged -= OnSourceCollectionChanged;

                _source = value;

                if (_source != null)
                {
                    foreach (var model in _source)
                    {
                        this.Add(CreateViewModel(model));
                    }
                    _source.CollectionChanged += OnSourceCollectionChanged;
                }
            }
        }

        private void OnSourceCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
        {
            switch (e.Action)
            {
                case NotifyCollectionChangedAction.Add:
                    for (int i = 0; i < e.NewItems.Count; i++)
                    {
                        this.Insert(e.NewStartingIndex + i, CreateViewModel((TModel)e.NewItems[i]));
                    }
                    break;

                case NotifyCollectionChangedAction.Move:
                    if (e.OldItems.Count == 1)
                    {
                        this.Move(e.OldStartingIndex, e.NewStartingIndex);
                    }
                    else
                    {
                        List<TViewModel> items = this.Skip(e.OldStartingIndex).Take(e.OldItems.Count).ToList();
                        for (int i = 0; i < e.OldItems.Count; i++)
                            this.RemoveAt(e.OldStartingIndex);

                        for (int i = 0; i < items.Count; i++)
                            this.Insert(e.NewStartingIndex + i, items[i]);
                    }
                    break;

                case NotifyCollectionChangedAction.Remove:
                    for (int i = 0; i < e.OldItems.Count; i++)
                        this.RemoveAtWithHandling(e.OldStartingIndex);
                    break;

                case NotifyCollectionChangedAction.Replace:
                    // remove
                    for (int i = 0; i < e.OldItems.Count; i++)
                        this.RemoveAtWithHandling(e.OldStartingIndex);

                    // add
                    goto case NotifyCollectionChangedAction.Add;

                case NotifyCollectionChangedAction.Reset:
                    this.ClearWithHandling();
                    if (e.NewItems == null)
                        break;
                    for (int i = 0; i < e.NewItems.Count; i++)
                        this.Add(CreateViewModel((TModel)e.NewItems[i]));
                    break;

                default:
                    break;
            }
        }

        private void RemoveAtWithHandling(int index)
        {
            _viewModelRemoveHandler?.Invoke(this[index]);
            this.RemoveAt(index);
        }

        private void ClearWithHandling()
        {
            if (_viewModelRemoveHandler != null)
            {
                foreach (var item in this)
                {
                    _viewModelRemoveHandler(item);
                }
            }

            this.Clear();
        }

        private TViewModel CreateViewModel(TModel model)
        {
            return _viewModelFactory(model);
        }
    }
}

为了解决这两个问题中的第一个问题,您可以简单地将Source设置为null,以摆脱CollectionChanged事件处理程序。
为了解决这两个问题中的第二个问题,您可以简单地添加一个viewModelRemoveHandler,允许您“准备对象销毁”,例如通过删除任何附加到它的事件处理程序。

我还注意到,在获取“Reset”时,e.NewItems 有时可能为空。我刚刚添加了处理这种情况的代码。 - Hauke P.
你能提供一个快速的示例,展示如何实现 viewModelRemoveHandler 吗? - Mike Christiansen
哎呀,太久没有在短时间内制作出可用的示例了。如果我没记错的话,我将一个函数作为构造函数的第二个参数传递了进去。这个函数移除了所有附加到视图模型上的事件。这有帮助吗? - Hauke P.
是的,我猜那就是它的含义,但是我希望你有一个好的例子。很抱歉四年后才回复!无论如何还是谢谢你! - Mike Christiansen

1

好吧,我对 这个答案 有一点儿迷恋,所以我必须分享一下我添加的抽象工厂来支持我的构造函数注入。

using System;
using System.Collections.ObjectModel;

namespace MVVM
{
    public class ObservableVMCollectionFactory<TModel, TViewModel>
        : IVMCollectionFactory<TModel, TViewModel>
        where TModel : class
        where TViewModel : class
    {
        private readonly IVMFactory<TModel, TViewModel> _factory;

        public ObservableVMCollectionFactory( IVMFactory<TModel, TViewModel> factory )
        {
            this._factory = factory.CheckForNull();
        }

        public ObservableCollection<TViewModel> CreateVMCollectionFrom( ObservableCollection<TModel> models )
        {
            Func<TModel, TViewModel> viewModelCreator = model => this._factory.CreateVMFrom(model);
            return new ObservableVMCollection<TViewModel, TModel>(models, viewModelCreator);
        }
    }
}

这是基于这个构建的:

using System.Collections.ObjectModel;

namespace MVVM
{
    public interface IVMCollectionFactory<TModel, TViewModel>
        where TModel : class
        where TViewModel : class
    {
        ObservableCollection<TViewModel> CreateVMCollectionFrom( ObservableCollection<TModel> models );
    }
}

还有这个:

namespace MVVM
{
    public interface IVMFactory<TModel, TViewModel>
    {
        TViewModel CreateVMFrom( TModel model );
    }
}

以下是用于完整性检查的空值检查器:

namespace System
{
    public static class Exceptions
    {
        /// <summary>
        /// Checks for null.
        /// </summary>
        /// <param name="thing">The thing.</param>
        /// <param name="message">The message.</param>
        public static T CheckForNull<T>( this T thing, string message )
        {
            if ( thing == null ) throw new NullReferenceException(message);
            return thing;
        }

        /// <summary>
        /// Checks for null.
        /// </summary>
        /// <param name="thing">The thing.</param>
        public static T CheckForNull<T>( this T thing )
        {
            if ( thing == null ) throw new NullReferenceException();
            return thing;
        }
    }
}

0

将集合重置为默认值或匹配目标值是我经常遇到的问题

我编写了一个包含各种方法的小助手类,其中包括:

public static class Misc
    {
        public static void SyncCollection<TCol,TEnum>(ICollection<TCol> collection,IEnumerable<TEnum> source, Func<TCol,TEnum,bool> comparer, Func<TEnum, TCol> converter )
        {
            var missing = collection.Where(c => !source.Any(s => comparer(c, s))).ToArray();
            var added = source.Where(s => !collection.Any(c => comparer(c, s))).ToArray();

            foreach (var item in missing)
            {
                collection.Remove(item);
            }
            foreach (var item in added)
            {
                collection.Add(converter(item));
            }
        }
        public static void SyncCollection<T>(ICollection<T> collection, IEnumerable<T> source, EqualityComparer<T> comparer)
        {
            var missing = collection.Where(c=>!source.Any(s=>comparer.Equals(c,s))).ToArray();
            var added = source.Where(s => !collection.Any(c => comparer.Equals(c, s))).ToArray();

            foreach (var item in missing)
            {
                collection.Remove(item);
            }
            foreach (var item in added)
            {
                collection.Add(item);
            }
        }
        public static void SyncCollection<T>(ICollection<T> collection, IEnumerable<T> source)
        {
            SyncCollection(collection,source, EqualityComparer<T>.Default);
        }
    }

这基本上满足了我的大部分需求,第一个可能更适用,因为您还要转换类型。

注意:这仅同步集合中的元素,而不是其中的值。


0

我编写了一些辅助类,用于将业务对象的可观察集合包装在它们的视图模型对应项中这里


0

我真的很喜欢280Z28的解决方案。只有一条评论。对于每个NotifyCollectionChangedAction,做循环是否必要?我知道操作的文档状态“一个或多个项”,但由于ObservableCollection本身不支持添加或删除范围,我觉得这永远不会发生。


完全不相关,但你是不是被称为Bert Vermeire? - El Ronnoco
不,我不是,'h' 没有意义 :) - bertvh
ObservableCollection 支持多个项,我相信基本上是 WPF 不支持在单个更改事件中有多个项。 - jpierson
@jpierson:ObservableCollection 支持多个删除(例如 clear),但不支持添加。我承认 AddRange 方法会很有用,但通过强制逐个添加,它们将响应时间降低到监视 GUI 元素上。这减少了因为某人一次向 listview 添加了 10000 个元素而导致锁定的可能性。 - MikeT

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接