hildsoftのコード置き場

プログラム関連で調べたことやコードの保管場所です

Unityでエディタ拡張を作るときのUndo、Redoの落とし穴

スポンサーリンク

f:id:hildsoft:20180818151746j:plain

検証環境

Unity 2018.1.6f1

現象

ScriptabelObjectを継承しない自作クラスの参照を持っている状態でRedoを行うと、正しく参照が保持されない。

こちらの記事で指摘されている現象です。
http://sassembla.github.io/Public/2015:09:17%203-14-23/2015:09:17%203-14-23.html

原因

データの持ち方に問題がある。

実験

  • ScriptableObjectを継承したTestDocumentクラスを作成。
  • EditorWindow内にフィールド(doc)として保持。
  • TestDocument内に何種類かデータを保持。
  • Undo.RecordObject(this.doc, "Add Node")にてUndo、Redoを行う。
  • Undo、Redoでデータがどうなるか簡単に調査。

プリミティブ

public class TestDocument : ScriptableObject

    public int PublicCounter;

    private int privateCounter;
    public int PrivateCounter { get { return this.privateCounter; } set { this.privateCounter = value; } }

    [SerializeField]
    private int serializeCounter;
    public int SerializeCounter { get { return this.serializeCounter; } set { this.serializeCounter = value; } }

こちらはprivate以外はUndo、Redo共に問題ありませんでした。
privateについてはUndo、Redoで値が変更されません。

クラス

public class TestDocument : ScriptableObject

    // クラス
    [SerializeField]
    private List<Node> nodeList;
    public List<Node> NodeList { get { return this.nodeList; } }

    public List<Connection> ConnectionList;

クラスについてはprivateは実験しませんでした。
SerializeFieldもpublicもどちらもUndo、Redoで更新されます。

参照の問題点

Connectionクラスに

    [SerializeField]
    private Node inNode;
    public Node InNode { get { return this.inNode; } set { this.inNode = value; } }

    [SerializeField]
    private Node outNode;
    public Node OutNode { get { return this.outNode; } set { this.outNode = value; } }

と、Nodeクラスへの参照を持っているわけですが、

f:id:hildsoft:20180818154446j:plain

Nodeクラスが[System.Serializable]によってシリアライズ対象になっているときは、ConnectionをUndoで削除した後のRedo時にNodeがnewで生成されてしまいます。

NodeクラスがScriptableObjectを継承することによりシリアライズされている場合はコネクションの再生成時に、docで管理されているnodeListの中にあるNodeへの参照を設定してくれます。
その際、コネクションがScriptableObjectでなく[System.Serializable]でも問題はありませんが、コネクションが参照される可能性も含めてScriptableObjectにしておいた方が良いでしょう。

実験はしていないのですが、おそらくdoc単位でUndoしているので正しく参照関係を保持して戻してくれているのでしょう。

確かにぱっと見では正しく動いているように見えるので、浮いたノードを参照しているコネクション経由でノードへ変更をかけるアクセスをしない限り発覚しないので怖いですね。

結論

  • Undo、Redoを行う変数は、publicで宣言するか[SerializeField]を設定する
  • Undo、Redoの対象となる自作クラスはScriptableObjectを継承しておく
  • Undoで記録する際は参照範囲にあるオブジェクトを纏めて放り込む

を心がけておくと良いかと思います。

サンプルコード

WindowTest.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;

public class WindowTest : EditorWindow
{
    static WindowTest window;

    // データ管理インスタンス
    private TestDocument doc;

    private Node selectNode;

    [MenuItem("Window/UndoRedo Test")]
    private static void OpenWindow()
    {
        if (window == null)
        {
            window = EditorWindow.CreateInstance<WindowTest>();
            window.titleContent = new GUIContent("UndoRedo Test");
        }
        window.doc = CreateInstance<TestDocument>(); // ScriptableObjectはnewで作成しない
        window.doc.init();
        window.Show();

        // Undo、Redo実行時に再描画
        Undo.undoRedoPerformed += () =>
        {
            window.Repaint();
        };
    }

    private void OnGUI()
    {
        CheckEvent(Event.current);

        foreach (var item in this.doc.ConnectionList)
        {
            item.Draw();
        }
        foreach (var item in this.doc.NodeList)
        {
            item.Draw();
        }

        GUI.Label(new Rect(10, 10, 200, 30), new GUIContent("Public:" + this.doc.PublicCounter));
        GUI.Label(new Rect(10, 30, 200, 30), new GUIContent("Private:" + this.doc.PrivateCounter));
        GUI.Label(new Rect(10, 50, 200, 30), new GUIContent("Serialize:" + this.doc.SerializeCounter));

        if (GUI.changed) Repaint();
    }

