LoginSignup
22
19

MonoBehaviourアンチが採用する技術

Last updated at Posted at 2023-12-11

この記事はHIKKYアドベントカレンダー12日目の記事です。

昨日の記事は @SenooYudai さんの「オブジェクト指向の未来と古代東洋哲学」でした。
偉大なパラダイムを生んだ開発者でも既存の思想(フレームワークとも言えるかも)に影響を受けていたのではという話は示唆に富んでいますね。自分はAIによるパラダイムシフトも不安と楽しみ半々といったところですが、話題に乗り遅れないようにしたいところです。

初めに

UnityとC#を使って開発する人で MonoBehaviour を知らない人はいないでしょう。Unityの世界と自分の実装とを繋げる上で必須のクラスであり、おなじみのgameObjecttransform といったフィールドや GetComponent といったメソッドを提供してくれます。

しかし、null の扱いが独特だったり1Awake() などの初期化処理が呼ばれるタイミングがまちまちだったり2とUnityエンジニアの悩みの種でもあります。

巷では MonoBehaviour から脱却するための考え方やノウハウを公開している記事もあります。

筆者本人も readonly 主義者であり、MonoBehaviour をできるだけ使わない実装方針を色々と模索してきました。
本記事ではその過程で得られた MonoBehaviour を極力使わない実装方針を紹介します。

使用技術

  • Unity (2023.2.1f1)
  • VContainer
  • UniTask

使用技術からわかるように少なくとも初心者向けではありません。Unityでのセオリーの実装を修めたうえで読むことをお勧めします。

なぜ MonoBehaviour が必要なのか

筆者のような MonoBehaviour を可能な限り使わない方針ととっている開発者でも以下の3つのような目的で MonoBehaviour を使うことはあるのではないでしょうか。

  1. Unityからライフサイクルのイベントを取得するため
  2. [SerialzeField] によるDIのため
  3. UI画面やワールドを作るため

各項目について少し掘り下げます。

Unityからライフサイクルのイベントを取得するため

AwakeUpdate などUnityのライフサイクルから実行されるタイミングで目的の処理を実行したい場合は MonoBehaviour を継承する必要があります。
特に Awake はEntryPointとして使っている人も多いはずでず。またゲームロジックを実装するためには UpdateOnCollisionEnter が不可欠なことが多いと思います。

[SerialzeField] によるDIのため

シーン上の GameObject やそこから作られた Prefab は全てUnity上でシリアライズ可能であり、ドラッグ&ドロップで参照可能です。Unityに慣れすぎていると結構当たり前に感じるかもしれませんが、他のフレームワークだとファイルまでのパス指定が必要だったりするので、簡単に依存関係を設定できるのであえて MonoBehaviour とすることで [SerialzeField] に対応させることもあるかと思います。

UI画面やワールドを作るため

そもそもC#関係なくてもシーン上にUIや何かしらのObjectをおけばそれは GameObject であり MonoBehaviour です。

本題

ここまで前置きです。
この先は上記のライフサイクルとDIのために MonoBehaviour を使うモチベーションを VContainer を使うことで払しょくする方法について解説します。

本記事の解説のために作ったプロジェクトが以下になります

EntryPoint

EntryPointを定義しないとDebug.Logすら表示できません。前述の通り、MonoBehaviour をEntryPointとして使っている人は多いと思います。Gameというシーン上に GameSceneController みたいな名前のScriptがあれば多分それです。

MonoBehaviour に頼らないEntryPoitの一つに [RuntimeInitializeOnLoadMethod] があります。このアトリビュートがついたメソッドは実行時に最初に呼び出されるのでEntryPointとして使うことができます。

ApplicationEntryPoint.cs
public static class ApplicationEntryPoint
{
    [RuntimeInitializeOnLoadMethod]
    static void Entry()
    {
        Debug.Log("Entry");
    }
}

筆者は当初この方針で実装していましたが、VContainerに RootLifetimeScope という概念があることを知り、これをEntryPointとする方針に切り替えました。

設定方法は上のリンクを参照してください。以下のように記述することで実質シーン実行のEntryPontとして使えます。

RootLifetimeScope.cs
public class RootLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        Debug.Log("Entry");
    }
}

ただ、VContainerSettingsの RootLifetimeScope に参照させるためにはPrefab化する必要があるのでそこだけ注意してください。

そのうえでEntryPointとしたいクラスをVContainerに則って実装すれば MonoBehaviour に依存しないEntryPointを定義できます。

