アヒルのある日

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

【Unity】SerializedPropertyの罠

こんにちは!いかついプログラマです。

今回は、先日Unityのエディタ拡張でSerializedPropertyの罠(?)にハマってしまったため、それについて記事にしようかと思います。

1.そもそもSerializedObjectって?

Unityでエディタ拡張を作ったことがある人にはお馴染みかもしれませんが、超ざっくり言うと様々なアセットを汎用的に編集するためのクラスです。

例えば、こんな感じのScriptableObjectがあったとき、

public class CharacterStatus : ScriptableObject
{
    public enum JOB_TYPE
    {
        ATTACKER,
        HEALER,
        DEFENDER,
    }

    [SerializeField]
    private int m_hp;
    [SerializeField]
    private int m_power;
    [SerializeField]
    private JOB_TYPE m_job;
}

こんな感じでインスタンスのプロパティへアクセスが可能です。

var serializedObject = new SerializedObject(characterStatus);  // CharacterStatusのインスタンス
SerializedProperty jobProperty = serializedObject.FindProperty("m_job"); // m_jobプロパティにアクセス
Debug.Log((CharacterStatus.JOB_TYPE)jobProperty.intValue); // SerializedProperty.intValueでint型の値を取得可能
jobProperty.intValue = (int)CharacterStatus.JOB_TYPE.HEALER; // プロパティを書き換えることも可能

2.SerializedPropertyの罠

事件は「インスペクタの表示をプルダウンに変更する」↓こんな感じのCustomAttributeを作成していた際、

[CustomPropertyDrawer(typeof(CustomTableIndexAttribute))]
private class StringIndexDrawer : PropertyDrawer
{
    public override void OnGUI(Rect rect, SerializedProperty property, GUIContent label)
    {
        var stringIndexAttribute = (ContextTableIndexAttribute)attribute;
        string[] names = {"アタッカー", "ヒーラー", "ディフェンダー"};
        property.intValue = EditorGUI.Popup(rect, property.intValue, names);
    }
}

作成したCustomAttributeをつけたプロパティを持つファイルをエディタ上で複数選択すると、プルダウンを触っていないのにプロパティの内容が書き変わってしまうという不具合が発生してしまいました。 そんな不具合を引き起こしそうなコードはないように見えますが、ここにSerializedPropertyの罠が存在していました。 それがこちら

property.intValue = EditorGUI.Popup(rect, property.intValue, names);

…特に問題ないように見えますよね? a = aしているだけで、一体何がダメなのかと。

落とし穴となっていたのは、SerializedProoertyがターゲットを複数持っていた場合でした。

どういうことか? Unityエディタの挙動を考えてみます。 エディタ上で同一クラスのアセットを複数選択した場合には複数のアセットのプロパティを同時に書き換えることができますよね?

このとき、SerializedPropertyはターゲットとなるアセットを複数持った状態になります。 実際、SerialozedProperty.serializedObjectはメンバ変数にtargetObjectとは別にtargetObjectsを持っています。

では、SerializedPropertyのターゲットが複数存在することがあるという前提に立って、改めてSerializedProperty.intValueについて考えてみます。

…このintValueとは何を指しているのか?

実際に内部実装を確認したわけではありませんが、外部から観測した挙動から次のような実装と同じ振る舞いをしています。

private int[] _intValue; 

public int intValue
{
    get
    {
        return _intValue[0];
    }
    set
    {
        for (int i = 0; i < _intValue.Length; i++)
        {
            _intValue[i] = value;
        }
    }
}

intValueはGet時には配列の先頭の値のみを返してあたかもただのint型のように振る舞います。 しかし、実際にはintValueは配列データであり、Set時には配列の全ての要素にvalueを代入する挙動をします。 つまり、

serializedProperty.intValue = serializedProperty.intValue

という命令は一見何の変更も及ぼさない処理のようで、その実配列の先頭の値で全ての要素を塗りつぶす処理になってしまいます。

これが、エディタ上で複数選択するだけで全てのプロパティが書き変わってしまう不具合になっていました。

3.回避策

上手い実装も思いつかなかったため、今回は

if (property.serializedObject.targetObjects.Length <= 1)
{
    property.intValue = EditorGUI.Popup(rect, property.intValue, names);
}
else
{
    EditorGUI.LabelField(rect, "------------");
}

として複数選択した場合にはプルダウンを表示しないようにして逃げることにしました。

ちなみに、今回はintValueについてのみ触れましたがfloatValueみたいな他の型でも同様だと思います。

4.まとめ

配列っぽく振る舞うならintValuesみたいな名前にしてくれないとわかんないよ……

と嘆くいかついプログラマでした。

それではまた!