MVVMでUndo, Redoの実装 前編

普段アプリケーションはビューアとかデータコンバータとか単機能のちょっとしたツールしか作らないんですが、 込み入ったアプリケーションをWPFで作る必要が出てきちゃいました。
Undo機能があると便利だなーと思ったんですが意外に定番の方法が見つからない・・
普段なら諦めるところですが、珍しく前向きに取り組んだのでまとめておきます。

Mementoパターン

Undo,Redoに使われるデザインパターンとしてMementoというパターンがあります。
Mementoは「記念品」や「思い出の品」という意味らしいです。
メメントというとクリストファー・ノーラン監督の映画を思い出してしまいますが、単語の意味を知ると「なるほどなー」って合点が行きますね。

ja.wikipedia.org

Wikipediaで例示されているコードをクラス図にするとこんな感じです。

Originatorは対象のオブジェクト、Mementoは状態オブジェクト、CaretakerはMementoを管理をしているオブジェクトです。

操作者であるmain関数が値のセットとMementoの取得、保存を実行している感じですかね。

MVVMではプロパティの変化を購読できるのでその辺りを利用してMementoの生成と管理をできればViewModelに手を入れずに機能を追加できそうです。

サンプル

まずはUndo, Redoを組み込む先のアプリケーションをでっち上げます。

string, int, bool型のプロパティを持つViewModelを作成します。
Viewはそれぞれのプロパティを操作できるコントロールをStackPanelで配置しています。

class ViewModel : INotifyPropertyChanged {
    public event PropertyChangedEventHandler? PropertyChanged;

    private string _Text = "SampleText1";
    public string Text
    {
        get => _Text;
        set => SetProperty(ref _Text, value);
    }

    private int _Integer = 100;
    public int Integer
    {
        get => _Integer;
        set => SetProperty(ref _Integer, value);
    }

    private bool _Boolean;
    public bool Boolean
    {
        get => _Boolean;
        set => SetProperty(ref _Boolean, value);
    }

    protected void SetProperty<T>(ref T target, T value, [CallerMemberName] string? propertyName = null) {
        if (!Equals(target, value)) {
            target = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

それ以外のコードは こちらで確認できます。

このアプリケーションにUndo機能を追加していきます。

Caretaker

まずはMementoの定義をしましょう。
ViewModelにはいろんな型があるのでプロパティ名とデータをメンバに用意します。データの操作はリフレクションで行えば良いでしょう。
WikipediaによるとMementoオブジェクトはCaretakerによって変更されてはいけないということなので、プロパティはgetのみとします。

class Memento {
    public object Target { get; }
    public string PropertyName { get; }
    public object Data { get; }

    public Memento(object target, string propertyName, object data)  {
        Target = target;
        PropertyName = propertyName;
        Data = data;
    }
}

ViewModelのプロパティ操作はViewが担うのでそちらに任せるとして、Mementoの管理を行うCaretakerを作成します。

Caretakerに必要な要素は下記の3つです。

  • Mementoのコレクション
  • ViewModelのプロパティ変化をキャッチしてMemento作成
  • Mementoの書き戻し

Mementoのコレクション

Mementoのコレクションは後入れ先出し(LIFO)で操作したのでStackを利用します。

Stack<Memento> Mementos { get; } = new Stack<Memento>();

Memento作成

Mementoを作成するには変更前の値が必要となりますが、INotifyPropertyChanged.OnPropertyChanged()では変更後のプロパティしか取得できません。
ViewModel側で何か工夫するのも癪なので、CaretakerにViewModelを登録した段階でオブジェクトの初期値を取得します。 保存先としてプロパティ名をキーとしたDictionaryを用意します。ViewModelが複数登録されることを想定しているのでプロパティのDictionaryをViewModelのオブジェクトをキーにしたDictionary化します。

Dictionary<object, Dictionary<string, object>> Values { get; } = new Dictionary<object, Dictionary<string, object>>();

初期値の取得はLINQでCanWriteのプロパティを雑にGetValueで実現します。(実際はAttributeとかでやった方が良いんですかね?)

Values[target] = target.GetType()
  .GetProperties()
  .Where(p => p.CanWrite)
  .ToDictionary(p => p.Name, p => p.GetValue(nc)!);  

PropertyChangedが発火した時はDictionaryに保存してあるプロパティからMementoを作成し、現在のプロパティをDictionaryに保存しなおします。

target.PropertyChanged += (s, e) => {
   var t = (object)s!;
   var n = (string)e.PropertyName!;
   Mementos.Push(new Memento(t, n, Values[t][n]!));
   Values[t][n] = target.GetType().GetProperty(n)?.GetValue(target)!;
};

実際はPropertyChangedのハンドラはこのままではうまく行きません。
後述するUndoでプロパティを書き戻した際にもPropertyChangedは発火するのでMementoを作成してしまいおかしなことになってしまいます。
これを防ぐためにUndo実行時はフラグなどでMementoの作成を行わないようにすれば良いでしょう。

Mementoの書き戻し

UndoはPropertyChangedと逆にMementoを取り出してプロパティにセットします。

public void Undo() {
  try
  {
    Active = false;
    var memento = Mementos.Pop();
    Values[memento.Target][memento.PropertyName] = memento.Data;

    var property = memento.Target.GetType().GetProperty(memento.PropertyName);
    property?.SetValue(memento.Target, memento.Data);
  }
  finally
  {
    Active = true;
  }
}

最初にfalseにしているフラグActiveはMementoを保存するか否かを示すフラグです。このフラグがfalseの場合、PropertyChangedが発火してもMementoを保存しない用にハンドラを実装しておきましょう。

また、Commandを提供したいのでUndo可能かを示すプロパティも用意します。 StackにMementoがあればUndo可能なので、StackへのPush,Popが生じたタイミングで更新します

CanUndo = Mementos.Count > 0;

Command

Commandはかなり単純に作成できます。CanExecuteExecuteもCaretakerへの移譲で完了です。   CanExecuteChangedのコールはCaretakerのProperyChangedで行います。

インスタンスの注入が面倒なのでCaretekerはシングルトンにしちゃいます。

class UndoCommand : ICommand {
  public event EventHandler? CanExecuteChanged;

  public UndoCommand() {
      Caretaker.Instance.PropertyChanged += (s, e)
        => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
  }
  public bool CanExecute(object? parameter) => Caretaker.Instance.CanUndo;
  public void Execute(object? parameter) => Caretaker.Instance.Undo();
}

ViewModelの登録

ViewModelをMainWindowのDataContextに設定する前にViewModelをCaretakerに登録します。

注入はApp.xamlのコードビハインドで行うのが一般的だと思うのでそれに倣います。

<Application x:Class="undo_sample.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:undo_sample"
             Startup="Application_Startup">
    <Application.Resources>
         
    </Application.Resources>
</Application>
public partial class App : Application
{
    private void Application_Startup(object sender, StartupEventArgs e)
    {
        var vm = new ViewModel();
        Caretaker.Instance.Register(vm);
        new MainWindow { DataContext = vm }.Show();
    }
}

UndoCommandのBinding

先程作成したCommandをMainWindowにBindします。
ButtonにBindしてみました。

<Button Content="Undo">
    <Button.Command>
        <local:UndoCommand/>
    </Button.Command>
</Button>

完成

以上でUndoが実装されました。
ViewModelは特にUndoのことを意識しないでできたので当初の思惑通りにできました。

続いてRedoですが、長くなってきたので今回はここまで。

今回のプロジェクトは以下のコミットで参照できます。

github.com