public class RootLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        // 全てのシーンで実行されると鬱陶しいので名前でガード
        if (SceneManager.GetActiveScene().name != "Entry") return;

        builder.RegisterEntryPoint<ApplicationEntryPoint>();
    }
}

public class ApplicationEntryPoint : IStartable
{
    public void Start()
    {
        Debug.Log("Entry");
    }
}

念のため補足ですが、UnityEngine に依存しないのでなく、MonoBehaviour に依存しないEntryPointです。VContainerはUnityを前提としたライブラリです。

LifetimeScopeMonoBehaviour やんけ!」と思った人は鋭いです。
本記事は MonoBehaviour に全く依存しないことは目的としていません。MonoBehaviour のライフサイクルに直接依存するよりは VContainer を経由して依存する方が余計な心配事が減るよねというスタンスでこの記事を書いています。

状態遷移

通常Unityでの状態(画面)遷移は Scene を使います。しかしここでは MonoBehaviour に頼らない実装を目指すため、Process という独自の形式で実装します。

Unityのシーン遷移ではシーン間の処理が分断されたり、各シーン間のパラメータの受け渡しに難儀したりすると思います。そこで Process では EntryPoint 上で処理のループを表現しつつ、パラメータを受け渡して処理を行う方針を実現しようと思います。
詳細は省きますが、例えばこんな感じです。

ApplicationEntryPoint.cs
public class ApplicationEntryPoint : IAsyncStartable
{
    async UniTask IAsyncStartable.StartAsync(CancellationToken cancellation)
    {
        // まずはスプラッシュ画面を表示
        var splashScreenProcess = new SplashScreenProcess();
        await splashScreenProcess.ShowSplashScreenAsync(cancellation);
        splashScreenProcess.Dispose();

        // 終了まで タイトル->ゲーム->結果表示を繰り返す
        var isNextTitle = true;
        while (!cancellation.IsCancellationRequested)
        {
            if (isNextTitle)
            {
                // タイトルの表示
                var titleProcess = new TitleProcess();
                await titleProcess.WaitForPressStartButtonAsync(cancellation);
                titleProcess.Dispose();
            }

            // ゲーム開始
            var gameProcess = new GameProcess();
            var result = await gameProcess.WaitForFinishGameAsync(cancellation);
            gameProcess.Dispose();

            // 結果の表示
            var resultProcess = new ResultProcess();
            resultProcess.ShowResult(result);
            var next = await resultProcess.WaitForNextActionAsync(cancellation);
            resultProcess.Dispose();

            isNextTitle = next is ResultNextActionType.BackToTitle;
        }
    }
}

どうでしょうか?
ApplicationEntryPoint.csを読むだけで以下のことが読み取れると思います。

  • まずSplashScreenが表示され、そのあとタイトルが表示される
  • タイトル :arrow_right: ゲーム :arrow_right: 結果表示が終了までループする
  • ゲームの結果を受け取りそれを表示する
  • 結果画面ではタイトルに戻るか、もう一度ゲームを行うが選択できる

なお、IAsyncStartable.StartAsync に渡される CancellationToken ではそのクラスを登録した LifetimeScope (ここでは RootLifetimeScope)が破棄されたときにキャンセルが発生します。3

さらに次の節では各Processへのパラメータの受け渡しをVContainerにも任せる方法を説明します。

[Inject] をいい感じに利用したい

今回はゲームの結果など関心の高いパラメータは Process クラスのコンストラクタやメソッド経由で渡しつつ、実装の裏側の隠ぺいしたい(勝手に依存解決していてほしい)パラメータに関しては [Inject] 経由で受け渡すことを目指したいと思います。

例えば、ResultProcess.ShowResult には GameResult クラスのインスタンスが渡されることを明確にしつつ、ゲーム全体で共有したい設定値だったり、セーブデータ保存先の情報だったり、あるいはAPIの環境変数などは勝手に行き渡って欲しいということです。

(余談)皆さんは何でもかんでもコンストラクタ渡しにした結果、訳わからんくなったことはありませんか? 私はあります。

VContainerでは通常、MonoBehabiour であるもしくは IInitializableIStartable など専用のinterfaceを実装しないと [Inject] の値が提供されません。

しかし、RegisterBuildCallback を利用することで通常のC#のクラスでも[Inject]の恩恵に預かる方法がこちらの記事に書かれています

一旦簡単のため以下のように実装された SplashScreenProcess に任意のメッセージを表示する方法を考えます。

