繁体   English   中英

C# WPF 带有 INotifyPropertyChanged 的 MVVM 撤消系统

[英]C# WPF MVVM undo system with INotifyPropertyChanged

我正在尝试创建一个撤消系统,当实现 INotifyPropertyChanged 的 object 上的属性发生更改时,属性名称及其旧值通过 KeyValuePair 推送到堆栈中。 当用户单击“撤消”时,它会从堆栈中弹出并使用反射将属性的值设置为其旧值。 问题在于它再次调用 OnPropertyChanged,因此该属性及其恢复的值再次添加到撤消堆栈中。 另一方面,我仍然希望它调用 OnPropertyChanged,因为我希望视图更新其绑定。 我的设计方式显然有问题,但我似乎无法找到另一种解决方法。

这是我的 model

internal class MyModel : INotifyPropertyChangedExtended
{
    private string testProperty1 = "";

    public string TestProperty1
    {
        get { return testProperty1; }
        set {
            var oldValue = testProperty1;
            testProperty1 = value;
            OnPropertyChanged(nameof(TestProperty1), oldValue);
        }
    }
    
    private string testProperty2 = "";

    public string TestProperty2
    {
        get { return testProperty2; }
        set {
            var oldValue = testProperty2;
            testProperty2 = value;
            OnPropertyChanged(nameof(TestProperty2), oldValue);
        }
    }
    
    public event PropertyChangedEventHandler? PropertyChanged;
    
    public void OnPropertyChanged(string propertyName, object oldValue)
    {
        if (PropertyChanged != null)
        {
            PropertyChanged(this, new PropertyChangedEventArgsExtended(propertyName, oldValue));
        }
    }
}

这是我的 INotifyPropertyChangedExtended 接口

public class PropertyChangedEventArgsExtended : PropertyChangedEventArgs
{
    public virtual object OldValue { get; private set; }

    public PropertyChangedEventArgsExtended(string propertyName, object oldValue)
           : base(propertyName)
    {
        OldValue = oldValue;
    }
}

public class INotifyPropertyChangedExtended : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;
    protected void NotifyPropertyChanged(string propertyName, object oldValue)
    {
        if (PropertyChanged != null)
            PropertyChanged(this, new PropertyChangedEventArgsExtended(propertyName, oldValue));
    }
}

这是我的观点 model

internal class MyViewModel
{
    public MyModel MyModel { get; set; } = new();
    
    public Stack<KeyValuePair<string, object>> PropertyStateStack = new();
    public RelayCommand Undo { get; set; }

    public MyViewModel()
    {
        SetupCommands();

        MyModel.PropertyChanged += MyModel_PropertyChanged;
    }

    private void MyModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
    {
        var args = e as PropertyChangedEventArgsExtended;

        if (args.OldValue != null)
        {
            PropertyStateStack.Push(new KeyValuePair<string, object>(args.PropertyName, args.OldValue));
        }
    }
    
    private void SetupCommands()
    {
        Undo = new RelayCommand(o =>
        {
            KeyValuePair<string, object> propertyState = PropertyStateStack.Pop();
            PropertyInfo? property = MyModel.GetType().GetProperty(propertyState.Key);

            if (property != null)
            {
                property.SetValue(MyModel, Convert.ChangeType(propertyState.Value, property.PropertyType), null);
            }
        });
    }
}

编辑:我确实研究了“纪念品模式”,但我无法让它与 INotifyPropertyChanged 一起使用,因为一旦我将 MyModel 设置为它的备份,与视图的绑定就会停止工作。

实施 Memento 或变体是正确的方法。 与存储特定的修改撤消操作相反,例如Action<T> (另一个好的解决方案),Memento 具有更高的 memory 占用空间(因为它存储了完整的 object 状态),但允许随机访问存储的状态。

关键是,当正确实现 Memento 时,你不必依赖反射,这只会让你的代码变得又慢又笨重。

