【Unity】SerializeReferenceをちゃんと理解する

Unityで参照をシリアライズするSerializeReferenceアトリビュートについてまとめました。

Unity2019.4.0

はじめに

SerializeReferenceアトリビュートはUnity2019.3で追加された、参照をシリアライズするための機能です。

うまく使えば非常に便利な機能なのですが、使いこなすには仕様をしっかり理解する必要があります。
また、値をシリアライズする従来のSerializeFieldとの性質の違いも把握する必要があります。

本記事ではこのSerializeReferenceの基本的な知識をまとめた上で実用的な活用方法について考えます。

注意点:結構バグってた

以下のフォーラムを見ると、SerializeReferenceについては結構バグの報告が多い印象です。

https://forum.unity.com/threads/serializereference-attribute.678868/

修正は随時行われているようなので、微妙なUnityのバージョン違いで挙動が異なることがあると思います。
Unity2019.3の前半あたりは結構まだバグが多いかもしれません。
本記事中でももし古い内容などありましたらご指摘いただけますと幸いです。

以前書いた記事

なお以前SerializeReferenceについて以下の記事を書きましたが、あくまで触りだけだったので本記事できちんとまとめます。

light11.hatenadiary.com

SerializeFieldを復習する

SerializeReferenceについて学ぶ前に、まずは以前からあるSerializeFieldの挙動について簡単にまとめます。

プリミティブ型・構造体をシリアライズできる

SerializeFieldを付けるとprivateなプリミティブ型、構造体を明示的にシリアライズ対象にできます。
ためしにいくつかのプリミティブ型や構造体にSerializeFieldを付けたコンポーネントを作ってみます。

using System;
using UnityEngine;

public class Example : MonoBehaviour
{
    [SerializeField] private int _field1; // プリミティブ
    [SerializeField] private Vector3 _field2; // Unity定義の構造体
    [SerializeField] private ExampleStruct _field3; // 自作の構造体
    
    [Serializable] // 自作の構造体にはSerializableアトリビュートをつける
    public struct ExampleStruct
    {
        public int exampleInt;
        public string exampleString;
    }
}

このコンポーネントを適当なGameObjectにアタッチすると、SceneやPrefabに値がシリアライズされます。
またそれらの値は以下のようにInspectorから編集可能になります。

f:id:halya_11:20200708103853p:plain
Inspector

UnityEngine.Objectの派生型の参照をシリアライズできる

また、UnityEngine.Objectから派生した型のインスタンスに関してはその参照をシリアライズすることができます。

using UnityEngine;

public class Example : MonoBehaviour
{
    [SerializeField] private GameObject _field1;
    [SerializeField] private MeshRenderer _field2;
    [SerializeField] private Example _field3;
}

このコンポーネントを適当なGameObjectにアタッチすると以下のように各オブジェクトの参照を代入できることが確認できます。

f:id:halya_11:20200708105815p:plain
Inspector

非UnityEngine.Objectなクラスのインスタンスは値としてシリアライズできる

UnityEngine.Objectではないクラスのインスタンスも、Serializableアトビリュートが付いていればシリアライズすることができます。

ただしこの場合、参照ではなくフィールドの値をシリアライズします。
つまり、同じインスタンスを違うフィールドに代入しても、デシリアライズされた際にそれらは別々のインスタンスになってしまいます。

この挙動を確認するコードを以下に示します。

using System;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;

public class Example : MonoBehaviour
{
    [SerializeField] private ExampleInner _inner1;
    [SerializeField] private ExampleInner _inner2;

    [MenuItem("Example/SerializeTest")]
    public static void SerializeTest()
    {
        var example = FindObjectOfType<Example>();
        if (example == null)
            return;
        var gameObject = example.gameObject;

        // _inner1と_inner2に同じインスタンスを代入する
        var inner = new ExampleInner
        {
            exampleInt = 1234,
            exampleString = "Example"
        };
        example._inner1 = inner;
        example._inner2 = inner;
        EditorUtility.SetDirty(example);

        // 参照が同じか判定する
        Debug.Log($"判定1: {example._inner1 == example._inner2} ({example._inner1.GetHashCode()} : {example._inner2.GetHashCode()})");

        // シリアライズ&デシリアライズするためシーンを切り替えて戻す
        var scenePath = gameObject.scene.path;
        EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
        EditorSceneManager.OpenScene(scenePath);

        // 参照が同じか改めて判定する
        example = FindObjectOfType<Example>();
        Debug.Log($"判定2: {example._inner1 == example._inner2} ({example._inner1.GetHashCode()} : {example._inner2.GetHashCode()})");
    }

    // シリアライズ対象のクラス
    [Serializable]
    public class ExampleInner
    {
        public int exampleInt;
        public string exampleString;
    }
}

これをシーン上の適当なGameObjectにアタッチしてExample > SerializeTestを実行すると以下のようなログが出力されます。

f:id:halya_11:20200708103403p:plain
出力

シリアライズされると二つのオブジェクトが全く異なるインスタンスになっていることが確認できました。

