简体   繁体   English

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

[英]C# WPF MVVM undo system with INotifyPropertyChanged

I am attempting to make an undo system, where when a property on an object that implements INotifyPropertyChanged is changed, the property name and its old value is pushed onto a stack via a KeyValuePair.我正在尝试创建一个撤消系统,当实现 INotifyPropertyChanged 的 object 上的属性发生更改时,属性名称及其旧值通过 KeyValuePair 推送到堆栈中。 When the user clicks "Undo" it then pops from the stack and uses reflection to set the property's value to its old value.当用户单击“撤消”时,它会从堆栈中弹出并使用反射将属性的值设置为其旧值。 The problem with this is that it calls OnPropertyChanged again, so the property and its restored value is added to the undo stack a second time.问题在于它再次调用 OnPropertyChanged,因此该属性及其恢复的值再次添加到撤消堆栈中。 On the other hand, I still want it to call OnPropertyChanged since I want the view to update its bindings.另一方面,我仍然希望它调用 OnPropertyChanged,因为我希望视图更新其绑定。 There's obviously something wrong with how I'm designing it, but I can't seem to figure out another way of going about it.我的设计方式显然有问题,但我似乎无法找到另一种解决方法。

Here's my model这是我的 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));
        }
    }
}

Here's my INotifyPropertyChangedExtended interface这是我的 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));
    }
}

And here's my view model这是我的观点 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);
            }
        });
    }
}

EDIT: I did research the "memento pattern" but I couldn't get it to work with INotifyPropertyChanged, since as soon as I set MyModel to a backup of it the bindings to the view stopped working.编辑:我确实研究了“纪念品模式”,但我无法让它与 INotifyPropertyChanged 一起使用,因为一旦我将 MyModel 设置为它的备份,与视图的绑定就会停止工作。

Implementing Memento or a variant is the right way.实施 Memento 或变体是正确的方法。 Opposed to storing the particular modifying undo action eg, Action<T> (another good solution), Memento has a higher memory footprint (as it stores the complete object state), but allows random access to the stored states.与存储特定的修改撤消操作相反,例如Action<T> (另一个好的解决方案),Memento 具有更高的 memory 占用空间(因为它存储了完整的 object 状态),但允许随机访问存储的状态。

The key point is that when implementing Memento properly, you don't have to rely on reflection, which will only make your code slow and heavy.关键是,当正确实现 Memento 时,你不必依赖反射,这只会让你的代码变得又慢又笨重。

The following example uses the IEditableObject interface to implement the Memento pattern (variant).下面的示例使用IEditableObject接口来实现 Memento 模式(变体)。 TextBox is implementing undo/redo in a similar way using the same interface. TextBox使用相同的接口以类似的方式实现撤消/重做。 The advantage is that you have full control when to record object state.优点是您可以完全控制何时记录 object state。 You can even cancel the ongoing modification.您甚至可以取消正在进行的修改。

This example clones the complete object to backup the state.此示例克隆完整的 object 以备份 state。 See the example provided by the IEditableObject link above to learn how to introduce an immutable data model that holds the object's data.请参阅上面IEditableObject链接提供的示例,了解如何引入包含对象数据的不可变数据 model。
Because objects can be quite expensive, for example when they allocate resources, it could make sense to introduce an immutable data model that actually stores the values of the public editable properties.因为对象可能非常昂贵,例如当它们分配资源时,引入一个实际存储公共可编辑属性值的不可变数据 model 可能是有意义的。 This can improve the performance in critical scenarios.这可以提高关键场景中的性能。

The actual undo/redo logic is encapsulated in the examples abstract StateTracker<TData> class.实际的撤消/重做逻辑封装在示例抽象StateTracker<TData> class 中。 StateTracker<TData> implements the aforementioned IEditableObject and the ICloneable interface. StateTracker<TData>实现了前面提到的IEditableObjectICloneable接口。 To add convenience StateTracker<TData> also implements a custom IUndoable interface (to enable anonymous usage).为了增加便利StateTracker<TData>还实现了一个自定义的IUndoable接口(以启用匿名使用)。

Every class that needs to support state tracking (undo/redo) must extend the abstract StateTracker<TData> to provide a ICloneable.Clone and a StateTracker.UpdateState implementation.每个需要支持 state 跟踪(撤消/重做)的 class 都必须扩展抽象StateTracker<TData>以提供ICloneable.CloneStateTracker.UpdateState实现。

The following example is very basic.下面的例子是非常基本的。 It allows undo and redo, but does not support random access to undo/redo states.它允许撤消和重做,但不支持对撤消/重做状态的随机访问。 You would have to use an index based backing store like List<T> to imlpement such a feature.您必须使用像List<T>这样的基于索引的后备存储来实现这样的功能。

IUndoable.cs IUndoable.cs
Enable anonymous access to the undo/redo API.启用对撤消/重做 API 的匿名访问。

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

StateTracker.cs状态跟踪器.cs
Encapsulates the actual undo/redo logic to avoid duplicate implementations封装实际的撤消/重做逻辑以避免重复实现
for each type that is supposed to support undo/redo.对于应该支持撤消/重做的每种类型。

You can consider to add a public UndoCommand and RedoCommand to this class and let the commands invoke TryUndo and TryRedo respectively.您可以考虑在此 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;
  }
}

MyModel.cs我的模型.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();
    }
  }
}

Example例子

The following example stores the state of a TextBox , that binds to a MyModel instance.以下示例存储绑定到MyModel实例的TextBox的 state。 When the TextBox receives focus, the MyModel.BeginEdit method is called to start recording the input.TextBox获得焦点时,调用MyModel.BeginEdit方法开始记录输入。 When the TextBox loses focus the recorded state is pushed onto the undo stack by calling the MyModel.EndEdit method.TextBox失去焦点时,通过调用MyModel.EndEdit方法将记录的 state 推入撤消堆栈。

MainWindow.xaml主窗口.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>

MainWindow.xaml.cs主窗口.xaml.cs
Because of the defined interfaces we can handle undo/redo without knowing the actual data type.由于定义了接口,我们可以在不知道实际数据类型的情况下处理撤消/重做。

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();

An alternative flow could be that the MyModel class internally calls BeginEdit and EndEdit inside the relevant property setters (before accepting the new value and after accepting the new value).另一种流程可能是MyModel EndEdit在相关属性设置器内部调用BeginEdit和 EndEdit(在接受新值之前和接受新值之后)。
In this case, the GotFocus and LostFocus event handlers previously defined on the TextBox (example above) are not needed:在这种情况下,不需要先前在TextBox上定义的GotFocusLostFocus事件处理程序(上面的示例):

MyModel.cs我的模型.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