C# Wpf mvvm 保持多个 ViewModels 与模型同步

C# Wpf mvvm keep multiple ViewModels with model sychronized

我有数据架构问题。我的目标应该是进行双向数据通信 在 ViewModel 和模型之间 类。我有一个 window 具有不同的用户控件。每个用户控件 有自己的数据,但它们之间共享一些属性。 对于每个 ViewModel,我实现了两个函数来同步模型和视图模型。 该模型应该保持更新,所以我在 属性Changed 事件中实现了方法调用 SyncModel。 到目前为止这还不太好,因为当我调用构造函数时,方法调用链是: 构造函数 -> SyncViewModel -> 属性 setter -> 属性已更改 -> SyncModel

这里有一些示例代码可以更好地理解我的问题:

public class SampleModel
{
    public string Material { get; set; }
    public double Weight { get; set; }

    public double Length { get; set; }
    public double Width { get; set; }
    public double Height { get; set; }

    public object SharedProperty { get; set; }
}

public class SampleViewModelA : AbstractViewModel
{
    public string Material
    {
        get
        {
            return _Material;
        }

        set
        {
            if (value != _Material)
            {
                _Material = value;
                OnPropertyChanged(nameof(Material));
            }
        }
    }
    public double Weight
    {
        get
        {
            return _Weight;
        }

        set
        {
            if (value != _Weight)
            {
                _Weight = value;
                OnPropertyChanged(nameof(Weight));
            }
        }
    }
    public object SharedProperty
    {
        get
        {
            return _SharedProperty;
        }

        set
        {
            if (value != _SharedProperty)
            {
                _SharedProperty = value;
                OnPropertyChanged(nameof(SharedProperty));
            }
        }
    }

    public SampleViewModelA(SampleModel Instance) : base(Instance) { }

    public override void SyncModel()
    {
        //If I wouldn't check here, it would loop:
        //constructor -> SyncViewModel -> Property setter -> PropertyChanged -> SyncModel
        if (Instance.Material == Material &&
            Instance.Weight == Weight &&
            Instance.SharedProperty == SharedProperty)
            return;

        Instance.Material = Material;
        Instance.Weight = Weight;
        Instance.SharedProperty = SharedProperty;
    }

    public override void SyncViewModel()
    {
        Material = Instance.Material;
        Weight = Instance.Weight;
        SharedProperty = Instance.SharedProperty;
    }

    private string _Material;
    private double _Weight;
    private object _SharedProperty;
}

public class SampleViewModelB : AbstractViewModel
{
    //Same like SampleViewModelA with Properties Length, Width, Height AND SharedProperty
}

public abstract class AbstractViewModel : INotifyPropertyChanged
{
    //All ViewModels hold the same Instance of the Model
    public SampleModel Instance { get; set; }

    public event PropertyChangedEventHandler PropertyChanged;

    public AbstractViewModel(SampleModel Instance)
    {
        this.Instance = Instance;

        SyncViewModel();
    }

    protected virtual void OnPropertyChanged(string PropertyName)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(PropertyName));
        SyncModel();
    }

    public abstract void SyncModel();
    public abstract void SyncViewModel();
}

真正的问题是,SharedProperty 需要在 SampleViewModelASampleViewModelB 之间更新。首先,我认为观察者模式可以帮助我,但 SharedProperties 是多种多样的,以使其与通用接口一起工作。然后我认为带有更改事件的数据控制器可以像这样帮助我

public class SampleDataController
{
    public SampleModel Instance { get; set; }

    public delegate void SynchronizeDelegate();
    public event SynchronizeDelegate SynchronizeEvent;

    public void SetSharedProperty(object NewValue)
    {
        if (Instance.SharedProperty != NewValue)
        {
            Instance.SharedProperty = NewValue;
            SynchronizeEvent?.Invoke();
        }
    }
}

如果它这样做,我的 AbstractViewModel 将只与控制器而不是实例通信。 SyncModel 函数会调用 SetSharedProperty 之类的方法,而不是直接访问。 MainViewModel 代码可能如下所示。

public class SampleMainViewModel
{
    public SampleViewModelA ViewModelA { get; set; }
    public SampleViewModelB ViewModelB { get; set; }

    public SampleDataController Controller { get; set; }

