アヒルのある日

株式会社AHIRUの社員ブログです。毎週更新!社員が自由に思いついたことを書きます。

Undo/Redoの実装

こんにちは みにくい社長です。

エディターを作るのって、普通のアプリを作るより大変だったりしますよね。
機能が多くて自由度が高いほど、作れるものの幅は広がりますが、エディターを作る側は大変です。
特にエディターの必須要件のUndo/Redoですが、簡単に見えて実装が難しい機能の1つです。
開発途中に欲しくなって入れようと思っても、簡単に実装できるものではなく、データ設計の時点で想定しておく必要があります。
今日はUndo/Redoの実装方法を紹介します。

実装方法には大きく2種類あります。
(A)操作するたびにデータを全て保存し、Undo/Redo時に復元する
長所:一度実装してしまえばデータが壊れる等のバグが発生しない
短所:保存データのサイズが大きい場合、全ての操作が重くなる

(B)操作した内容のみを履歴として保存しておく
長所:操作が重くならない
短所:全ての操作に対して実装する必要があり、どこかでバグが発生するとデータが壊れ続ける

基本的には(A)の実装の方が安全で容易に実装できますが、エディターで編集するデータが大きい場合は(B)の方法を検討する必要が出てきます。
今日は(A)の方法を紹介します。
言語はC#で、保存フォーマットはXMLを例にします。

(1)保存するデータをシリアライズ/デシリアライズに対応する

まずは、シリアライズ/デシリアライズを実装します。
この機能はUndo/Redoだけでなく、ファイルの読み込み/保存にも流用できます。
保存するデータは変数をpublicにするだけで保存できます。
保存しておきたくないデータがある場合は、privateにするかXmlIgnore属性を設定しましょう。

public class Editor
{
    public class SaveData
    {
        public Vector2 Position; // 保存するデータ
        [System.Xml.Serialization.XmlIgnore]
        public int TempValue;   // 保存したくないデータ
    }
    private SaveData saveData;

    // シリアライズ
    private string serialize()
    {
        System.Xml.Serialization.XmlSerializer serializer = new System.Xml.Serialization.XmlSerializer(typeof(SaveData));
        StringBuilder builder = new StringBuilder();
        System.IO.StringWriter writer = new System.IO.StringWriter(builder);
        serializer.Serialize(writer, saveData);
        writer.Close();
        return builder.ToString();
    }

    // デシリアライズ
    private void deserialize(string xml)
    {
        System.Xml.Serialization.XmlSerializer serializer = new System.Xml.Serialization.XmlSerializer(typeof(SaveData));
        System.IO.StringReader reader = new System.IO.StringReader(xml);
        saveData = (SaveData)serializer.Deserialize(reader);
        reader.Close();
    }
}

(2)操作履歴保存用クラスを作成する

操作履歴をリングバッファに保存するためのクラスを作ります。
Undo/Redoに対応するため、前回の履歴と次の履歴が取得出来るようにしておきます。

class History
{
    // 履歴の最大数
    private static readonly int HISTORY_MAX = 32;

    // 現在のインデックス
    private int _current = 0;
    // 先頭のインデックス
    private int _top = 0;
    // 末尾のインデックス
    private int _tail = 0;
    // 履歴
    private string[] _history = new string[HISTORY_MAX];

    // コンストラクタ
    public History() { }

    // 初期化
    public void initialize(string text)
    {
        _history[0] = text;
        _current = _top = _tail = 0;
    }

    // 履歴を追加
    public void addHistory(string text)
    {
        _current = (_current + 1) % HISTORY_MAX;
        _history[_current] = text;
        _top = _current;
        if (_top == _tail)
        {
            _tail = (_current + 1) % HISTORY_MAX;
        }
    }

    // 前回の履歴を取得
    public string getPrevious()
    {
        if (_current != _tail)
        {
            _current = (_current + HISTORY_MAX - 1) % HISTORY_MAX;
            return _history[_current];
        }
        return null;
    }

    // 次の履歴を取得
    public string getNext()
    {
        if(_current != _top)
        {
            _current = (_current + 1) % HISTORY_MAX;
            return _history[_current];
        }
        return null;
    }
}

(3)操作履歴を保存する

準備ができたので、履歴保存オブジェクトをエディタに追加し、初期化します。
後は操作を行う度に履歴を追加していきます。

private History _history = new History();

public Editor()
{
    // 履歴を初期化
    _history.initialize(serialize());
}

public void SetPosition(Vector2 position)
{
    saveData.Position = position;
    // 履歴を追加
    _history.addHistory(serialize());
}

(4)操作履歴から復元する

ここまでくれば、後は単純です。
Undo/Redoを実行するときは、前回の履歴を読み込むだけです。
読み込んだ後は、必要に応じて画面をリフレッシュしてください。

public void undo()
{
    string text = _history.getPrevious();
    if (text != null)
    {
        deserialize(text);
        // 必要に応じて画面のリフレッシュを行う
    }
}

private void redo()
{
    string text = _history.getNext();
    if (text != null)
    {
        deserialize(text);
        // 必要に応じて画面のリフレッシュを行う
    }
}

非常にシンプルですね。
開発初期からSaveDataクラスをしっかり設計しておけば、簡単に導入できるのがわかると思います。
(3)と(4)が重くてどうしようもない場合は(B)案で実装することになりますが、(B)案は次回にしましょう。

またねー