🦔

UnityのMonoBehaviourとのお付き合いの仕方

2024/03/13に公開

はじめに

ゲームエンジンであるUnity の MonoBehaviour と適切な距離の取り方がわからない方向けにまとめます。UnityとはUnity 5の頃からずっとお付き合いしてきて、10年近くなります。以来、MonoBehaviourのアンドキュメントな仕様に裏切られて続けてきました。
本稿ではMonoBehaviourの安全な使い方および使いどころについて自分ながらにまとめたいと思います。

前提

MonoBehaviourと適切なお付き合いをするにはそもそもMonoBehaviourとはいったい何なのかを抑えておかねばなりません。

MonoBehaviour は素人向けの機能

MonoBehavoiur は非エンジニア向けの機能です。プロのエンジニアは使うべきではありません。
本職のエンジニアは pure C# で実装しましょう。MonoBehaviour はブリッヂ部分として使うべきでありロジックを持つべきでありません。

ただし、学生、非IT経験者、アーティスト、サウンド、プランナといった一般的にC#つよつよじゃない方はMonoBehviourを使いましょう。ガンガン使うべきです。プログラミングがよくわからない方でも、簡単に振る舞いを実装できるようにすることがゲームエンジンの本懐なのですから。

重ねての言及になりますが、プロのC#er はMonoBehaviourを安易に使うのはやめましょう。テストコードも書きにくいし、パフォーマンスもでないし、実行タイミングがよくわからんし、とにかくいうことを聞きません。とはいえ、MonoBehaviour無しではUnityでゲーム実装できません。そのため、MonoBehaviourとはちょうどいい適切な距離を取ってお付き合いする必要があるのです。

要点

MonoBehaviourの適切と思われる私なりの使い方は次の通りです。

  1. MonoBehaviour はSerializeFieldのために使う
  2. MonoBehaviour はOnEnable で初期化しOnDisableで破棄する

詳解

完成系となるMonoBehaviourのテンプレコードを記載します。
以下のテンプレコードに従って解説していきます。

namespace MyGame.MyModule
{
public sealed class MySomeMonoBehaviour : MonoBehaviour
{
    // コンポーネントへの参照 今回は例としてカメラ
    [SerializeField] private Camera _camera; 

    // 実装への参照
    private MyCameraController _impl;

    // 初期化を担うものとしてのOnEnable
    private void OnEnable()
    {
        _impl = new MyCameraController(_camera);
    }

    // 破棄を担うものとしてのOnDisable
    private void OnDisable()
    {
        _impl.Dispose()
        _impl = null;
    }

    // ロジックを持たず移譲するだけのUpdate
    private void Update()
    {
        _impl.Update();
    }
}

// 例としてカメラ制御クラス
internal sealed class MyCameraController : System.IDisposable
{
    private Camera _camera; // readonly フィールドはやめとけ
    // private readonly Camera _camera;

    // コンストラクタインジェクションで依存物をもらう
    public MyCameraController(Camera camera)
    {
        _camera = camera;
    }

    // GCを早めるための明示的null代入
    public void Dispose()
    {
        // leadked managed shell対策のnull代入
        _camera = null;
    }

    public void Update()
    {
        // fake null対策の ガード節
        if(!_camera) { return; }

        // ロジック本体をここに書く
    }
}
}

1行目から順番に解説していきましょう。

namespace に必ず入れる

namespace MyGame.MyModule {
}

グローバルnamespaceを使うと名前衝突によりコンパイルできなくなる危険があります。
後で名前を変更するのは非常に大変なので必ずnamespaceに入れておきましょう。
namespace はモジュール分けを意識する設計の最上流です。どのような名前を付けるかで全体像が大いに変わってきます。

もしnamespace の名づけに迷ったらUnityの公式パッケージを参考にしましょう。
Addressables や Cinemachine などの(比較的)後発パッケージは大変良くできています。

sealed 句はとりあえずつけてよい

public sealed class MySomeMonoBehaviour : MonoBehaviour

class修飾のsealed はとりあえずつけておいて問題ないと思います。
なぜなら後からsealed を付けるのは困難ですが、sealed を削除するのは簡単だからです。継承したくなったら sealed を削除すればいいです。

もちろん継承するつもりがあるならsealed は付けません。ただし、3重4重以上の継承は人智を超えるので用法用量にお気をつけください。