下面的示例使用IEditableObject接口来实现 Memento 模式(变体)。 TextBox使用相同的接口以类似的方式实现撤消/重做。 优点是您可以完全控制何时记录 object state。 您甚至可以取消正在进行的修改。

此示例克隆完整的 object 以备份 state。 请参阅上面IEditableObject链接提供的示例,了解如何引入包含对象数据的不可变数据 model。
因为对象可能非常昂贵,例如当它们分配资源时,引入一个实际存储公共可编辑属性值的不可变数据 model 可能是有意义的。 这可以提高关键场景中的性能。

实际的撤消/重做逻辑封装在示例抽象StateTracker<TData> class 中。 StateTracker<TData>实现了前面提到的IEditableObjectICloneable接口。 为了增加便利StateTracker<TData>还实现了一个自定义的IUndoable接口(以启用匿名使用)。

每个需要支持 state 跟踪(撤消/重做)的 class 都必须扩展抽象StateTracker<TData>以提供ICloneable.CloneStateTracker.UpdateState实现。

下面的例子是非常基本的。 它允许撤消和重做,但不支持对撤消/重做状态的随机访问。 您必须使用像List<T>这样的基于索引的后备存储来实现这样的功能。

IUndoable.cs
启用对撤消/重做 API 的匿名访问。

public interface IUndoable
{
  bool TryUndo();
  bool TryRedo();
}

状态跟踪器.cs
封装实际的撤消/重做逻辑以避免重复实现
对于应该支持撤消/重做的每种类型。

您可以考虑在此 class 中添加公共UndoCommandRedoCommand ,并让命令分别调用TryUndoTryRedo

public abstract class StateTracker<TData> : IEditableObject, IUndoable, ICloneable
{
  public bool IsInEditMode { get; private set; }
  private Stack<TData> UndoMemory { get; }
  private Stack<TData> RedoMemory { get; }
  private TData CurrentState { get; set; }
  private bool IsUpdatingState { get; set; }

  protected StateTracker()
  {
    this.UndoMemory = new Stack<TData>();
    this.RedoMemory = new Stack<TData>();
  }

  public abstract TData Clone();
  protected abstract void UpdateState(TData state);

  object ICloneable.Clone() => Clone();

  public bool TryUndo()
  {
    if (this.UndoMemory.TryPop(out TData previousState))
    {
      this.IsUpdatingState = true;

      this.CurrentState = Clone();
      this.RedoMemory.Push(this.CurrentState);

      UpdateState(previousState);

      this.IsUpdatingState = false;

      return true;
    }

    return false;
  }

  public bool TryRedo()
  {
    if (this.RedoMemory.TryPop(out TData nextState))
    {
      this.IsUpdatingState = true;

      this.CurrentState = Clone();
      this.UndoMemory.Push(this.CurrentState);

      UpdateState(nextState);

      this.IsUpdatingState = false;

      return true;
    }

    return false;
  }

  public void BeginEdit()
  {
    if (this.IsInEditMode || this.IsUpdatingState)
    {
      return;
    }

    this.IsInEditMode = true;
    this.CurrentState = Clone();
  }

  public void CancelEdit()
  {
    if (this.IsInEditMode)
    {
      UpdateState(this.CurrentState);
      this.IsInEditMode = false;
    }
  }

  public void EndEdit()
  {
    if (this.IsUpdatingState)
    {
      return;
    }

    this.UndoMemory.Push(this.CurrentState);
    this.IsInEditMode = false;
  }
}

我的模型.cs

public class MyModel : StateTracker<MyModel>, INotifyPropertyChanged
{
  public event PropertyChangedEventHandler PropertyChanged;

  public MyModel()
  {
  }

  // Copy constructor
  private MyModel(MyModel originalInstance)
  {
    // Don't raise PropertyChanged to avoid the loop of death
    this.testProperty1 = originalInstance.TestProperty1;
    this.testProperty2 = originalInstance.TestProperty2;
  }

  // Create a deep copy using the copy constructor
  public override MyModel Clone()
  {
    var copyOfInstance = new MyModel(this);
    return copyOfInstance;
  }

