Help us understand the problem. What is going on with this article?

【Unity】インターフェイスをSerialize出来るようにするSerializeReferenceのための表示attributeを作ってみた

※ 2019/12/01 @baba_s 様のコメントを参考にコード修正
※ 2019/11/28 一部バグが2019.3.0f1(正式リリース候補)にて解消されたので、対策部分のコードを消しました
ついでにDrawerのコード整理して見やすくした
(あとMonobehaviourはシリアライズ出来ないので、弾くようにした)
※ 2019/10/19 フリーズバグについて少し進展があったので、Drawer.csと参考に加筆、追記をしました。

本題

Unity 2019.3 から新たに [SerializeRefence] という attribute が追加されました。

皆さんご存知ですか?
なんと、非Monoクラス限定ですが、この attribute を使えば、インターフェイスや子クラスも Serialize 出来るようになり、
エディターから直接編集出来るようになります!

もう本当に、大興奮ですよ。この情報を初めて知った自分は小一時間ずっと飛び跳ねていました。
このattributeがあると何が嬉しいかというと、これで使い方がめっちゃ広がるし、interfaceを実装したクラスを一々 ScriptableObject で包まなくて済みます。

実際に使ってみたのですが

この機能を試すべく2019.3.0a7をダウンロードして、このattributeを早速使ってみました。
書いたコードはこれです:

[SerializeReference] 
ICommand command;

しかし、出てきたのはこれです:
empty-command.png
ご覧の通り、インスペクターにフィールドの名前以外、何も表示されませんでした……

せっかくインターフェイスをシリアライズ出来るようになったのに、工夫してEditor拡張をしなければ何も出来ません。依然として敷居が高いです。

これじゃイケない! と思ったので、SerializeReference を補助する
自動でサブクラス検索し、インスペクターから選択生成できる attribute [SubclassSelector] 作りました。

[SubclassSelector]

使い方:

[SerializeReference, SubclassSelector] 
ISomeItnterface someInterface;
// Monobehaviourも含めて検索したい場合:
[SerializeReference, SubclassSelector(true)] 
ISomeItnterface someInterface;

追記:コメントで @baba_s 様から typeof(ISomeItnterface) を使わないで、
PropertyDrawerから直接フィールドのTypeを取得する関数を教えて頂きました。ありがとうございます!

導入

以下の2ファイルをコピペし、プロジェクトに入れる。(厳密に言うと、Drawer.cs のほうは任意の Editor/ フォルダーの下に入れる)
また、@baba_s 様のサンプル付きプロジェクトもここからダウンロード出来ます:
https://github.com/baba-s/Unity-SerializeReferenceExtensions

SubclassSelectorAttribute.cs
using System;
using UnityEngine;

[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
public class SubclassSelectorAttribute : PropertyAttribute
{
    private bool m_includeMono;

    public SubclassSelectorAttribute(bool includeMono = false)
    {
        m_includeMono = includeMono;
    }

    public bool IsIncludeMono()
    {
        return m_includeMono;
    }
}
SubclassSelectorDrawer.cs
#if UNITY_2019_3_OR_NEWER
using System;
using System.Reflection;
using System.Linq;
using UnityEngine;
using UnityEditor;

[CustomPropertyDrawer(typeof(SubclassSelectorAttribute))]
public class SubclassSelectorDrawer : PropertyDrawer
{
    bool initialized = false;
    Type[] inheritedTypes;
    string[] typePopupNameArray;
    string[] typeFullNameArray;
    int currentTypeIndex;

    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        if (property.propertyType != SerializedPropertyType.ManagedReference) return;
        if(!initialized) {
            Initialize(property);
            GetCurrentTypeIndex(property.managedReferenceFullTypename);
            initialized = true;
        }
        int selectedTypeIndex = EditorGUI.Popup(GetPopupPosition(position), currentTypeIndex, typePopupNameArray);
        UpdatePropertyToSelectedTypeIndex(property, selectedTypeIndex);
        EditorGUI.PropertyField(position, property, label, true);
    }

    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        return EditorGUI.GetPropertyHeight(property, true);
    }

    private void Initialize(SerializedProperty property)
    {
        SubclassSelectorAttribute utility = (SubclassSelectorAttribute)attribute;
        GetAllInheritedTypes(GetFieldType(property), utility.IsIncludeMono());
        GetInheritedTypeNameArrays();
    }

    private void GetCurrentTypeIndex(string typeFullName)
    {
        currentTypeIndex = Array.IndexOf(typeFullNameArray, typeFullName);
    }

    private void GetAllInheritedTypes(Type baseType, bool includeMono)
    {
        Type monoType = typeof(MonoBehaviour);
        inheritedTypes = AppDomain.CurrentDomain.GetAssemblies()
            .SelectMany(s => s.GetTypes())
            .Where(p => baseType.IsAssignableFrom(p) && p.IsClass && (!monoType.IsAssignableFrom(p) || includeMono))
            .Prepend(null)
            .ToArray();
    }

    private void GetInheritedTypeNameArrays()
    {
        typePopupNameArray = inheritedTypes.Select(type => type == null ? "<null>" : type.ToString()).ToArray();
        typeFullNameArray = inheritedTypes.Select(type => type == null ? "" : string.Format("{0} {1}", type.Assembly.ToString().Split(',')[0], type.FullName)).ToArray();
    }

    public static Type GetFieldType(SerializedProperty property)
    {
        const BindingFlags bindingAttr =
                BindingFlags.NonPublic |
                BindingFlags.Public |
                BindingFlags.FlattenHierarchy |
                BindingFlags.Instance
            ;

        var propertyPaths = property.propertyPath.Split('.');
        var parentType = property.serializedObject.targetObject.GetType();
        var fieldInfo = parentType.GetField(propertyPaths[0], bindingAttr);
        var fieldType = fieldInfo.FieldType;

        // 配列もしくはリストの場合
        if (propertyPaths.Contains("Array")) {
            // 配列の場合
            if (fieldType.IsArray) {
                // GetElementType で要素の型を取得する
                var elementType = fieldType.GetElementType();
                return elementType;
            }
            // リストの場合
            else {
                // GetGenericArguments で要素の型を取得する
                var genericArguments = fieldType.GetGenericArguments();
                var elementType = genericArguments[0];
                return elementType;
            }
        }

        return fieldType;
    }

    private void UpdatePropertyToSelectedTypeIndex(SerializedProperty property, int selectedTypeIndex)
    {
        if (currentTypeIndex == selectedTypeIndex) return;
        currentTypeIndex = selectedTypeIndex;
        Type selectedType = inheritedTypes[selectedTypeIndex];
        property.managedReferenceValue =
            selectedType == null ? null : Activator.CreateInstance(selectedType);
    }

    private Rect GetPopupPosition(Rect currentPosition)
    {
        Rect popupPosition = new Rect(currentPosition);
        popupPosition.width -= EditorGUIUtility.labelWidth;
        popupPosition.x += EditorGUIUtility.labelWidth;
        popupPosition.height = EditorGUIUtility.singleLineHeight;
        return popupPosition;
    }
}
#endif

