MonoBehaviourアンチが採用する技術

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

https://qiita.com/advent-calendar/2023/hikky

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

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

https://docs.unity3d.com/ja/2021.1/ScriptReference/MonoBehaviour.html

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

[^1]: C#の?や??演算子は使わない方がいいとの記述
https://light11.hatenadiary.com/entry/2019/09/01/223507
[^2]: `activeSelf=false` だと `Awake()` 実行されないとか、親子関係のないオブジェクト間だと `Awake()` のタイミングが実質ランダムだったり。

巷では `MonoBehaviour` から脱却するための考え方やノウハウを公開している記事もあります。
* [Unityにおける「設計レベル」を定義してみた
レベル4 : MonoBehaviourへの依存をやめた状態](https://qiita.com/toRisouP/items/79b97c472e588bb91c52#%E3%83%AC%E3%83%99%E3%83%AB4--monobehaviour%E3%81%B8%E3%81%AE%E4%BE%9D%E5%AD%98%E3%82%92%E3%82%84%E3%82%81%E3%81%9F%E7%8A%B6%E6%85%8B)
* [ホロライブアプリを育て続けるために：MonoBehaviour分離編](https://note.cover-corp.com/n/n7f5c3a2c6adc)

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

# 使用技術
* Unity (2023.2.1f1)
* VContainer
* UniTask

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

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

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

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

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

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

## UI画面やワールドを作るため
そもそもC#関係なくてもシーン上にUIや何かしらのObjectをおけばそれは `GameObject` であり `MonoBehaviour` です。

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

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

https://github.com/naninunenoy/AntiMonoBehaviour

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

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

https://docs.unity3d.com/ja/2020.3/ScriptReference/RuntimeInitializeOnLoadMethodAttribute.html

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

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

https://vcontainer.hadashikick.jp/ja/scoping/project-root-lifetimescope

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

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

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

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

```c#
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");
    }
}
```

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

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

## 状態遷移

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

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

```c#: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: 結果表示が終了までループする
* ゲームの結果を受け取りそれを表示する
* 結果画面ではタイトルに戻るか、もう一度ゲームを行うが選択できる

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

[^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

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

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

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

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

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

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

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

https://qiita.com/sakano/items/872a9e4cdf14be0f0c61#registerbuildcallback%E3%82%92%E5%88%A9%E7%94%A8%E3%81%99%E3%82%8B

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

```c#: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` を書き換えます。

```c#: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();
    }
}
```

その上で、`RootLifetimeScope`で `Func<SplashScreenProcess>` DIします。

```c#: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 も破棄する仕掛けとしました。

```c#: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は存在しないので後で渡してやる必要があります。
今回は拡張メソッドとして以下のように定義しました。

```c#
//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` を渡すくらいなら、いっそ継承させた方が楽では?と思ったためです。

```C#
[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という何でもできる最大公約数的なプラットフォーム上にででベタに実装していくよりもあなたの用途に特化した実装になっていきます。そして、実装が用途に特化すると余分な部分が省略される分、実装のために記述する量が減り、コードの読みやすさやにつながることが期待できます。(とはいえ特化する反面カバーしきれない部分が出てくるのが難しいところです)

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

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

# 参考または参考にしてほしい記事
* [Unity専用最速DIコンテナVContainer_と、UnityにおけるDIの勘所](https://scrapbox.io/hadashiA/Unity%E5%B0%82%E7%94%A8%E6%9C%80%E9%80%9FDI%E3%82%B3%E3%83%B3%E3%83%86%E3%83%8AVContainer_%E3%81%A8%E3%80%81Unity%E3%81%AB%E3%81%8A%E3%81%91%E3%82%8BDI%E3%81%AE%E5%8B%98%E6%89%80)
* [async/await,disposableを使って素直で読みやすいコードを書く](https://learning.unity3d.jp/7224/)