UnityEditorのUndo/Redoシステムについて【解決編】【最新】
概要
UnityEditorのUndo/Redoを管理するシステムについて、
ScriptableObjectとかを使わないでも後付けで割と楽にUndo管理できるかんじになった。
採点してほしい。
☆この記事に書いてある内容は古くなった。もっともっともっと楽にUndo/Redo実現できた。
以下を見ような。
http://sassembla.github.io/Public/2015:09:17%203-14-23/2015:09:17%203-14-23.html
サンプル
https://github.com/sassembla/UndoSample
ScriptableObjectをEditorで使った場合の欠点
1.以下のタイミングでnullになる
・UnityEditor起動時にInstance作成していた場合、UpdateやOnGUIが2~3回走ったあとにnullになる
・EditorのPlay -> nullになる -> Stop -> nullになる
たとえばEditorWindowを使っていて、そこのOnEnableでScriptableObject派生のインスタンスを初期化した場合、
OnEnableが発生したあと、特定の秒数後にすべてのScriptableObjectがnullになる。
ちなみにこのときOnDestroyとかは発生しない。
なんだかEditorの挙動と連動しているっぽい。
2.生成 -> Undo -> 消える -> Redo -> 復活 の際に、identityを失って困る
下記で詳しく書く
1.ScriptableObjectをEditorで使うとnullになるケースへの対処
たとえばScriptableObjectを避ける
正確には、「ScriptableObjectやその拡張を自分で作成して使う」のを避ける。
EditorWindowのインスタンスなんかはScriptableObjectを拡張して作られているが、こいつらは突然nullになったりしない。
なのでUndo/Redo記録にはEditorWindowのインスタンスを使うといい。
2.復活の際に困るケースの紹介とその解
要約すると、「Undo/Redoするとidentityを失ってRedoで関係再構築ができなくなる」問題。
次のような場合を想定する。
オブジェクトid Aを生成
-> オブジェクトid Bを生成、Bはオブジェクトid Aの下にぶら下がるような関係
-> Undo
-> オブジェクトid Bが消える
-> Undo
-> オブジェクトid Aが消える
-> Redo
-> オブジェクトid Aが復活(この際呼ばれるのはデフォルトコンストラクタ)
出来上がるのはオブジェクトid A’
-> Redo
-> オブジェクトid Bを生成、、、するんだけど、オブジェクトAは存在してなくて、呼ばれちゃうのはデフォルトコンストラクタで、、、
出来上がるのはオブジェクトid B’
オブジェクトid BをRedoしたつもりが、Bの生成にはデフォルトコンストラクタが呼ばれてしまい、
またその要素をセットするのも難しい(ていうかセットする値を覚えているやつが居ない)
「Undo/Redoするとidentityを失ってRedoで関係再構築ができなくなる」
さてどうしよう、っていう。
Undo -> Redoの流れで、非常に困る。
特にリストでものを扱ったりする際に困る。
Undo/Redo対象がScriptableObjectでもScriptableObjectでなくても困る。
Q.なんでこうなってるんだろう?
推測A.
前提として、MonoBehaviourとかComponentとか、そのへんを扱う前提だからじゃねーかな、、
Componentへの干渉とかも特別にトラックしてるくらいだし。
たとえばGameObjectとかを扱う場合だとすごくスッキリ動くんだけど、Editor側で利用するにはnull化とかがあって正直無理。
んで解
解決を考える。
生成時にフックになるような情報(ここではindexと生成idを組み合わせた辞書)を持っておく。
あるリストがあるとき、その要素は
[0,1,2,3,,,] みたいなindexで表される。
これは要素の途中削除を行わない限り、Undoしたら最大1項目減り、Redoすれば最大1項目増える、という形になる。
こんなリストがあったとして、
[] (empty
このリストに何かを足す(Do
[0]
このリストにさらに何かを足す(Do
[0,1]
このリストに足したのをUndoする
[0]
このリストに足したのをUndoしたのをRedoする
[0,1]
というかんじに、indexの値と動作の内容は完全に一致させて記録することができる。
この関係は増えたり減ったりするのが連続した1次元的な事象である限り崩れない。
要素の途中削除を避ける方法としては、論理削除とかがありえる。(要素からは消さず、ただ見えないことにする等。)
この特性を利用して、
オブジェクトid Aを生成
生成時にint index_of_A(たとえば0)をキー、id値 Aをvalueとして持つ。
-> オブジェクトid Bを生成、Bはオブジェクトid Aの下にぶら下がるような関係
生成時にint index_of_B(ここではAのindexが0なので、0 + 1 = 1)をキー、id値 Bをvalueとして持つ。
-> Undo
-> オブジェクトid Bが消える
-> Undo
-> オブジェクトid Aが消える
このあたりの動作では生成時の記録は干渉されず、消えない。
-> Redo
-> オブジェクトid Aが復活(この際呼ばれるのはデフォルトコンストラクタ)
出来上がるのはオブジェクトid A’
ここで、Redoをトリガーに、A’(indexは0)の内容を、記録をもとに過去存在した A に書き換える。
-> Redo
-> オブジェクトid Bを生成、、、するんだけど、オブジェクトAは存在してなくて、呼ばれちゃうのはデフォルトコンストラクタで、、、
出来上がるのはオブジェクトid B’
ここで、Redoをトリガーに、B’(indexは0)の内容を、記録をもとに過去存在した B に書き換える。
という感じで、コンストラクタの役割を持たせることができる。
「途中のオブジェクトを消してはならない」という制約はつくが、安定して動く。
Undoで消えるぶんには、新しい記録が作られるかRedoされるかの2択しかないので、問題ない。
新しい記録を作った際にはRedo履歴も消えるので。
嘘
実際には上記の説明では端折りがあり、もっと厄介なことが起きている。そして自動的に解決される。
あるScriptableObjectの下に、List<Content> contents があった時、
contentsに対してnew Contentを足したりすると、
Undo時にcontentsが一件消え、
Redo時にcontentsの内容すべてが再生成される
(サンプルをUndo/Redoしたりしてみてびっくりしてほしい。)
ドラマチックゥ~~。
ただし、contentsの内容のすべてが再生成される際、その内部のパラメータは「オリジナルがある場合引っ張ってくる」みたいなのが発生しているらしく、
全く問題ない状態を維持する。不思議だ。まあインスタンスの内容をまるっと覚えておいて反映させる、っていう機構なので、さもありなん。
よって、問題にはならない。
Undo/Redoをあとから設置しやすくなる指針
ガチでやらんかぎりそんなにないとは思うが。
・Redoでのリジェネレーション時にできることを明確にしておく
コンストラクタはデフォルトをつかう、とか。
・Inspector表示用にはScriptableObjectが使える(時々勝手にnullになるが)
・Inspector表示用以外にScriptableObjectまたは派生物を扱うのはやめる(スッゲーめんどいので)
まとめ
EditorWindowにUndo/Redoを積んで、new -> Undo -> Redoさえ追跡できるようになればUndo/Redoめっちゃ簡単だぞ!!
私は悟りを開いた。
一巡した世界の果てで「えっそうなの」って叫ぶ
ということで中の方に読んでいただいたところ、
・ScriptableObjectがnullになるのは、ScriptableObject 内で hideFlags = HideFlags.DontSave; とかやるとnullにならなくなるよ
って教えてもらった。
うおーーー素敵。
んで、あとは、
ScriptableObjectをガンガン作る
or
上位に一個記録媒体みたいな役割を置いて下位を自動的に監視する、の2択。
個人的には上位集約させるほうが枝葉にUndo書くとかがなくなってくれるので好き。
一定の共通の意味の処理を上位に集約できると、挙動変えたり直すの楽なので、、
各ScriptableObjectでRecordObject書くのはヤダなあ、、まあ郷に入っては郷に従え。
そして3週目の世界へ
この記事に書いてある内容は古い。
以下を見て欲しい。
http://sassembla.github.io/Public/2015:09:17%203-14-23/2015:09:17%203-14-23.html