sealed を付与すると乱暴な継承によるコードの複雑性の増加が抑えられます。前提として継承はコードを難しくします。経験上、継承しか知らない方が無理矢理継承を使うことが多いように感じています。それはhas-a関係で解決できないでしょうか?DRY原則やis-a関係を鑑みて、継承することが真に正解である場合のみ継承してください。

ちなみにsealed を付与するとコンパイラによる最適化が効いてvtable経由ではなく、直接関数が呼び出されるようになりオーバーヘッドが減ります。少し気分がいいですね。

SerializeFieldで依存性の解決を行う

    [SerializeField] private Camera _camera; 

ゲームエンジンを使う理由がこれではないでしょうか?
インスペクター上でコンポーネントを設定できるのが大変便利です。
オーサリング時に依存性や設定値の解決ができるため、ランタイム時に考えることが減ります。
prefab上で設定しておけば、Missingになる危険も減ります。
特にUIを作るときは SerializeField を使いまくることかと思います。
巷で見かけるサンプルコードではpublic がよく使われていますが、必ず [SerializeField] private にしておきましょう。なぜなら、Serializeするということはインスペクターで設定したい、つまりスクリプトから設定して欲しくないからです。もしgetしたいならば、public getter のみを用意してください。

こんな感じ。
public Camera Camera => _camera;

MonoBehaviour はロジックへの参照を持つ

    // 実装への参照
    private MyCameraController _impl;

_impl が 何らかの実ロジックを持つコアクラスへの参照です。
MySomeMonoBehaviourクラスは メンバーフィールドに_implを所有していますね。これがロジックを持たず、ロジッククラスへの参照を持つということです。
 このような関係をhas-a関係と呼びます。has-a関係はカプセル化を保つことができるという点でis-a関係よりも優れています。継承よりもCompositionと言われるアレです。

ロジックへの参照を持つことで、MonoBehaviour自体はロジックを実装しなくなります。
ロジックをpure C#なclassへ逃がすことで、様々な恩恵が得られます。

  • null条件演算子?.が使える

  • コンストラクタを使ってRAIIを実現できる

  • IDisposable, IAsyncDisposable パターンと usingステートメントが使える

  • EditModeテストを用いて高速にイテレーションできる

    特にMonoBehaviourに直接ロジックを実装してしまった場合、PlayModeテストが必要になります。PlayModeテストはシーン切り替えやドメインリロードが遅くてお仕事に使えません。自動テストや開発者テストが困難になります。

今回例として挙げたMySomeMonoBehaviourクラス責務は依存性を解決すること、です。カメラ制御の責務はMyCameraControllerへ委譲しています。

OnEnableで初期化しOnDisableで破棄する

    // 初期化を担うものとしてのOnEnable
    private void OnEnable()
    {
        _impl = new MyCameraController(_camera);
    }

    // 破棄を担うものとしてのOnDisable
    private void OnDisable()
    {
        _impl.Dispose()
        _impl = null;
    }

長年の研究の結果 OnEnable と OnDisable 達は信用できるいいやつと分かりました。他にもAwake と OnDestroyも信用できます。信じていいのは2組だけです。

  • (Awake + OnDestroy)
  • (OnEnable + OnDisable)

間違っても Startで初期化してOnDisableで破棄する、といった非対称なことはしないでください。実行タイミングや呼び出し回数の違いにより非直感的な挙動を見せます。UnityにはOnDestroyが呼ばれないパスが存在することにも留意してください。

詳しくコチラ↓
https://baba-s.hatenablog.com/entry/2022/03/07/090000

new で依存物を渡す

_impl = new MyCameraController(_camera); の部分も芸術点高いです。
Cameraを使う場合、GameObject.FindCamera.mainを使いたくなりますが、より直接的に
SerializeField経由で取得したインスタンスをそのままコンストラクタ経由で渡しています。

この結果、pure C# なMyCameraController は難しいことを考えることなく必要なインスタンスへの参照を取得できています。カメラの検索処理が重いかも、とか取得すべきCameraインスタンスはキャラカメラなのかUIカメラなのか、等を考えなくて済みます。コンストラクタで渡されたカメラを制御することに集中できますね。必要ならばコンストラクタでバリデーションを行い、エラーや例外を投げることができます。参照透過性も高く保たれています。サイコー!

今回はコンストラクタインジェクションを使いました。
コンストラクタインジェクションはめちゃつよであり、Dependency Injection:DIの基本型なので多用していきましょう。

詳しくは IoC vs DI でググってみよう!

Disposeパターン

        _impl.Dispose()

C#では終了処理を確実に行うことが大変難しいです。なぜなら、C# にはデストラクタが存在しません。ファイナライザがこれに相当するのですが、GCのタイミングまで遅延されますし、いつGC回収されて呼び出されるかを制御できません。gen0なら比較的早いでしょうし、gen2に入ったらなかなかファイナライズされません。極めつけとして、ファイナライザは必ず呼ばれることが保証されていません。アプリ終了時に呼ばれるどうかは.NET実装依存です。

そこで確実な終了処理の仕組みとして登場するのがIDisposableによるDisposeパターンです。using ステートメントと組み合わせることでDispose呼び出しをかなり保証することができます。MonoBehaviourの場合、usingステートメントが難しいので、OnDisableで明示的にDispose呼び出しをしましょう。

Disposeパターンはリソースの破棄に使うという説明がよく見られますが、何にでも使っていいです。スコープを抜けたときに発動する関数呼び出し器として使っていいです。

参考情報:
https://learning.unity3d.jp/7224/

明示的 null代入

        _impl = null;

こちらはLeaked Managed Shell 対策およびメモリリーク時の被害最小化目的です。
今回は pure C# なclass だったので無理にnull代入しなくてもいいのですが、UnityEngine.Objectの派生型である場合はnull代入した方が安全です。

詳しくは公式の解説動画をご覧ください。

https://www.youtube.com/watch?v=UIwQmpQTtA4&ab_channel=UnityJapan

そのインスタンスがpure C#なのかUnityEngine.Object派生型なのかを区別することが大変なので一律null代入としています。別の考え方として、Dispose済みなオブジェクトへの参照を握り続けても意味がないので、null代入しています。

null代入の欠点として、_impl を readonlyフィールドにできなくなります。Leaked Managed Shell 問題を引くよりかはマシと考えて、私はnull代入を推奨しています。readonly による非null保証 vs Leaked Managed Shellの危険、というメリットデメリットを天秤にかけた結果、null代入した方がマシと考えました。 null条件演算子?. と組み合わせることでヌルポ例外を防ぐことができるようになりますし。

賢明な諸氏ならば pure C# なメンバフィールドはreadonlyにして UnityEngine.Objectなフィールドは readonlyにしないという使い分けができることでしょう。(がんばれ)

MonoBehaviourからのdelegateパターン

    // ロジックを持たず移譲するだけのUpdate
    private void Update()
    {
        _impl.Update();
    }

MonoBehaviourにロジックを持たせるのは避けましょう。テストコードが全然書けません。
数行で収まる程度ならまぁええか、とも思うのですが、一つの指標としてテストコードを書きたくなったらMonoBehaviourからお別れしましょう。delgate パターンと HambleObjectパターンの合わせ技を使って実装をC#クラスへと委譲してください。

他にもMonoBehaviourだけに許されるメッセージ系関数がいくつかあります。

  • OnTriggerEnter/ Stay / Exit
  • OnCollistionEnter
  • FixedUpdate
  • LateUpdate
  • OnAnimatorIK
  • などなど

これらはすべてMonoBehaviourに実装したくなりますが、そんなことはありません。C# の eventdelegate Action<T>などを駆使してコールバックしてください。典型的な実装としてR3.Triggers があります。(UniRxよりもR3かな)

https://github.com/Cysharp/R3?tab=readme-ov-file#triggers

(そもそも、MonoBehaviour.Updateなんて使うなというお気持ちもあるのですが、それはまた別のお話)

pure C#で実装する

// 例としてカメラ制御クラス
internal sealed class MyCameraController : System.IDisposable

ここから先はUnity関係なくただのC#論になります。

基本的にinternal にしとけ

再掲。

internal sealed class MyCameraController

本記事冒頭で namespace に入れることを推奨しました。聡明なUnityエンジニアならば、namespaceと asmdefを適切に設定されていることでしょう。その効果を十全に発揮するためにinternal修飾を駆使しましょう。

今回の事例として挙げたコードはMySomeMonoBehaviourから使われる内部クラスです。そのため外のモジュールに公開する必要はありません。カプセル性が高いほど保守性が高まりますのでinteralにしましょう。
ゲーム開発では仕様変更が頻繁に発生します。APIや内部実装も頻繁に変更したいのです。そのときinternalクラスであれば、そのモジュール内での影響さえ気を付ければよいのです。保守性が高い、特に改造・変更しやすいということはゲーム開発では正義なのです。