    public SampleMainViewModel()
    {
        ViewModelA = new SampleViewModelA(Controller);
        ViewModelB = new SampleViewModelB(Controller);

        Controller.SynchronizeEvent += ViewModelA.SyncViewModel;
        Controller.SynchronizeEvent += ViewModelB.SyncViewModel;
    }
}

这会导致问题,SynchronizeEvent 调用的来源也是 订阅事件本身。这不会导致无限循环,因为我检查是否 值等于新状态,但对我来说似乎很难看。一定有更好的 远不止于此。

在我的项目中,我有 8 个 ViewModel 和多个模型 类,我需要在其中同步数据 不同的共享属性。

感谢您的帮助,并希望到目前为止问题是可以理解的。

您已经使用了由另一个视图模型 classes SampleViewModelASampleViewModelB.
组成的 SampleMainViewModel 现在您所要做的就是在视图 models/views 之间移动 all shared 的属性(例如 SharedProperty,还有 MaterialWeight) 组合 SampleMainViewModel 或共享 class。这样您的所有控件都可以绑定到相同的数据源。

此外 Model 之间的通信 --> View Model 应该只通过事件发生:Model 可以通过以下方式通知 View Model公开例如 DataChanged 事件。 ModelView Model 之间没有真正的双向 communication/dependency。这就是 MVVM 的主要特征:参与组件的单向依赖 - 通过实现事件、命令,尤其是利用数据绑定来实现。

以下示例显示了如何将控件绑定到共享属性和非共享属性(那些是专用视图模型 classes 的属性)。

MainWindow.xaml

<Window>
  <Window.DataContext>
    <SampleMainViewModel />
  </Window.DataContext>

  <StackPanel>
    <UserControlA Material="{Binding Material}" 
                  SharedProperty="{Binding SharedProperty}" 
                  UnsharedPropertyA="{Binding ViewModelA.UnsharedPropertyA}" />

    <UserControlB Material="{Binding Material}" 
                  SharedProperty="{Binding SharedProperty}" 
                  UnsharedPropertyB="{Binding ViewModelB.UnsharedPropertyB}" />
  </StackPanel>
</Window>

SampleMainViewModel.cs

public class SampleMainViewModel : INotifyPropertyChanged
{
    public SampleViewModelA ViewModelA { get; }
    public SampleViewModelB ViewModelB { get; }

    /* Properties must raise INotifyPropertyChanged.PropertyChanged */
    public string Material { get; set; }
    public double Weight { get; set; }
    public object SharedProperty { get; set; }

    // Example initialization
    public SampleMainViewModel(SomeModelClass someModelClass)
    {
        this.ViewModelA = new SampleViewModelA();
        this.ViewModelB = new SampleViewModelB();

        this.Material = someModelClass.Material;
        this.Weight = someModelClass.Weight;
        this.SharedProperty = someModelClass.SharedProperty;

        someModelClass.DataChanged += UpdateData_OnDataChanged;
    }

    private void UpdateData_OnDataChanged(object sender, EventArgs args)
    {
        var someModelClass = sender as SomeModelClass;
        this.Material = someModelClass.Material;
        this.Weight = someModelClass.Weight;
        this.SharedProperty = someModelClass.SharedProperty;
    }
}

SampleViewModelA.cs

public class SampleViewModelA : INotifyPropertyChanged
{
    public object UnsharedPropertyA { get; set; }
}

SampleViewModelB.cs

public class SampleViewModelB : INotifyPropertyChanged
{
    public object UnsharedPropertyB { get; set; }
}

"Your suggestion cause the disadvantage, that my separated ViewModels are no longer encapsulated. And if I would move all my code with shared properties to the MainViewModel, the class would end up very large"

针对您的评论:如果您坚持每个控件都有一个视图模型,包括复制属性等,那么您必须采取不同的方法。
此外,将 shared/duplicate 代码移出视图模型不会破坏封装 - 假设那些 classes 不包含仅重复的代码。

但请注意,不建议为每个控件创建一个视图模型。您有一个 view/page - 多个控件的集合 - 它具有定义的数据上下文。此视图中的所有控件共享相同的数据上下文 - 通常,因为视图与结构化上下文相关。
这就是默认情况下继承 FrameworkElement.DataContext 的原因。

每个控件都有一个视图模型会使事情变得过于复杂,并导致大量重复代码 - 而不仅仅是像您的示例中那样的重复属性。你会发现自己也在重复逻辑。谈到可测试性,如果你复制逻辑,你也会复制单元测试。 这是因为您正在处理相同的数据和相同的模型 classes.

您通常将重复代码提取到单独的 class 中,这是由依赖于此重复代码的类型引用的。考虑到这种“无重复代码”策略重构您的视图模型 classes 最终会将“共享”属性移动到单独的 class。由于我们讨论的是同一视图的数据上下文,因此这个单独的 class 将是分配给页面 DataContext 的视图模型 class。我想说你的方法恰恰相反:你复制代码(并将其称为封装)。如果 class 最终变得非常大,因为它包含很多属性,那么您可以检查您的 UI 设计 - 也许您应该将您的大页面拆分为更多内容更简洁的页面。这也可能会改善用户体验。

通常,拥有一个具有更多属性的视图模型并没有错。如果您的视图模型 class 也包含很多逻辑,您可以提取此逻辑以分离 classes.

你仍然可以使用上一个例子的模式,即监听模型的数据变化事件。

您要么实现一个非常通用的事件,如上面的 DataChanged 事件,要么实现几个更专业的事件,如 MaterialChanged 事件。还要确保将相同的模型实例注入每个视图模型。
下面的示例展示了如何让多个不同的视图模型 classes 公开相同的数据,其中所有这些视图模型 classes 通过观察它们的模型 classes 来更新自己:

MainWindow.xaml

<Window>
  <Window.DataContext>
    <SampleMainViewModel />
  </Window.DataContext>

  <StackPanel>
    <UserControlA DataContext="{Binding ViewModelA}"
                  Material="{Binding Material}" 
                  Weight="{Binding Weight}" 
                  SharedProperty="{Binding SharedPropertyA}" />

    <UserControlB DataContext="{Binding ViewModelB}"
                  Material="{Binding Material}" 
                  Weight="{Binding Weight}" 
                  SharedProperty="{Binding SharedPropertyB}" />
  </StackPanel>
</Window>

SampleMainViewModel.cs

public class SampleMainViewModel : INotifyPropertyChanged
{
    public SampleViewModelA ViewModelA { get; }
    public SampleViewModelB ViewModelB { get; }

    // Example initialization
    public SampleMainViewModel()
    {
        var sharedModelClass = new SomeModelClass();
        this.ViewModelA = new SampleViewModelA(sharedModelClass);
        this.ViewModelB = new SampleViewModelB(sharedModelClass);
    }
}

SampleViewModelA.cs

public class SampleViewModelA : INotifyPropertyChanged
{    
    /* Shared properties */
    public string Material { get; set; }
    public double Weight { get; set; }
    public object SharedProperty { get; set; }

    private SomeModelClass SomeModelClass { get; }

    // Example initialization
    public SampleViewModelA(SomeModelClass sharedModelClass)
    {    
        this.SomeModelClass = sharedModelClass;

        this.Material = this.SomeModelClass.Material;
        this.Weight = this.SomeModelClass.Weight;
        this.SharedProperty = this.SomeModelClass.SharedProperty;

        // Listen to model changes
        this.SomeModelClass.DataChanged += UpdateData_OnDataChanged;
        this.SomeModelClass.MaterialChanged += OnModelMaterialChanged;
    }

    // Example command handler to send dat back to the model.
    // This will trigger the model to raise corresponding data chnaged events
    // to notify listening view model classes that new data is available.
    private void ExecuteSaveDataCommand()
      => this.SomeModelClass.SaveData(this.Material, this.Weight);

    private void OnModelMaterialChanged(object sender, EventArgs args)
    {
        var someModelClass = sender as SomeModelClass;
        this.Material = someModelClass.Material;
    }

    private void UpdateData_OnDataChanged(object sender, EventArgs args)
    {
        var someModelClass = sender as SomeModelClass;
        this.Weight = someModelClass.Weight;
        this.SharedProperty = someModelClass.SharedProperty;
    }
}

SampleViewModelB.cs

public class SampleViewModelB : INotifyPropertyChanged
{
    /* Shared properties */
    public string Material { get; set; }
    public double Weight { get; set; }
    public object SharedProperty { get; set; }

    private SomeModelClass SomeModelClass { get; }