コード内容を簡単に説明すると

  • Assemblyを検索して、インターフェイスや親クラスを実装、継承した子クラスのリストを取得
  • property.managedReferenceFullTypenameでシリアライズされたobjectのTypeを取得
  • ポップアップリストを作り、違うTypeのクラスが選択されたらActivator.CreateInstance()で該当classを生成してproperty.managedReferenceValueに入れる
  • PropertyField()でchildren込みで表示させる

結果

[SubclassSelector]attributeを入れると、
インスペクターから全てのサブクラスを選択できて、
2.png
再生してもちゃんとサブクラスの動きをしています。
3.png
これで簡単にインターフェイス・ライフが始められます!

大したことはしていないですけれど、Unity 2019.3はまだbeta版だったため情報があまり出回らず、調べるのに苦労をしました。
でも、これで便利になるなら、時間をかけて調べる価値はあります。Unityのシリアライズについてめっちゃ勉強になりましたし、とても楽しかったです。

参考

2019.3.0f1(正式リリース候補)にてバグが殆ど解消されました。

今既知の問題は2つあります:

  1. [SerializeReference] List<ISomething>で prefab-asset と prefab-instance(シーン上のオブジェクト)のsizeが違う時、unityがcrashする場合がある(Arrayも同じ)
    ※Unityに問い合わせた結果、どうやらprefabのListに[SerializeReference]をつけても値を保存できないことは現行のprefab仕様の限界です。
    将来的には解消される可能性もあるんですが、現段階ではバグと見なされません。

  2. クラスにシリアライズ可能なフィールドが無いと上手くデシリアライズできない(公式発表済み)
     例えば、下記のクラスはシリアライズ出来ません。

[System.Serializable]
class Someclass : ISomeInterface {
    public DoSomething() { }
}

テスト時に使ったクラスはこちらです。

CommandTestClass.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CommandTestClass : MonoBehaviour
{
    [SerializeReference, SubclassSelector(typeof(ICommand))]
    ICommand command;

    [SerializeReference, SubclassSelector(typeof(ICommand))]
    List<ICommand> commandList = new List<ICommand>();

    void Start()
    {
        command?.Execute();
        foreach(ICommand c in commandList)
            c?.Execute();
    }
}

public interface ICommand
{
    void Execute();
}

[System.Serializable]
public class Command_Empty : ICommand
{
    public void Execute() { }
}

[System.Serializable]
public class Command_Log : ICommand
{
    [SerializeField]
    string text;
    public Command_Log() { text = "default text"; }
    public Command_Log(string logText) { text = logText; }

    public void Execute()
    {
        Debug.Log(text);
    }
}

[System.Serializable]
public class Command_InstantiatePrefab : ICommand
{
    [SerializeField]
    GameObject prefab;
    [SerializeField]
    Vector3 position;

    public void Execute()
    {
        GameObject.Instantiate(prefab, position, Quaternion.identity);
    }
}

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした