簡體   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