ちなみにこうしてシリアライズしたフィールドはnullにならず必ずインスタンスが代入された状態になるという仕様があります。

抽象クラスやインタフェースには非対応

さてSerializeFieldは非UnityEngine.Objectの抽象クラス型やインタフェース型をシリアライズ対象にすることはできません。
ただしUnityEngine.Object派生型の抽象クラスについてはシリアライズ対象にできます。

using UnityEngine;

public class Example : MonoBehaviour
{
    // これらはシリアライズできない
    [SerializeField] private ExampleAbstract _field1;
    [SerializeField] private IExample _field2;
    
    // これらはシリアライズできる
    [SerializeField] private ExampleAbstractBehaviour _field3;
}

public abstract class ExampleAbstract
{
}

public interface IExample
{
}

public abstract class ExampleAbstractBehaviour : MonoBehaviour
{
}
Genericな型はシリアライズできる(Unity2020.1から)

なおUnity2020.1からはGenericな型をSerializeFieldでシリアライズできるようになりました。

using System;
using UnityEngine;

public class Example : MonoBehaviour
{
    // Genericなクラスのインスタンスをシリアライズ可能に
    [SerializeField] private ExampleGeneric<int> _example;
}

// SerializableでGenericなクラス
[Serializable]
public class ExampleGeneric<T>
{
    public T _testField;
}

SerializeReferenceの仕様を理解する

それでは次にSerializeReferenceの仕様を見ていきます。

SerializeReferenceは非UnityEngine.Objectのための機能

さてまず前提として、SerializeReferenceはUnityEngine.Objectを継承した型ではない型の参照をシリアライズするためのものです。
前述の通り、SerializeFieldはUnityEngine.Objectの派生型の参照をシリアライズできるのでそっちを使えばOKです。

仮にSerializeReferenceアトリビュートを付けたInterface型にUnityEngine.Objectを代入しようとすると以下のようなエラーが出力されます。

Fields with [SerializeReference] cannot serialize objects that derive from Unity.Object
参照をシリアライズする

さてSerializeReferenceSerializeFieldとは異なり、参照情報を含めてシリアライズします。
これを確認するために、SerializeFieldのときに使ったコードをSerializeReferenceに書き換えてみます。

using System;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;

public class Example : MonoBehaviour
{
    [SerializeReference] private ExampleInner _inner1;
    [SerializeReference] private ExampleInner _inner2;

    [MenuItem("Example/SerializeTest")]
    public static void SerializeTest()
    {
        var example = FindObjectOfType<Example>();
        if (example == null)
            return;
        var gameObject = example.gameObject;

        // _inner1と_inner2に同じインスタンスを代入する
        var inner = new ExampleInner
        {
            exampleInt = 1234,
            exampleString = "Example"
        };
        example._inner1 = inner;
        example._inner2 = inner;
        EditorUtility.SetDirty(example);

        // 参照が同じか判定する
        Debug.Log($"判定1: {example._inner1 == example._inner2} ({example._inner1.GetHashCode()} : {example._inner2.GetHashCode()})");

        // シリアライズ&デシリアライズするためシーンを切り替えて戻す
        var scenePath = gameObject.scene.path;
        EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
        EditorSceneManager.OpenScene(scenePath);

        // 参照が同じか改めて判定する
        example = FindObjectOfType<Example>();
        Debug.Log($"判定2: {example._inner1 == example._inner2} ({example._inner1.GetHashCode()} : {example._inner2.GetHashCode()})");
    }

    // シリアライズ対象のクラス
    [Serializable]
    public class ExampleInner
    {
        public int exampleInt;
        public string exampleString;
    }
}

これをシーン上の適当なGameObjectにアタッチしてExample > SerializeTestを実行すると以下のようなログが出力されます。

f:id:halya_11:20200708185949p:plain
結果

同じインスタンスを二つのフィールドにシリアライズしてデシリアライズしても、それらは同じインスタンスを示すことがわかりました。

抽象型やインタフェース型のフィールドをシリアライズできる

SerializeReferenceアトリビュートは抽象クラスやインタフェース型のフィールドに付けることもできます。
これらのフィールドにインスタンスを代入すると実際の型の情報がシリアライズされます。

using System;
using UnityEditor;
using UnityEngine;

public class Example : MonoBehaviour
{
    // インタフェース
    [SerializeReference] private IExampleInner _inner1;
    // 抽象クラス
    [SerializeReference] private ExampleInnerBase _inner2;

    [MenuItem("Example/SerializeTest")]
    public static void SerializeTest()
    {
        var example = FindObjectOfType<Example>();
        if (example == null)
            return;

        // _inner1と_inner2に同じインスタンスを代入する
        var inner = new ExampleInner
        {
            exampleInt = 1234,
            exampleString = "Example"
        };
        example._inner1 = inner;
        example._inner2 = inner;
        EditorUtility.SetDirty(example);
    }

    public interface IExampleInner
    {
    }

    public abstract class ExampleInnerBase
    {
    }