public クラスであっても 基本的にprivate, internalメソッドを可能な限り使いましょう。publicメソッドは案外少ないものです。

UnityEngine.Objectへの参照を持つ

    private Camera _camera; // readonly フィールドはやめとけ
    // private readonly Camera _camera;

    // コンストラクタインジェクションで依存物をもらう
    public MyCameraController(Camera camera)
    {
        _camera = camera;
    }

    // GCを早めるための明示的null代入
    public void Dispose()
    {
        // leadked managed shell対策のnull代入
        _camera = null;
    }

繰り返しの言及になりますが、Leaked Managed Shell対策です。 readonly フィールドにはできません。コンストラクタで受け取り、Disposeでnull代入しましょう。pure C#なので OnDestroyは存在しません。C# で使えるデストラクタっぽく使える機能は IDisposableだけです。ファイナライザに期待すると危険です。

なお非同期破棄がしたいならば IAsyncDisposableを継承してください。UniTaskを使っているならばIUniTaskAsyncDisposable を継承した方がよいでしょう。

UnityEngine.Objectへの参照を安全に使う

    public void Update()
    {
        // fake null対策の ガード節
        if(!_camera) { return; }

        // ロジック本体をここに書く
    }

参照を使用する前に必ずnullチェックをしましょう。UnityEngine.Objectに非null保証はありません。いつでもどこでもDestroyされて fake null に落ちることがあるからです。同一のスコープ内であれば冒頭でチェックすればよいです。フレームを跨いだ場合、非null保証は消えてなくなります。

よくある危険な使い方

private void Initialize()
{
    // 有効なやつを取ってくる
    _renderes = GameObject.FindObjectsByType<Renderer>(FindObjectsSortMode.None)
                   .Where((r)=> (bool)r);
    foreach(var renderer in _renderes)
    {
        renderer.enabled = true; // 表示
    }
}

private void UpdateImpl()
{
    foreach(var renderer in _renderes)
    {
         // まだ有効だと思い込んでしまいチェックを怠る
         // if(renderer){ continue; } // 都度 fake nullチェックが必要

         renderer.enabled = true; // 例外が出る危険!
    }
}

private void Finalize()
{
    //非表示にする
    // 後片付け出来て偉い!
    foreach(var renderer in _renderes)
    {
         // しかし、まだ生きていると思い込んでしまいチェックを怠る
         // if(renderer){ continue; } // 都度 fake nullチェックが必要
        renderer.enabled = false; // 例外が出る危険!
    }
}

やりがちです。自分でインスタンスへの所有権を握ったと思っているのでチェックを冒頭で行えばいいやと考えがちです。がUnityEngine.Component の所有権はだれにも握れません。
なぜならば、1.どこからでもDestroyすることができるから、2.UnityEngine側で勝手にDestroyされるからです。

1について説明します。まずComponentは何らかの形でGameObjectに紐づきます。そしてComponentが紐づくGameObjectはGameObject.Find で探してこれます。Scene.RootObject当たりからトラバーサル出来ます。DontDestroyOnLoadなGameObjectの一覧も探してこれます。それらをよそのクラスが勝手にDestroyすることができてしまいます。

2について説明します。OnApplicationQuit によってすべてのシーンが破棄されます。よってすべての Componentは破棄されます。シーンアンロード時もシーンに紐づくGameObjectが破棄されます。

Dispose呼び出しやfinally句やOnDestroy呼び出しよりも先にそのComponentが破棄されることがあるのです。

というわけで、専らシーン切り替えや、UnityEditor の再生を終了(PlayModeを終了)したときに例外が発生します。気を付けましょう。シーン切り替えおよびUnityEditor の再生終了時の例外そのものが問題になることはほとんどありません。問題になるのは例外により後続の処理がスキップされることです。finally句の中や catch句の中でさらに例外を出すことです。(例外が隠蔽される)
気を付けましょう。

対策

thin ラッパーを用意してUnityEngine.Object をラップする作戦、NullObjectを代入する作戦などいくつか対策はとれるのですが、完全ではありません。
(だれか教えてください)

まとめ

MonoBehaviour との適切な付き合い方は奴にロジックを持たせないことです。ゲームドメインのロジックはpure C#で実装するにかぎります。MonoBehaviourの専らの使いどころは SerializeField を活用して参照を取ってくる点にあります。それ以外のモノビヘならではの利点は回避策がとれますから、回避しましょう。
OnEnableで初期化してOnDisableで破棄する、これだけでも安定性が段違いに上がります。

Discussion