こんばんは。
現在鋭意開発中のboiler's Graphicsですが、バージョンは最新でv3.4となり、成熟してきたな...と思いつつもまだまだ開発できる部分はあり、スキマ時間で作業に取り組んでおります。
前書き
boiler's Graphicsのバージョン3ではレイヤー機能を実装しました。これが大変だったのですが、次のバージョン4ではUndoとRedoを実装します。これに着手したところ、1から作ろうとすると思い通りにいかず、苦労しました。私の手元にはPro WPF in C# 2010という分厚い本があるのですが、これにUndoに関する記載(p.286-291)がありましたが、これはUIElementのプロパティ値変更をUndoする方式であり、DataContextの変更をUndoする方式ではなかったため、採用を見送りました。
で、結局どうしたかというと、Web上にUndo/Redoのライブラリがないか探しました。探したところ、p4j4dyxcryさんが開発したTsOperationHistoryというライブラリを見つけました。
このライブラリが使用できるか検証してみたのですが、MITライセンスであることもあり、おおむね使用できるが、一部不足している機能があるという判断になりました。
今回の記事ではその一部不足している機能を追加実装しようというものです。
TsOperationHistoryの機能紹介
TsOperationHistoryがどんな風につかえるのかを紹介します。
まあ、TsOperationHistory.Test/UnitTest.cs
を見てもらえば一目瞭然なので、こちらを引用させてもらいます。
using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using TsOperationHistory.Extensions;
using Xunit;
namespace TsOperationHistory.Test
{
internal class Person : Bindable
{
private string _name;
public string Name
{
get => _name;
set => SetProperty(ref _name, value);
}
private int _age;
public int Age
{
get => _age;
set => SetProperty(ref _age, value);
}
private ObservableCollection<Person> _children = new ObservableCollection<Person>();
public ObservableCollection<Person> Children
{
get => _children;
set => SetProperty(ref _children, value);
}
}
public class UnitTest
{
/// <summary>
/// 基本的なUndoRedoのテスト
/// </summary>
[Fact]
public void BasicTest()
{
IOperationController controller = new OperationController();
var person = new Person()
{
Name = "Venus",
};
controller.Execute(person.GenerateSetPropertyOperation(x=>x.Name , "Yamada"));
Assert.Equal("Yamada",person.Name);
controller.Execute(person.GenerateSetPropertyOperation(x=>x.Name , "Tanaka"));
Assert.Equal("Tanaka",person.Name);
controller.Undo();
Assert.Equal("Yamada",person.Name);
controller.Undo();
Assert.Equal("Venus",person.Name);
}
/// <summary>
/// Operationの自動結合テスト
/// </summary>
[Fact]
public async void MergedTest()
{
IOperationController controller = new OperationController();
var person = new Person()
{
Age = 14,
};
// デフォルトのマージ時間を 70msに設定
Operation.DefaultMergeSpan = TimeSpan.FromMilliseconds(70);
//Age = 30
controller.ExecuteSetProperty(person,nameof(Person.Age),30);
Assert.Equal(30, person.Age );
//10 ms待つ
await Task.Delay(10);
//Age = 100
controller.ExecuteSetProperty(person,nameof(Person.Age),100);
Assert.Equal(100, person.Age );
//100ms 待つ
await Task.Delay(75);
//Age = 150
controller.ExecuteSetProperty(person,nameof(Person.Age),150);
Assert.Equal(150, person.Age );
//Age = 100
controller.Undo();
Assert.Equal(100, person.Age );
// マージされているので 30には戻らずそのまま14に戻る
// Age = 14
controller.Undo();
Assert.Equal(14, person.Age );
}
/// <summary>
/// リスト操作のテスト
/// </summary>
[Fact]
public void ListTest()
{
IOperationController controller = new OperationController();
var person = new Person()
{
Name = "Root"
};
controller.ExecuteAdd(person.Children ,
new Person()
{
Name = "Child1"
});
controller.ExecuteAdd(person.Children ,
new Person()
{
Name = "Child2"
});
Assert.Equal(2 , person.Children.Count);
controller.ExecuteRemoveAt(person.Children,0);
Assert.Single(person.Children);
controller.Undo();
Assert.Equal(2 , person.Children.Count);
controller.Undo();
Assert.Single(person.Children);
controller.Undo();
Assert.Empty(person.Children);
}
/// <summary>
/// PropertyChangedを自動的にOperation化するテスト
/// </summary>
[Fact]
public void ObservePropertyChangedTest()
{
IOperationController controller = new OperationController();
var person = new Person()
{
Name = "First",
Age = 0,
};
var nameChangedWatcher = controller.BindPropertyChanged<string>(person, nameof(Person.Name),false);
var ageChangedWatcher = controller.BindPropertyChanged<int>(person, nameof(Person.Age));
// 変更通知から自動的に Undo / Redo が可能なOperationをスタックに積む
{
person.Name = "Yammada";
person.Name = "Tanaka";
Assert.True(controller.CanUndo);
controller.Undo();
Assert.Equal("Yammada",person.Name);
controller.Undo();
Assert.Equal("First",person.Name);
}
// Dispose後は変更通知が自動的にOperationに変更されないことを確認
{
nameChangedWatcher.Dispose();
person.Name = "Tanaka";
Assert.False(controller.CanUndo);
controller.Undo();
Assert.Equal("Tanaka",person.Name);
}
// Ageは自動マージ有効なため1回のUndoで初期値に戻ることを確認
{
for (int i = 1; i < 30; ++i)
{
person.Age = i;
}
Assert.Equal(29,person.Age);
controller.Undo();
Assert.Equal(0,person.Age);
ageChangedWatcher.Dispose();
}
}
[Fact]
public void RecorderTest()
{
IOperationController controller = new OperationController();
var person = new Person()
{
Name = "Default",
Age = 5,
};
var recorder = new OperationRecorder(controller);
// 操作の記録開始
recorder.BeginRecode();
{
recorder.Current.ExecuteAdd(person.Children,new Person()
{
Name = "Child1",
});
recorder.Current.ExecuteSetProperty(person , nameof(Person.Age) , 14);
recorder.Current.ExecuteSetProperty(person , nameof(Person.Name) , "Changed");
}
// 操作の記録完了
recorder.EndRecode("Fixed");
// 1回のUndoでレコード前のデータが復元される
controller.Undo();
Assert.Equal("Default",person.Name);
Assert.Equal(5,person.Age);
Assert.Empty(person.Children);
// Redoでレコード終了後のデータが復元される
controller.Redo();
Assert.Equal("Changed",person.Name);
Assert.Equal(14,person.Age);
Assert.Single(person.Children);
}
}
}
要約すると以下のようになります。
- OperationControllerクラスのインスタンスに、プロパティ値を変更するインスタンスと、プロパティと、値を登録します。
- OperationController.Undo()でUndoします。
- Operationcontroller.Redo()でRedoします。
- Undo履歴を登録する時に使用するメソッドはいくつかバリエーションがあります。例えば、リストのインスタンスにオブジェクトを追加する時に使うExecuteAdd(IList, T value)や、インスタンスのプロパティに値をセットする時に使うExecuteSetProperty(T owner, string propertyName, TProperty value)があります。
- 複数のUndo履歴を一度でUndoするRecorder機能があります。Undo履歴を登録する前に、OperationRecorder.BeginRecord()し、Undo履歴を登録した後で、OperationRecorder.EndRecord(string message)します。
不足する機能について
上記の要約を読んでもらえば、このライブラリの便利さがだいたいわかってもらえるのではないかと思ってます。しかし、このライブラリには足りない機能があります。下記に示します。
(1) インデクサを経由する値変更をサポートしていない。
(2) プロパティ名に"."を含めたプロパティチェーン(例えば、Left.Value)をサポートしていない。
(3) クラスの静的プロパティの値変更をサポートしていない。
(4) IDisposable.Dispose()をUndoする仕組みがない。
(1)はインデクサのsetterに値を渡すのをどうやろうか悩みましたが、原始的な方法でpropertyNameを解釈し、indexを取得してSetValue(object target, int index, object value)に渡すことで解決しました。GetValue(object target, int index)も同様です。
(2)はReactivePropertyを使っていれば誰でも気づくと思います。ReactivePropertyクラス/ReactivePropertySlimクラスはValueプロパティに生の値を保持するという仕組みですが、TsOperationHistoryは例えばLeft.Valueというような"."で接続されたプロパティチェーンを解釈できないという致命的な問題を抱えています。なので私が修正しました。
(1)と(2)は私が修正して、https://github.com/dhq-boiler/TsOperationHistoryにプッシュしておきましたので、誰でも利用できます。
(3)は、私がこれだと思って実装したコードが上手くいかなかったので、残念ながらサポートを見送りました。Delegate.CreateDelegate(getterDelegateType, getInfo)する時に「System.ArgumentException: 'ターゲット メソッドとデリゲート型との間に、シグネチャまたはセキュリティ透過性の互換性がないため、ターゲット メソッドにバインドできません。'」という例外が出てしまうのです。場所はFastReflection.csのline.77にあるCreateIAccessorWithType(object, string)です。
(4)は絶賛検討中現在進行系です!
まとめ
いかがだったでしょうか。この記事が少しでもTsOperationHistoryが便利だったということを理解する助けになれば幸いです。これを機に、このライブラリの利用者が増えて、TsOperationHistory本家リポジトリのアーカイブが解除されて、引き続き開発継続されれば良いなぁ~とちょっと思ったりします。でもライブラリ開発は大変ですからね、強要はできませんが。私も気づいたところがあれば、こちらにコミットしていきたいと思っています。