SplashScreenProcess.cs
public class SplashScreenProcess : ProcessBase
{
    [Inject] readonly string _message;

    public async UniTask ShowSplashScreenAsync(CancellationToken cancel)
    {
        Debug.Log(_message);
        await UniTask.Never(cancel);//一旦ここで停止
    }
}

まず、[Inject] のために独自でnew SplashScreenProcess() するのでなく、VContainerに任せる必要がありますので ApplicationEntryPoint を書き換えます。

ApplicationEntryPoint.cs
public class ApplicationEntryPoint : IAsyncStartable
{
    [Inject] Func<SplashScreenProcess> _createSplashScreenProcess;

    async UniTask IAsyncStartable.StartAsync(CancellationToken cancellation)
    {
        // まずはスプラッシュ画面を表示
        var splashScreenProcess = _createSplashScreenProcess();
        await splashScreenProcess.ShowSplashScreenAsync(cancellation);
        splashScreenProcess.Dispose();
    }
}

その上で、RootLifetimeScopeFunc<SplashScreenProcess> DIします。

RootLifetimeScope.cs
public class RootLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        // 全てのシーンで実行されると鬱陶しいので名前でガード
        if (SceneManager.GetActiveScene().name != "Entry") return;

        // プロセスの生成
        builder.RegisterFactory(() =>
        {
            // 処理単位ごとにLifetimeScopeを作成する
            var scope = this.CreateChild(childBuilder =>
            {
                childBuilder.RegisterInstance("起動中...");
                childBuilder.RegisterPlainEntryPoint<SplashScreenProcess>();
            });
            return scope.Container.Resolve<SplashScreenProcess>();
            // しかし、このままだと作成された LifetimeScope のインスタンスは破棄されない
        });

        // EntryPointの設定
        builder.RegisterEntryPoint<ApplicationEntryPoint>();
    }
}

こうすることで、SplashScreenProcess にDIされた文字列である "起動中..."[Inject] アトリビュートで取得することができます。

作成したLifetimeScopeの破棄

処理単位である Process 毎にLifetimeScopeを作成し、必要なパラメータをDIすることを考えます。
先の処理のように、CreateChild() を行うことで、実行したLifetimeScopeを親とする別の LifetimeScope を作成することができます。また、子となった LifetimeScope は親にDIされたパラメータも参照することができます。

だたし、作成した LifetimeScope は明示的に Dispose() を呼んで破棄する必要がある点に注意します。特に RootLifetimeScope とした LifetimeScope は DontDestroyOnLoad となるようなので特に注意が必要です。

今回は Process 毎に作成された LifetimeScope をベースクラスに保持させ、Process の Dispose() とともに LifetimeScope も破棄する仕掛けとしました。

ProcessBase.cs
public abstract class ProcessBase : IProcess
{
    LifetimeScope _lifetimeScope;

    public virtual void Dispose()
    {
        if (_lifetimeScope != null)
        {
            _lifetimeScope.Dispose();
        }
    }

    public void SetLifetimeScope(LifetimeScope lifetimeScope) => _lifetimeScope = lifetimeScope;
}

SplashScreenProcess がVContainerにDIされるタイミングではProcessに渡したい子の LifetimeScopeは存在しないので後で渡してやる必要があります。
今回は拡張メソッドとして以下のように定義しました。

//CreateChildに渡す用にIInstallerを定義
public class SplashScreenInstaller : IInstaller
{
    public void Install(IContainerBuilder builder)
    {
        builder.RegisterInstance("起動中...");
        builder.RegisterPlainEntryPoint<SplashScreenProcess>();
    }
}

public class RootLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        //略
        var splashScreenInstaller = new SplashScreenInstaller();
        builder.RegisterFactory(() => CreateProcess<SplashScreenProcess>(splashScreenInstaller, this));
        //略
    }

    static TProcess CreateProcess<TProcess>(IInstaller installer, LifetimeScope parentLifetimeScope)
        where TProcess : ProcessBase
    {
        var childScope = parentLifetimeScope.CreateChild(installer);
        //対応するProcessの名前を反映しておく
        childScope.name = $"{typeof(TProcess).Name}LifetimeScope";
        //Processの生成後にProcess用のLifetimeScopeを渡す
        var process = childScope.Container.Resolve<TProcess>();
        process.SetLifetimeScope(childScope);
        return process;
    }
}

設定値にはScriptableObjectがおススメ

Process という処理単位を使用する上での問題点の一つにUnity上のアセットをどう参照させるかがあります。通常のシーンと MonoBehaviour を使った方式では [SerializeField] にPrefabなりテクスチャなりを参照させればおしまいですが通常のC#のクラスには渡せません。

参照させたいアセットを Addressable にしてすべてのアセットをアドレス指定で読み込んでくる方法もありますが、やはり参照用の ScriptableObject を作成して Process に渡すのが安パイかと思います。さらにここでは先ほど CreateChild に渡した IIsntaller の実装も ScriptableObject に任せてしまう方針としました。最初は分けてましたが、IInstaller に毎度 ScriptableObject を渡すくらいなら、いっそ継承させた方が楽では?と思ったためです。

[CreateAssetMenu(fileName = "SplashScreenInstaller",
    menuName = "AntiMonoBehaviour/SplashScreenInstaller", order = 0)]
public class SplashScreenInstaller : ScriptableObject, IInstaller, ISplashScreenParams
{
    [SerializeField] string splashScreenMessage;
    [SerializeField] float splashScreenSeconds;

    public override void Install(IContainerBuilder builder)
    {
        base.Install(builder);
        builder.RegisterInstance(this).As<ISplashScreenParams>();
        builder.RegisterEntryPoint<SplashScreenProcess>();
    }

    public string SplashScreenMessage => splashScreenMessage;
    public float SplashScreenSeconds => splashScreenSeconds;
}

// Installer を直接渡す(渡される)のは気が引けるのでinterfaceを挟んでおく
public interface ISplashScreenParams
{
    string SplashScreenMessage { get; }
    float SplashScreenSeconds { get; }
}

public class SplashScreenProcess : ProcessBase
{
    [Inject] readonly ISplashScreenParams _params;

    public async UniTask ShowSplashScreenAsync(CancellationToken cancel)
    {
        Debug.Log(_params.SplashScreenMessage);
        await UniTask.Delay(TimeSpan.FromSeconds(_params.SplashScreenSeconds), cancellationToken: cancel);
    }
}

考察~得られるもの失うもの~

今回の実装で得られたものは見通しよさげな前述の ApplicationEntryPoint の実装です。
その代わりUnity上でなく独自システムの上に実装したため、Unityの機能を直接使用できなくっています。例えば、プロジェクト内のファイルの参照を得るにしても [SerializeField] で持って来れないのでいちいちScriptableObjectを経由しないといけない煩わしさがあります。
おそらく要件が複雑になるにつれ、素直に MonoBehaviour 依存にしておけばよかったと後悔するでしょう。

良さげな設計を目指して MonoBehaviour 非依存を突き詰めたのにこれでは報われませんね。MonoBehaviour に使い辛さがあるのは事実でしょうが、MonoBehaviour 依存を無くしても必ずしも良いことばかりではないでしょう。結局は目的のアプリやゲーム(あるいはチーム事情)に合わせて変えていくしかありません。

しかし、あえてUnityに頼り切らない実装をしてみることで見えてくるものもあります。
例えば今回、各Process では毎回最初に Prefab を Instantiate しています4が、これを共通処理にできないかだったり、Process/Installer/View の関係性をまとめられないかだったりを考えると何らかの共通項が見えてきます。
共通項をまとめて仕様として独自に実装していくことで、Unityという何でもできる最大公約数的なプラットフォーム上にででベタに実装していくよりもあなたの用途に特化した実装になっていきます。そして、実装が用途に特化すると余分な部分が省略される分、実装のために記述する量が減り、コードの読みやすさやにつながることが期待できます。(とはいえ特化する反面カバーしきれない部分が出てくるのが難しいところです)

終わりに

いかがでしたか?イロモノ記事でしたが役立てそうなエッセンスを吸収して頂ければ幸いです。

参考または参考にしてほしい記事

  1. C#の?や??演算子は使わない方がいいとの記述
    https://light11.hatenadiary.com/entry/2019/09/01/223507

  2. activeSelf=false だと Awake() 実行されないとか、親子関係のないオブジェクト間だと Awake() のタイミングが実質ランダムだったり。

  3. StartAsync is provided with a CancellationToken that is canceled when the LifetimeScope that registered it is destroyed.
    https://vcontainer.hadashikick.jp/ja/integrations/unitask#cancellationtoken

  4. たとえばここ
    https://github.com/naninunenoy/AntiMonoBehaviour/blob/d85970ec17797363365600cbf149e1bdadb31393/Assets/AntiMonoBehaviour/Scripts/Processes/GameProcess.cs#L17

22
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
19