  protected override void UpdateState(MyModel state)
  {
    this.TestProperty1 = state.TestProperty1;
    this.TestProperty2 = state.TestProperty2;
  }

  private void OnPropertyChanged([CallerMemberName] string propertyName = null)
    => this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));

  private string testProperty1;
  public string TestProperty1
  {
    get => this.testProperty1;
    set
    {
      this.testProperty1 = value;
      OnPropertyChanged();
    }
  }

  private string testProperty2;
  public string TestProperty2
  {
    get => this.testProperty2;
    set
    {
      this.testProperty2 = value;
      OnPropertyChanged();
    }
  }
}

例子

以下示例存储绑定到MyModel实例的TextBox的 state。 TextBox获得焦点时,调用MyModel.BeginEdit方法开始记录输入。 TextBox失去焦点时,通过调用MyModel.EndEdit方法将记录的 state 推入撤消堆栈。

主窗口.xaml

<Window>
  <Window.DataContext>
    <local:MyModel />
  </Window.DataContext>

  <StackPanel>
    <Button Content="Undo"
            Click="OnUndoButtonClick" />
    <Button Content="Redo"
            Click="OnRedoButtonClick" />

    <TextBox Text="{Binding TestProperty1, UpdateSourceTrigger=PropertyChanged}" 
             GotFocus="OnTextBoxGotFocus" 
             LostFocus="OnTextBoxLostFocus" />
  </StackPanel>
</Window>

主窗口.xaml.cs
由于定义了接口,我们可以在不知道实际数据类型的情况下处理撤消/重做。

private void OnTextBoxGotFocus(object sender, RoutedEventArgs e) 
  => ((sender as FrameworkElement).DataContext as IEditableObject).BeginEdit();

private void OnTextBoxLostFocus(object sender, RoutedEventArgs e) 
  => ((sender as FrameworkElement).DataContext as IEditableObject).EndEdit();

private void OnUndoButtonClick(object sender, RoutedEventArgs e) 
  => _ = ((sender as FrameworkElement).DataContext as IUndoable).TryUndo();

private void OnRedoButtonClick(object sender, RoutedEventArgs e) 
  => _ = ((sender as FrameworkElement).DataContext as IUndoable).TryRedo();

另一种流程可能是MyModel EndEdit在相关属性设置器内部调用BeginEdit和 EndEdit(在接受新值之前和接受新值之后)。
在这种情况下,不需要先前在TextBox上定义的GotFocusLostFocus事件处理程序(上面的示例):

我的模型.cs

public class MyModel : StateTracker<MyModel>, INotifyPropertyChanged
{
  public event PropertyChangedEventHandler PropertyChanged;

  public MyModel()
  {
  }

  // Copy constructor
  private MyModel(MyModel originalInstance)
  {
    // Don't raise PropertyChanged to avoid the loop of death
    this.testProperty1 = originalInstance.TestProperty1;
    this.testProperty2 = originalInstance.TestProperty2;
  }

  // Create a deep copy using the copy constructor
  public override MyModel Clone()
  {
    var copyOfInstance = new MyModel(this);
    return copyOfInstance;
  }

  protected override void UpdateState(MyModel state)
  {
    // UpdateState() is called by the StateTracker
    // which internally guards against the infinite loop
    this.TestProperty1 = state.TestProperty1;
    this.TestProperty2 = state.TestProperty2;
  }

  private void OnPropertyChanged([CallerMemberName] string propertyName = null)
    => this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));

  private string testProperty1;
  public string TestProperty1
  {
    get => this.testProperty1;
    set
    {
      BeginEdit();
      this.testProperty1 = value;
      EndEdit();

      OnPropertyChanged();
    }
  }

  private string testProperty2;
  public string TestProperty2
  {
    get => this.testProperty2;
    set
    {
      BeginEdit();
      this.testProperty2 = value;
      EndEdit();

      OnPropertyChanged();
    }
  }
}

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM