【Unity】コンポーネントの初期化にAwakeを使うか自作メソッドを使うかで例外発生時の止まり方が違う

(※この記事は先月投稿したQiita記事のミラーです)

Instantiateなどで生成したコンポーネントを、その場ですぐに初期化する場合、大きく分けて2つの方法があると思います。

  1. Awake() のように、Unityのメッセージで初期化する
  2. Init() のように、メソッドを呼んで初期化する

どちらにもそれぞれのメリットがあります。
例えばAwakeはいつ呼ばれるのか単純明快ですし、生成側とコンポーネントが依存しない造りにできます。
一方メソッドを作るのなら引数を自由に変えられますし、Unity独特の仕様に依存しない造りにできます。
パフォーマンスも、InstantiateやAddComponentそれそのものに比べたら差はないも同然です。

というわけで、引数もなくて同じ所で呼ぶなら、様式以外は大差ない――

――と思っていました。

サンプル

その違いは例外処理にありました。
次の2つのコードは、上で説明した2パターンを再現したものの組み合わせです。

パターン1(Awakeで初期化)

public class Manager : MonoBehaviour {
    // ゲーム開始時にキャラクターを生成する
    void Awake() {
        Debug.Log("パターン1(Awakeで初期化)");

        var gameCharacter = new GameObject("GameCharacter");
        gameCharacter.AddComponent<GameCharacter>();

        Debug.Log("Game Start!");
    }
}
public class GameCharacter : MonoBehaviour {
    // だがそいつは初期化時にエラーを起こしてしまう死のキャラクターだった!
    void Awake() {
        Debug.Log("Initializing GameCharacter");
        throw new System.Exception("GameCharacter: Initialization failed");
    }
}

パターン2(Initメソッドで初期化)

public class Manager : MonoBehaviour {
    // ゲーム開始時にキャラクターを生成する
    void Awake() {
        Debug.Log("パターン2(Initメソッドで初期化)");

        var gameCharacter = new GameObject("GameCharacter");
        gameCharacter.AddComponent<GameCharacter>().Init();

        Debug.Log("Game Start!");
    }
public class GameCharacter : MonoBehaviour {
    // だがそいつは初期化時にエラーを起こしてしまう死のキャラクターだった!
    public void Init() {
        Debug.Log("Initializing GameCharacter");
        throw new System.Exception("GameCharacter: Initialization failed");
    }
}

結果

f:id:kraihd:20190517225637p:plain Managerを配置すると、1ではGame Startに到達していますが、2では到達しませんでした。

検証

C#の仕様上は、2のような挙動をするはずです。
つまりは「1は例外が起きたはずなのに止まっていない」ということです。
きっとUnityがAwakeを呼ぶとき何かしているに違いありません。
例ではAddComponentですが、もちろんInstantiate中に呼ばれるAwakeでも同じです。

試しにtryで囲ってみました。この他の部分はさっきと同じです。

// パターン1 + try-catch
try {
    var gameCharacter = new GameObject("GameCharacter");
    gameCharacter.AddComponent<GameCharacter>();
} catch {
    Debug.LogWarning("キャラクター生成できなかったけどいいよね?");
}
// パターン2 + try-catch
try {
    var gameCharacter = new GameObject("GameCharacter");
    gameCharacter.AddComponent<GameCharacter>().Init();
} catch {
    Debug.LogWarning("キャラクター生成できなかったけどいいよね?");
}

f:id:kraihd:20190517225529p:plain なんと前者は例外をキャッチできません。囲ってない状態と全く同じです。
別に、初期化が遅れて実行されているわけではないということは、ログからもスタックトレースからもわかりますが……。
これは「全てのメッセージは最初からtry-catchで囲まれている」ということか、もしくは同様の何かでしょう。

まとめ

  • Unity上のC#の例外処理は、Unityがメッセージ呼び出しをするたびに区切られます。
  • スクリプトの途中でメッセージが呼ばれる場合、メッセージから奥で例外が起きても、手前の処理は止まらずに続行します。

プログラムを書くときにはわざわざ例外が起きたときの止まり方まで意識はしないでしょうが、もしリリースビルドで明らかに例外が起きているっぽい状態に出くわしたら、何が動いていて何が止まったかから原因を特定するような時には役立つ知識かもしれません。

それにしても、UpdateのようにPlayerLoopから呼ばれるメッセージは当然こういう仕組みなんだろうとは思っていましたが、Awakeなどが途中で呼び出される場合でも同じというのは意外でした。