MVVM:双向绑定到模型中的数组(w/o INotifyPropertyChanged)

MVVM: Two-Way Binding To Array In The Model (w/o INotifyPropertyChanged)

假设我的模型有一个 属性 这是一个基元数组:

class Model
{
    public static int MaxValue { get { return 10; } }

    private int[] _array = {1,2,3,4};
    public int[] MyArray
    {
       get
       { 
          return _array; 
       }
       set
       {
           for (int i = 0; i < Math.Min(value.Length, _array.Length); i++)
               _array[i] = Math.Min(MaxValue, value[i]);
       }
}

然后 ViewModel 包装这个 属性 这个供视图使用:

class ViewModel
{
    Model _model = new Model();
    public int[] MyArray
    {
        get { return _model.MyArray; }
        set { _model.MyArray = value; }
    }
}

最后,我展示了一个 ItemsControl,以便用户可以使用滑块设置每个值:

<ItemsControl ItemsSource="{Binding MyArray}" >
  <ItemsControl.ItemTemplate>
    <DataTemplate>
      <Slider Value="{Binding Path=., Mode=TwoWay}" Minimum="0" Maximum="{Binding Source={x:Static Model.MaxValue}, Mode=OneWay}" />
    </DataTemplate>
  </ItemsControl.ItemTemplate>
</ItemsControl>

在这个简单的示例中,TwoWay 绑定将不起作用 - 滑块不会更新属性。这通过将每个数组元素包装在一个对象中来解决,例如:

class Wrap
{
    public int Value{ get; set; }
}

MyArray 属性然后变成 Wrap[],我们绑定到 Path=Value。虽然以这种方式在模型中包装数组确实感觉有点"messy"(注意:该模型也被其他 WinForms 应用程序使用),但它确实解决了问题并且滑块现在可以更新数组。

接下来,让我们添加一个按钮来重置数组值。单击时:

for( int i = 0; i < MyArray.Length; i++ )
    MyArray[i].Value = i;

出现了另一个问题:单击按钮后,滑块不反映新值。这可以通过将数组更改为 ObservableCollection 并使用对象替换更新值来解决:

   //MyArray is now ObservableCollection<Wrap>
   for( int i = 0; i < MyArray.Count; i++ )
        MyArray[i] = new Wrap() { Value = i };

第一个问题:我的理解是,在 MVVM 中,模型 类 不应该与通知有任何关系 - 应该完全由虚拟机。通常这样的讨论围绕 INotifyPropertyChanged,但在我看来,将数组更改为 ObservableCollection 确实开始为了......好吧......通知而破坏模型。这不会被认为是不好的做法吗?使用该模型的所有其他 (WinForms) 应用程序现在都必须更新以理解这个古怪的对象包装基元集合,只是为了使其与数据绑定一起工作。是否有其他解决方法?

第二个问题:我真正的问题来自于一个需求。在 ViewModel 中,数组值(和其他属性)实际上用于生成图像 - 每次更新 属性 时,都需要刷新图像。对于我的其他(非数组)属性,我这样做:

class ViewModel
{
    public int SomeProperty
    {
      get { return _model.SomeProperty; }
      set { _model.SomeProperty = value; RefreshImage(); }
    }
}

但我不知道如何在更改 MyArray 值的情况下更新图像。我试过订阅 ObservableCollection 的 CollectionChanged 事件,但它仅由按钮单击(对象替换)触发,而不是由滑块触发(因为它们更新集合中对象内的值)。我可以使 Wrap 对象 INotifyPropertyChanged 并订阅 ObservableCollection.PropertyChanged...但随后我从模型内部发送通知,(根据我读过的所有内容)你不应该这样做MVVM。因此,当用户操作滑块时,我要么无法刷新图像,要么必须基本上破坏 MVVM - 在我的模型中使用 INotifyPropertyChanged、ObservableCollections 和包装器。当然必须有更清洁的方法来解决这个问题...?

好问题。

问题 #1。我一直在 SO 上看到的一件事是人们声称模型不应该执行 INPC。我是一个绝对的 MVVM 纯粹主义者,以至于我参与了非常大的全栈应用程序并坚持(成功地)没有一行代码隐藏。这是一个连 Josh Smith 都认为过于极端的立场,但即使是像我这样的铁杆纯粹主义者也承认,将此功能添加到模型层是实际必要的。 INPC 不是特定于视图的概念;视图及其相应的视图模型在设计上是瞬态的。这意味着您有两种选择之一:要么几乎完全在视图模型层中复制模型层(当您的时间和预算紧张时容易出错且完全不切实际),或者为模型层提供一种机制对视图模型进行更改通知。如果您确实选择了第二种方法,那么您到底为什么要自己动手而不是使用 WPF 中已经内置的久经考验的机制呢?每一个物有所值的现代 ORM 都允许您 substitute in proxy objects with INPC, in cases of code-first design you can even add it at compile time automatically with a post-processing step.

问题 #2:我需要查看更多您的代码,但通常您只需要再次调用 RaisePropertyChange(或其他任何内容,具体取决于您使用的框架)并传入属性 为你的形象。

为了避免更改我的模型(以及依赖它的所有其他项目),我最终通过在我的 VM 中存储一个重复的集合来解决这个问题,并对模型进行 pushing/pulling 更改。它可能不是那么优雅,但它很好地完成了工作:我可以从滑块、按钮更新数组,并在每次更新后刷新图像——同时将模型的数组保留为常规的旧基元。

我最终得到了类似下面的结果。 TrulyObservableCollection 来自 here(修改为报告 NotifyCollectionChangedAction.Replace 而不是 Reset),而 BindableUint 只是一个实现 INotifyPropertyChanged 的​​包装器。

class ViewModel
{
    Model _model = new Model();
    TrulyObservableCollection<BindableUInt> _myArrayLocal = null;

    //Bind the ItemsControl to this:
    public TrulyObservableCollection<BindableUInt> MyArray
    {
        get
        {
            //If this is the first time we're GETTING this property, make a local copy
            //from the Model
            if (_myArrayLocal == null)
            {
                _myArrayLocal = new TrulyObservableCollection<BindableUInt>();
                for (var i = 0; i < _model.MyArray.Length; i++)
                    _myArrayLocal.Add(new BindableUInt(_model.MyArray[i]));

                _myArrayLocal.CollectionChanged += myArrayLocal_CollectionChanged;
            }

            return _myArrayLocal;
        }
        set 
        {
            //If we're SETTING, push the new (local) array back to the model
            _myArrayLocal = value;
            for (var i = 0; i < value.Count; i++) 
                _model.MyArray[i] = value[i].Value;

            RefreshImage();
        }
    }

    /// <summary>
    /// Called when one of the items in the local collection changes;
    /// push the changed item back to the model, & update the display.
    /// </summary>
    private void myArrayLocal_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        //Sanity checks here
        int changedIndex = e.NewStartingIndex;
        if (_model.MyArray[changedIndex] != _myArrayLocal[changedIndex].Value)
        {
            _model.MyArray[changedIndex] = _myArrayLocal[changedIndex].Value;
            RefreshImage();
        }
    }

    /// <summary>
    /// When using the button to modify the array, make sure we pull the
    /// updated copy back to the VM
    /// </summary>
    private void ButtonClick()
    {
        _model.ResetMyArray();
        for (var i = 0; i < _model.MyArray.Length; i++)
            _myArrayLocal[i].Value = _model.MyArray[i];
        RefreshImage();
    }
}