    // Example initialization
    public SampleViewModelB(SomeModelClass sharedModelClass)
    {    
        this.SomeModelClass = sharedModelClass;

        this.Material = this.SomeModelClass.Material;
        this.Weight = this.SomeModelClass.Weight;
        this.SharedProperty = this.SomeModelClass.SharedProperty;

        // Listen to model changes
        this.SomeModelClass.DataChanged += UpdateData_OnDataChanged;
        this.SomeModelClass.MaterialChanged += OnModelMaterialChanged;
    }

    // Example command handler to send dat back to the model.
    // This will trigger the model to raise corresponding data chnaged events
    // to notify listening view model classes that new data is available.
    private void ExecuteSaveDataCommand()
      => this.SomeModelClass.SaveData(this.Material, this.Weight);

    private void OnModelMaterialChanged(object sender, EventArgs args)
    {
        var someModelClass = sender as SomeModelClass;
        this.Material = someModelClass.Material;
    }

    private void UpdateData_OnDataChanged(object sender, EventArgs args)
    {
        var someModelClass = sender as SomeModelClass;
        this.Weight = someModelClass.Weight;
        this.SharedProperty = someModelClass.SharedProperty;
    }
}

第一个解决方案(旨在消除重复代码)的一个变体是重构您的绑定源,公开共享属性,根据它们的职责将这些属性提取到新的 classes。

例如,您可以让 MainViewModel 公开一个封装 material 相关属性和逻辑的 MaterialViewModel class。这样 MaterialViewModel 可以在全球范围内使用。
鉴于您遵循每个视图一个数据上下文 class 原则,您可以通过仅让特定页面的特定视图模型 class 公开 [=84] 来将共享属性的范围限制在特定页面=]相同 MaterialViewModel 实例:

MainWindow.xaml

<Window>
  <Window.DataContext>
    <MainViewModel />
  </Window.DataContext>

  <StackPanel>
    <MaterialControl DataContext="{Binding MaterialViewModel}"
                     Material="{Binding Material}" 
                     Weight="{Binding Weight}" />

    <UserControlB ... />
  </StackPanel>
</Window>

MainViewModel.cs

public class MainViewModel : INotifyPropertyChanged
{
    // Since defined in 'MainViewModel' the properties of 'MaterialViewModel' 
    // are globally shared accross pages
    public MaterialViewModel MaterialViewModel { get; }

    /* View model classes per page */
    public ViewModelPageA PageViewModelA { get; }

    // If 'ViewModelPageB' would expose a 'MaterialViewModel', 
    // you can limit the visibility of 'MaterialViewModel' to the 'ViewModelPageB' DataContext exclusively
    public ViewModelPageB PageViewModelB { get; }

    // Example initialization
    public SampleMainViewModel()
    {
        var sharedModelClass = new SomeModelClass();
        this.MaterialViewModel = new MaterialViewModel(sharedModelClass);
        this.ViewModelPageA = new ViewModelPageA(sharedModelClass);

        // Introduce the MaterialViewModel to a page specific class
        // to make the properties of 'MaterialViewModel' to be shared inside the page only
        this.ViewModelPageB = new ViewModelPageB(sharedModelClass);
    }
}

MaterialViewModel.cs

public class MaterialViewModel : INotifyPropertyChanged
{
    public string Material { get; set; }
    public double Weight { get; set; }

    private SomeModelClass SomeModelClass { get; }

    // Example initialization
    public MaterialViewModel(SomeModelClass sharedModelClass)
    {    
        this.SomeModelClass = sharedModelClass;

        this.Material = this.SomeModelClass.Material;
        this.Weight = this.SomeModelClass.Weight;

        // Listen to model changes
        this.SomeModelClass.MaterialDataChanged += OnModelMaterialChanged;
    }

    // Example command handler to send dat back to the model.
    // This will also trigger the model to raise corresponding data chnaged events
    // to notify listening view model classes that new data is available.
    // It can make more sense to define such a command in the owning  class,
    // like SampleMainViewModel in this case.
    private void ExecuteSaveDataCommand()
      => this.SomeModelClass.SaveData(this.Material, this.Weight);

    private void UpdateData_MaterialChanged(object sender, EventArgs args)
    {
        var someModelClass = sender as SomeModelClass;
        this.Material = someModelClass.Material;
        this.Weight = someModelClass.Weight;
    }
}