    [Serializable]
    public class ExampleInner : ExampleInnerBase, IExampleInner
    {
        public int exampleInt;
        public string exampleString;
    }
}

Inspectorにも実際の型に対応したプロパティが表示されます。

f:id:halya_11:20200708190945p:plain
Inspector

参照を二つのMonoBehaviour間で共有することはできない

SerializeReferenceシリアライズした参照情報を二つのMonoBehaviour間で使いまわすことはできません。
ScriptableObjectシリアライズした時も同様で、二つのScriptableObject間で使いまわすことはできません。

nullを表現できる

上述の通り、SerializeFieldには、このアトリビュートを付けたフィールドはnullにならないという仕様がありました。
これに対してSerializeReferenceを付けたフィールドはnullを表現できます。

Genericを使ったクラスのインスタンスには使えない

Genericを使ったクラスのインスタンスシリアライズしようとしてもできません。
そのようなケースではさらにそれを継承する非Genericなクラスを定義する必要があります。

using System;
using UnityEditor;
using UnityEngine;

public class Example : MonoBehaviour
{
    // シリアライズできない
    [SerializeReference] private ExampleInnerBase _inner1;

    [MenuItem("Example/SerializeTest")]
    public static void SerializeTest()
    {
        var example = FindObjectOfType<Example>();
        if (example == null)
            return;

        // シリアライズできる
        var inner = new ExampleInnerInt
        {
            example = 1234,
            exampleString = "Example"
        };
        // シリアライズできない
        /*
        var inner = new ExampleInner<int>
        {
            example = 1234,
            exampleString = "Example"
        };
        */
        example._inner1 = inner;
        EditorUtility.SetDirty(example);
    }
    
    public abstract class ExampleInnerBase
    {
    }

    public class ExampleInner<T> : ExampleInnerBase
    {
        public T example;
        public string exampleString;
    }

    [Serializable]
    public class ExampleInnerInt : ExampleInner<int>
    {
    }
}

SerializeReferenceの使いどころ

さてこのように意外と複雑なSerializeReferenceですが、実際どのような活用方法があるのでしょうか。

その① グラフ構造やツリー構造をシリアライズする

参照をシリアライズするという特徴からまず考えられるのはグラフ構造やツリー構造です。

f:id:halya_11:20200708210752p:plain
グラフ構造

このようにお互いに参照関係を持つ構造はまさにSerializeReferenceの使いどころといえます。

ScriptableObjectとSerializeFieldでも実現可能ですが、ノードの数が多いと辛いです。

その② エディタの入力インタフェースを手間なく切り替えられる

次の使いどころとして、入力インタフェースが簡単に切り替えられる点があります。

例えばいま、スキルの効果値を入力することを考えます。
スキルの種類には攻撃とバフがあり、攻撃の場合には攻撃力を入力できます。
バフの場合はバフの種類(攻撃力、防御力など)と効果値、そして効果時間を入力できます。

これをSerializeReferenceを使って実装してみます。

using System;
using UnityEditor;
using UnityEngine;

public class Example : MonoBehaviour
{
    [SerializeReference] private Skill _skill;

    [MenuItem("CONTEXT/Example/Set Attack Skill")]
    public static void SetAttackSkill(MenuCommand command)
    {
        var example = (Example) command.context;
        example._skill = new AttackSkill();
        EditorUtility.SetDirty(example);
    }
    
    [MenuItem("CONTEXT/Example/Set Buff Skill")]
    public static void SetBuffSkill(MenuCommand command)
    {
        var example = (Example) command.context;
        example._skill = new BuffSkill();
        EditorUtility.SetDirty(example);
    }

}

[Serializable]
public abstract class Skill
{
    public float value;
}
    
[Serializable]
public class AttackSkill : Skill
{
}
    
[Serializable]
public class BuffSkill : Skill
{
    public BuffType buffType;
    public float duration;
}

public enum BuffType { Attack, Defense }

インスタンスの型を切り替えるとそれに応じて入力フィールドも変更されていることが確認できます。

f:id:halya_11:20200708214822g:plain
型の切り替え

その③ 振る舞いをエディタから変更できる

インスタンスが差し替えられるということは以下のように振る舞いも差し替えられます。

using System;
using UnityEngine;

public class Example : MonoBehaviour
{
    [SerializeReference] private Speaker _speaker;

    public void Start()
    {
        _speaker?.Speak();
    }
}

[Serializable]
public abstract class Speaker
{
    public string word;
    public abstract void Speak();
}

[Serializable]
public class NormalSpeaker : Speaker
{
    public override void Speak() => Debug.Log($"{word}");
}

[Serializable]
public class LoudSpeaker : Speaker
{
    public override void Speak() => Debug.Log($"{word}{word}{word}");
}

これを実行するとシリアライズされているインスタンスの種類に応じたSpeakメソッドが呼ばれます。
ちなみにこちらもScriptableObjectでも実現はできますが、振る舞いの種類ごとにScriptableObjectを作らなければならなくなります。

関連

light11.hatenadiary.com

参考

docs.unity3d.com