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

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

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

 前回の記事の続きです。
someiyoshino.info
 OnValidate()タイミングにRectTransform.setDelta()を更新するコードを実装した所、Edit ModeからPlay Modeに移行した時(以下これを「Enter Play Mode時」と呼びます)にNull参照例外が発生してしまうという話でした。

 これは挙動だけ見ると不思議な感じがあります。制御自体は出来るのに、なぜNull参照例外が発生するのでしょうか? もっと言えば、なぜEnter Play Mode時にOnValidate()が実行されるのでしょうか?

 この謎を解くには、Enter Play Mode時にUnityがどのような内部処理を行っているかを知る必要があります。とはいえ、この内部処理は公式が完全に公開している訳ではない為*1、国内国外のネット記事を探しても正確な情報を得るのが難しいのです。その意味では本記事も独自検証の域を出る物ではないのでご注意ください。

 それでは後半戦スタートです。

サンプルコード(github)

 今回のサンプルコードはこちらです。前回もそうなんですが、コードをオブジェクトにアタッチするのは自分でやって下さい(今思うとシーンを分ければ良かったかもしれない)。
github.com

サンプルコード1:疑問1

 まず、内部挙動の検証の為に、以下のコードをCanvasのコンポーネントにアタッチします。

using UnityEngine;

public class SampleCode1 : MonoBehaviour
{
    public Vector2 SizeDelta;

#if UNITY_EDITOR
    private void OnValidate()
    {
        Debug.Log("OnValidate called");
    }
#endif
}

 Enter Play Mode時、コンソールに以下のように出力されます。

 OnValidate()が2回実行されています。初めて見た時「え? なんで?」となりました。なぜ2回呼ばれるのか分からないし、もっと言えば何故Enter Play Mode時にOnValidate()が実行されているのかが分かりません(これさっきも書いたな)。

サンプルコード2:疑問2

 もう一個試してみます。OnValidate()の処理をUpdateタイミングまで遅延させ、thisを出力します。UnityEditor.EditorApplication.updateデリゲートについては前回の記事を参照してください。

using UnityEngine;

public class SampleCode2 : MonoBehaviour
{
    public Vector2 SizeDelta;

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

    void OnValidateImpl()
    {
        UnityEditor.EditorApplication.update -= OnValidateImpl;
        Debug.Log(this);
    }
#endif
}

 Enter Play Mode時、コンソールに以下のように出力されます。

 2回実行されたOnValidateImpl()のうち、片方のthisがnullになっています! これが、前回遭遇したnull参照例外の正体です。

 何故そうなるかは後述しますが、実はEnter Play Mode時に、SampleCode2オブジェクトは2回生成されていて、そのうちの片方はUpdateタイミングより前に削除されます。その為、OnValidateImpl()内でGetComponent()が実行された時にNull参照例外が発生してしまったわけです*2

サンプルコード3:完全版(前回の記事のサンプルコード1と同じ)

 以上を踏まえて、warningやエラーが出ないようにしたコードが以下になります。

using UnityEngine;

public class SampleCode2 : 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
}

 thisがnullの時に処理をスキップするという、対処療法にも程がある修正ですが、これでwarningもエラーも対処できました。

解説編:Enter Play Mode時になにが起きているのか

 問題は解決したものの、結局「なぜOnValidate()が2回呼ばれるのか」「なぜ2回呼ばれるうちの片方がUpdateタイミングでnullになるのか」「そもそも、なぜEnter Play Mode時にOnValidate()が実行されるのか」などの疑問が解決できていません。
 実は、これらは全て、UnityがEnter Play Mode時に行っている内部処理に起因してます。以下、説明できる範囲での解説編になります。

Enter Play Mode時は全てのメモリ状態がリセットされる

 Enter Play Mode時、Unityはビルド版と(極力)同じ状況で実行する為に、Edit Mode時のメモリ状態を一度リセットし、ビルド版と(極力)同じ初期化手順でメモリ状態を再構築します。ここでキーワードになるのが「EditModeインスタンス」と「PlayModeインスタンス」、そして「ドメインリロード」と「シーンリロード」です。

「EditModeインスタンス」と「PlayModeインスタンス」

 UnityエディタにはEdit ModeとPlay Modeがあり、どちらのモードでも、シーンに配置されたオブジェクトのプロパティをインスペクタから更新できます。また、Edit Mode時に更新した値はPlay Mode時にも引き継がれ、逆に、Play Mode中に値が更新された場合でも、Edit Modeに戻った時は、元の値に戻ります。
 この一連の挙動は自然に行われているように見えますが、いざ考えてみると、内部的にはかなり複雑な事をしているように思えます(し、実際複雑な事をしています)。
 例えば、あるクラスのstaticプロパティが宣言時に初期値が設定されていて、その値がEdit Mode実行中に更新されているとします。このstaticプロパティをEnter Play Mode時に初期値に戻すにはどうすれば良いでしょうか? 一度アセンブリ(DLL)を捨てて、再読込するしかないように思えます。そして、Unityは実際にそうしているのです。
 つまり、シーンに登録されたオブジェクトはEdit ModeとPlay Modeでは異なるインスタンスが生成されているのです。これらは正式な呼称がないのですが*3、それぞれ「EditModeインスタンス」「PlayModeインスタンス」と呼ぶ事にします。

「ドメインリロード」と「シーンリロード」

 Enter Play Mode時、大きく「ドメインリロード」と「シーンリロード」という二つの初期化処理が実行されます。

ドメインリロード

 ドメインリロードでは、ゲームを構成するアプリケーションドメイン(DLLと考えて良いと思います)を再構築します。
 ドメインリロードの開始時、まず全てのEditModeインスタンスがdisableになり、その後にそれらの各パラメータを一時メモリに保存します(これを「シリアライズ」と言います)*4。その後に全てのオブジェクトが破棄され、まっさらな状態になります*5。当然ですが、登録されていたイベントハンドラもここでリセットされます。

 次にアセンブリが読み込まれます。このタイミングで各クラスの静的コンストラクタが呼びだされ、前項で説明したstaticプロパティの再初期化が行われます*6
 この後でアクティブシーンに登録されたオブジェクトが生成され、その後で、保存されていたインスペクタパラメータが再設定されます(これを「デシリアライズ」と言います)。この時、「インスペクタのパラメータが更新された」ので、1回目のOnValidateイベントが発生します。

シーンリロード

 ドメインリロードが終わると、次にシーンリロードが行われます。シーンリロードでは、実行するシーンが改めて再構築されます。
 まず、ドメインリロードで再生成されたシーン上のオブジェクトが全て削除されます(!)。ただし、これらはDestroy()によって削除されるだけなので、staticプロパティの初期化状態は維持されます。
 次に、改めてシーンに登録されたオブジェクトが生成され、再度デシリアライズされます。この時、「インスペクタのパラメータが更新された」ので、2回目のOnValidateイベントが発生します。

 おわかりかと思いますが、これがOnValidate()が2回実行される理由です。また、ドメインリロード時のインスタンスはシーンリロード開始時にDestroyされているので、遅延update内においてはthis==nullになってしまっているのです。

 この後、各インスタンスのAwake()が実行され、OnEnableイベントが発行された後で、ようやくStart(),FixedUpdate(),Update(),LateUpdate()というお馴染みのサイクルが始まります。

で、なんでOnValidate()は2回実行されるの?

 Enter Play Mode時の挙動は分かりました。が、であれば、結局、なぜシーンオブジェクトは2回再構築されるのでしょうか? ドメインリロード時にはstaticプロパティとイベントハンドラのみ再初期化して、シーンオブジェクトの生成はシーンリロードのみで行えば良いように思えます。残念ながら、土屋が調べた範囲ではこれについて納得のいく言及を見つけることは出来ませんでした。
 一個考えられるのは、Unity2019から、Play Modeに入る際の時間を短縮する為に、ドメインリロードとシーンリロードのどちらか(または両方)をスキップするための「Enter Play Mode Settings」というオプションが追加されまして*7、このオプションを用いてどちらかのリロード処理をスキップしたとしても、確実にシーンオブジェクトの初期化が行われるようなフローにしたかったのかもしれません*8

終わりに

 Enter Play Mode時にUnityが内部で複雑な初期化処理をしている事をおわかりいただけたかと思います。ついでに言うと、MonoBehaviour継承クラスでコンストラクタを使えず、初期化にはAwake()(もっと言えばStart())を使わなければいけないのも、このような複数回のオブジェクト生成で、正常なタイミングでの動作が保証されているメソッド(例えばStart())とそうでないメソッド(例えばコンストラクタ)があるためだと考えられると思います。

 今回はドメインリロードとシーンリロードの内容を随分簡略化して解説しました*9。普段のUnity開発中に意識することはなかなか無いかもしれませんが、いざ初期化周りでハマった時にこの辺りの挙動が分かってないのと泥沼になるので、参考になればと思います。

参考リンク

「ドメインリロード」「シーンリロード」の挙動についてのまとめ。ただし、何故かフローチャートの画像が若干不完全で、テキストの解説と対応していないので注意。
docs.unity3d.com

公式フォーラムでのドメイン・シーンリロードが採用された際の公式アナウンス、こちらにアップされているフローチャート画像の方がドキュメント版よりも詳細です。
forum.unity.com

"SendMessage cannot be called during Awake, CheckConsistency, or OnValidate"という警告に対応する方法についての公式フォーラムでの議論。2018年から現在まで(!)継続しています。
forum.unity.com

Canvasのカスタムコンポーネント実装の参考にさせてもらいました。記事内でリンクされている別記事も合わせて読んでおくとCanvasの理解が深まるでしょう。
enrike3.hatenablog.com

Unityには"fake null"と呼ばれる特殊な処理があり、GameObjectがDestoryされてから、接続されたC++のネイティブオブジェクトが削除されるまでの間、GameObjectのthisがnullを返したり*10、等価比較演算子がオーバーロードされてたりします。
blog.unity.com

*1:将来的にEnter Play Mode時の挙動が変更される可能性があるため仕様として公開していないのだと思われる。あるいは互換性の為に本来不要な処理が入っているのを負の遺産として継承していて合理的な解説を書けないかのどっちか

*2:デリゲートに登録されたオブジェクトが削除されることがあるのかという気がするけど詳しく無いので知らない。Unity固有の現象かもしれない

*3:多分

*4:シリアライズされたデータは、Play ModeからEdit Modeに戻った時にパラメータを元に戻す際にも使用されます。アセットファイルに保存していなくてもパラメータが維持されるのはこの仕組みによります

*5:この作業はアセンブリを削除してメモリを解放しているのだと思われるが、ドキュメントでは"Unity Child Domainを削除する"とだけ書かれていて、実際にどのような処理が行われるかは明示されていない

*6:C#の仕様では、静的コンストラクタ(cctor)が実行されるのはそのオブジェクトが生成された時か、staticプロパティに最初にアクセスした時のみ。恐らくだがUnityがメッセージ関数のリストアップの為に各クラスにアクセスする必要がありその為に初期化が走るのだと思われる

*7:これについてはいずれ記事にしたいと思います。特にUnity2021からやたら起動が遅くなったので活用していきたい

*8:ちなみに、ドメインリロードの処理をスキップした場合は、少し異なるタイミングでシリアライズが行われるようです

*9:実際にはこれに[ExecuteAlways]が入って更に複雑になる

*10:これの正確な振る舞いはちょっと良く分かってない。ただNull参照例外発生時に専用のエラーメッセージが返せるので、本当にthisがnullになっている訳ではないと思われる