    private void CheckEvent(Event e)
    {
        switch(e.type)
        {
            case EventType.MouseDown:
                Node clickNode = null;
                foreach (var item in this.doc.NodeList)
                {
                    if (item.Contains(e.mousePosition)) {
                        clickNode = item;
                        break;
                    }
                }

                if (clickNode == null)
                {
                    // 何もないところをクリックしたらノード作成
                    Node n = new Node();
                    //Node n = CreateInstance<Node>();
                    Undo.RecordObject(this.doc, "Add Node");
                    this.doc.NodeList.Add(n);
                    n.Pos = e.mousePosition;

                    e.Use();
                    GUI.changed = true;
                }
                else { 
                    if(this.selectNode == null)
                    {
                        this.selectNode = clickNode;
                    }
                    else
                    {
                        Connection c = new Connection();
                        //Connection c = CreateInstance<Connection>();
                        Undo.RecordObject(this.doc, "Add Connection");
                        this.doc.ConnectionList.Add(c);
                        c.InNode = clickNode;
                        c.OutNode = this.selectNode;

                        this.selectNode = null;

                        e.Use();
                        GUI.changed = true;
                    }
                }
                break;

            case EventType.KeyDown:
                if(e.keyCode==KeyCode.A)
                {
                    Undo.RecordObject(this.doc, "CountUp");

                    int addCount = Random.Range(1, 100);

                    this.doc.PublicCounter += addCount;
                    this.doc.PrivateCounter += addCount;
                    this.doc.SerializeCounter += addCount;

                    GUI.changed = true;
                }
                if (e.keyCode == KeyCode.S)
                {
                    // 参照が一致するか確認
                    this.doc.Log();
                }
                break;
        }
    }
}


TestDocument.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;

public class TestDocument : ScriptableObject
{
    // クラス
    [SerializeField]
    private List<Node> nodeList;
    public List<Node> NodeList { get { return this.nodeList; } }

    public List<Connection> ConnectionList;

    // プリミティブ

    public int PublicCounter;

    private int privateCounter;
    public int PrivateCounter { get { return this.privateCounter; } set { this.privateCounter = value; } }

    [SerializeField]
    private int serializeCounter;
    public int SerializeCounter { get { return this.serializeCounter; } set { this.serializeCounter = value; } }
    public void init()
    {
        this.nodeList = new List<Node>();
        this.ConnectionList = new List<Connection>();
        this.PublicCounter = 0;
        this.privateCounter = 0;
    }

    public void Log()
    {
        foreach (var conn in this.ConnectionList)
        {
            bool inNodeChek = false;
            bool outNodeChek = false;

            foreach (var node in this.nodeList)
            {
                if(ReferenceEquals(conn.InNode, node))
                {
                    inNodeChek = true;
                }
                if (ReferenceEquals(conn.OutNode, node))
                {
                    outNodeChek = true;
                }
            }
            if (inNodeChek && outNodeChek) { Debug.Log("OK"); }
            else { Debug.Log("NG"); }
        }
    }
}

[System.Serializable]
public class Node
//public class Node : ScriptableObject
{
    public Vector2 Pos { get { return this.rect.position; } set { this.rect.position = value; } }

    [SerializeField]
    private Rect rect = new Rect(0f, 0f, 150f, 40f);

    public bool Contains(Vector2 point)
    {
        return this.rect.Contains(point);
    }

    public void Draw()
    {
        GUI.Box(this.rect, new GUIContent("Rect:" + this.rect.position.ToString()));
    }
}

[System.Serializable]
public class Connection
//public class Connection : ScriptableObject
{
    [SerializeField]
    private Node inNode;
    public Node InNode { get { return this.inNode; } set { this.inNode = value; } }

    [SerializeField]
    private Node outNode;
    public Node OutNode { get { return this.outNode; } set { this.outNode = value; } }

    public void Draw()
    {
        if (this.inNode == null || this.outNode == null) return;

        float lineWidth = 3f;

        Vector3[] points = new Vector3[4];

        Vector3 inNodePos = this.inNode.Pos;
        Vector3 outNodePos = this.outNode.Pos;

        points[0] = new Vector3(inNodePos.x, inNodePos.y + 20f, 0);
        points[1] = new Vector3(inNodePos.x - 10f, inNodePos.y + 20f, 0);
        points[2] = new Vector3(outNodePos.x + 160f, outNodePos.y + 20f, 0);
        points[3] = new Vector3(outNodePos.x + 150f, outNodePos.y + 20f, 0);

        Handles.DrawAAPolyLine(lineWidth, points);
    }
}