土屋つかさの技術ブログは今か無しか

土屋つかさが主にプログラミングについて語るブログです。

#Unity 何故PlayModeに入った時にOnValidate()が2回呼ばれるのか問題(その1)

 現在Canvas用のコンポーネントを作っていて、その最中、Unity初期化時のMonoBehaviour.OnValidate()の挙動に悩まされまして、折角なのでちゃんと調べて記事にしました。
 今回は前半戦で、実は表題にした「何故OnValidate()は2回呼ばれるのか」に到達していませんごめんなさい。
 それでは、実はやたらと複雑でハマると抜け出せなくなるUnityの初期化周りの挙動にようこそ!

GitHubにサンプルコードあります

 現在GitHubの練習をしてまして、今回のコードもリポジトリをアップしました。短いコードですが参考になりましたらどうぞ。
github.com

サンプルコード1:完成版

 下記は、Canvasのコンポーネントにアタッチする前提のサンプルコードです。Editor Mode中にインスペクタ上のSize Deltaを更新すると、連動してRectTransform.sizeDeltaが更新されます。

//SampleCode1.cs
using UnityEngine;

public class SampleCode : MonoBehaviour
{
    public Vector2 SizeDelta;

//エディタ上で実行する場合のみビルドに含める
#if UNITY_EDITOR
    private void OnValidate()
    {
        //イベントハンドラを追加
        UnityEditor.EditorApplication.update += OnValidateImpl;
    }

    void OnValidateImpl()
    {
        //イベントハンドラを削除
        UnityEditor.EditorApplication.update -= OnValidateImpl;
        //自身が削除済みであればなにもしない
        if (this == null) return;
        //サイズを更新する
        GetComponent<RectTransform>().sizeDelta = SizeDelta;
    }
#endif
}

 開発時、Editor Mode中にインスペクタの値を変更してUIの挙動を確認したいので、こういうコードを書きます。Canvasにおけるイディオムの一つと言えます。
 しかしこのコード、「インスペクタの更新に応じてRectTransform.sizeDeltaを更新する」というだけにしては、やたらとコードが複雑に見えないでしょうか? どうしてこのようなロジックが必要なのでしょう?
 実はこれにはUnityの初期化処理にかかわる複数の課題が関わっています。今回はこれを見ていきましょう。

サンプルコード2:不完全版1

 先程のコードを以下のようにシンプルにしてみます。

//SampleCode2.cs
using UnityEngine;

public class SampleCode2 : MonoBehaviour
{
    public Vector2 SizeDelta;

#if UNITY_EDITOR
    private void OnValidate()
    {
        GetComponent<RectTransform>().sizeDelta = SizeDelta;
    }
#endif
}

 MonoBehaviour.OnValidate()は、インスペクタプロパティが更新される度に実行されるコールバック関数です。Edit Mode/Play Mode問わず呼びだされます。
docs.unity3d.com
 このコールバック関数はUnityエディタで実行した時のみ有効で、ビルド版では実行されません。その為、メソッドを"#if UNITY_EDITOR~#endif"で囲い、ビルド版のコンパイル対象から除外させています。
 この状態でインスペクタ上のSize Deltaを更新すると、RectTransformは連動して更新されますが、コンソールに延々とwariningが出力されます。


SendMessage cannot be called during Awake, CheckConsistency, or OnValidate (Panel: OnRectTransformDimensionsChange)
UnityEngine.RectTransform:set_sizeDelta (UnityEngine.Vector2)
SampleCode2:OnValidate () (at Assets/Scenes/SampleCode2.cs:11)
UnityEngine.GUIUtility:ProcessEvent (int,intptr,bool&)

 「PanelオブジェクトでOnRectTransformDimensionsChangeイベントが発行されましたが、Awake/CheckConsistency/OnValidateの実行中にSendMessage(=イベント発行)は実行できません」というwarningです。
docs.unity3d.com
 RectTransformは自身のサイズが更新されると、そのタイミングでUIBehaviour.OnRectTransformDimensionsChangeというコールバック関数を(SendMessageで)呼び出します。この挙動が「OnValidate中にはSendMessageを実行出来ない」という制約に抵触した為、上記のwarningが出てしまったわけです。

 何故このような制約が存在するのかというと、Awake()やOnValidate()の実行中は、他のインスタンスの初期化が完了していない可能性があり、そのような未初期化インスタンスが、イベント発火によって実行されると予期しない挙動になるためです。これは公式ドキュメント内の補足事項として記載されています*1。このサンプルはたまたま動いていますが、避けた方が無難でしょう。

サンプルコード3:不完全版2

 warningの発生を回避するコードは以下のようになります。

//SampleCode3.cs
using UnityEngine;

public class SampleCode3 : MonoBehaviour
{
    public Vector2 SizeDelta;

#if UNITY_EDITOR
    private void OnValidate()
    {
        UnityEditor.EditorApplication.update += OnValidateImpl;
    }

    void OnValidateImpl()
    {
        UnityEditor.EditorApplication.update -= OnValidateImpl;
        GetComponent<RectTransform>().sizeDelta = SizeDelta;
    }
#endif
}

 UnityEditor.EditorApplication.updateはデリゲートで、追加したメソッドをupdateタイミングに実行します*2。OnValidate()内でOnValidateImplを登録する事で、全てのオブジェクトの初期化が終わった後でsizeDeltaを更新するようにしたわけです。
docs.unity3d.com
 EditorApplication.updateに登録したデリゲートは残り続けるので、OnValidateImpl()内でデリゲートを解除しています。これを忘れるとOnValidateイベントが発生する度にハンドラに追加されてしまうのでご注意下さい。

 さて、これで問題は解決したかにように思えますが、実はまだ終わっていません。
 SampleCode3をアタッチした状態でPlay Modeに入ると、コンソールに以下のエラーが出力されます。

 SampleCode3にアクセスしようとしてNull参照例外が発行されたようです。しかし、コンポーネントは生成されていますし、インスペクタプロパティに連動してRectTransformは更新されています。いったいこの例外はなんなのでしょうか?

*1:参考:https://docs.unity3d.com/2023.1/Documentation/ScriptReference/PrefabUtility.InstantiatePrefab.html なおCheckConsistencyはプロファイラー用のマーカーぽいのだけどなんでここに書いてあるのか不明

*2:LateUpdateよりも後に実行するようですが、公式の説明はありません