マルチシーン構成のプロジェクトに Doinject を導入して、いかなるシーンの作業中でもUnityEditor を直接再生できるようにする

Published: Dec 21, 2024 by mewlist

マルチシーン構成の Unity プロジェクトを開発しているとして、 以下のような構成を想定してみましょう。

Application.unity
 + Title.unity   # タイトル画面 - App シーンが読み込まれていないと動かない

このようなシーン構成を持つプロジェクトでは、たとえば、Title 画面を編集中の場合、一度 Application シーンを開き直してから、 Unity Editor を再生開始しないと正しく動かないというケースが多いのではないでしょうか。

編集したら「シーンを開き直す」という作業を我慢するだけで良いと考えがちですが、 ゲーム開発という不確定要素に溢れ、試行錯誤の繰り返しを必要とする作業においては、 開発効率を低下させる要因となります。

非効率な開発作業の例

例えば、インゲーム実装を行う InGame シーンを開発しているとします。 この InGame シーンは、App シーンが読み込まれたあとに、 マルチシーン読込(LoadSceneMode.Additive) されたときに正しく動作することを期待しているとします。

インゲームの実装やデータの設定を行ったあとは、動作確認をすることになります。 しかし、InGame シーンは、Application シーン(前提コンテクスト)を必要としているため、再生ボタンを押してもこのシーンは動作しません。

動作確認するためには、一度起動用の Application シーン を開き、そこから再生するといった手順が必要となります。

Doinject を使うことで、このようなマルチシーン構成のプロジェクトにおいて、 どのシーンを編集していても、そこから直接動作確認ができるように構成することができます。

Doinject は Unity 向け非同期DIフレームワークです。 Doinject には、Unity を使った開発を効率よく行うための便利な機能が備わっています。

ちなみに、Doinject の開発を行う最初のきっかけは、この問題を解決するためでした。

Application Scene の実装

Application シーン には、ゲーム全体にわたって必要となるモデルやサービスが配置されています。 最初にゲームのシーン遷移処理を行うコードのインターフェースだけ、記述してみましょう。

public class Application : MonoBehaviour
{
    public void GotoTitle()
    {
    }
}

シーンの読み込みが行われたらすぐにTitle シーン に遷移したいので、Start メソッドで Title シーン を読み込むようにします。

    // いつもは Start メソッドで初期化処理を行っているが…
    private void Start()
    {
        GotoTitle();
    }

こういった初期化処理はいつもなら StartAwake に書いていると思いますが、 今回は、Doinject の機能を使って、どのシーンを開いていても再生できるようにする必要があります。

どのシーンを開いていても再生できるようにするためには、再生を開始したときに、Doinject に初期化順序を委ねる必要があります。 そのため、Unity が提供する初期化コールバックを使用せず、Doinject が提供する初期化コールバックを使用するようにします。

Application クラスに IInjectableComponent インターフェース継承させ、[OnInjected] 初期化コールバックを使用するようにします。

// IInjectableComponent インターフェースを継承
public class Application : MonoBehaviour, IInjectableComponent
{
    // Start や Awake は使用せず、Doinject の初期化コールバックを使用する
    [OnInjected]
    public void OnInjected()
    {
        GotoTitle();
    }
    ...

[OnInjected] は、Doinject が提供する初期化コールバックで、コンテクストの初期化が完了したときに呼び出されます。 Doinject を使用したプロジェクトの場合は、Start の代わりに置き換えることで、 依存関係を持ったシーンが読み込まれたときにも、Doinject がその依存関係に従って初期化順序を制御してくれるようになります。

今後 DI フレームワークに初期化順を制御してほしいコンポーネントではすべて、IInjectableComponent を継承し、Start() の代わりに [OnInjected] コールバックを使用するようにします。

Title シーンの作成

Title シーンを実際に読み込む処理を書いてみましょう。

まず、Title.unity を作ります。猫がいるだけのシンプルなシーンです。

タイトルシーン

作成したシーンを Build Profiles のシーンリストに追加しましょう。

Title シーンを Scene List に追加

Title シーンを追加で読み込む処理を Application.GotoTitle() に追加します。

    public void GotoTitle()
    {
        SceneManager.LoadScene("Title", LoadSceneMode.Additive);
    }

今回は、マルチシーンの運用を考えているので、 Title シーンを読み込む際に LoadSceneMode.Additive を指定します。 こうすることで Application シーンが起動した直後に Title シーンが共存した状態で読み込まれるようになります。

今回は、Unity のシーンリストを使ったシーンロードを行っていますが、 Doinject には、Addressables を使ったシーンの読み込みをサポートする機能も備わっています。 Addressables 関連の機能については、別の機会に紹介したいと思います。

Application シーンを読み込み直して、Unity Editor の再生ボタンを押すと、無事 Title シーンが表示されました。

アプリケーションとライフサイクルを共にするモデルの作成

プレイヤーの情報を管理する PlayerModel というモデルを考えてみます。 PlayerModel は、アプリケーションの起動時に初期化され、アプリケーションの終了時に破棄されるようなライフサイクルを持つモデルです。

Application シーンはこのライフサイクルに合わせて用意されたものですので、PlayerModel の初期化処理は Application クラスに記述します。

PlayerModel の作成

PlayerModel クラスを作成します。

public class PlayerModel : MonoBehaviour
{
    public string displayName;
}

今回のプロジェクトでは、モデルは MonoBehaviour を継承しています。 このサンプルプロジェクトでは Unity の提供する機能は基本的にドメインの内側に存在していると考えることを設計の起点としています。 アーキテクチャの議論になりがちな部分ですので、コンセプトだけは明確にしておきます。

PlayerModel は、アプリケーションのライフサイクルに於いて、Singleton なモデルとします。 ですので、Application シーンにこのモデルを配置してみましょう。 DisplayName は適当に SomePlayer とでもしておきます。

img.png

PlayerModel を DI コンテナに登録する

作成した PlayerModel をアプリケーション全体で利用できるように、DI コンテナに登録します。 便宜的に、BindingInstallerComponent というヘルパーコンポーネントを利用します。

SceneContext BindingInstallerComponent コンポーネントを Application シーンに配置します。 次に、BindingInstallerComponent の ComponentBindings フィールドに、先程作成した PlayerModel を登錫します。

img.png

これで、Application シーンが DIフレームワークのコンテクスト空間として定義され、空間内に PlayerModel のインスタンスが登録され、いつでもインスタンスを取得できるようになりました。

Title シーンで PlayerModel を利用する

Title シーンPlayerModel を利用してみましょう。 適当に Text フィールドを配置し、PlayerModeldisplayName フィールドを表示するようにします。

  • SerializeFieldText コンポーネントを取得し文字列を設定できるようにします。
  • [Inject] アトリビュートを指定したメソッドを用意し、PlayerModel を注入してもらうようにします。
  • [OnInjected] コールバックで取得したプレイヤー名を Text コンポーネントに設定し画面上に反映させます。
using Doinject;
using UnityEngine;
using UnityEngine.UI;

public class Title : MonoBehaviour, IInjectableComponent
{
    [SerializeField] private Text playerNameText;

    private PlayerModel PlayerModel { get; set; }

    [Inject]
    public void Construct(PlayerModel playerModel)
    {
        PlayerModel = playerModel;
    }

    [OnInjected]
    public void OnInjected()
    {
        playerNameText.text = PlayerModel.displayName;
    }
}

Title シーンCanvas を配置し適当に Text コンポーネントTitle コンポーネント を配置して、フィールドを埋めます。

img.png

Title シーンを Application シーンの子コンテクストとして動かす

Title シーンApplication シーンの子コンテクストとして動かすために、Title シーンSceneContext コンポーネントを配置します。

img.png

次に、Application 側のシーンロード処理を修正し、Title シーンApplication シーンの子コンテクストとして読み込むようにします。

Doinject では、シーンを SceneReference もしくは、 UnifiedScene という型で扱います。 タイトルシーンを SceneReference としてインスペクタから設定できるようにしましょう。

public class Application : MonoBehaviour, IInjectableComponent
{
    public SceneReference titleSceneReference;
    ...
}

Title シーンSceneReference としてインスペクタから設定します。

img.png

Application クラスを変更して、Doinject が提供するシーンローダーを使ってシーンをロードするようにします。 シーンローダーは DIコンテナに自動的に登録されているため、依存注入によりそのインスタンスを取得することができます。

public class Application : MonoBehaviour, IInjectableComponent
{
    private SceneContextLoader SceneContextLoader { get; set; }

    [Inject]
    public void Construct(SceneContextLoader sceneContextLoader)
    {
        SceneContextLoader = sceneContextLoader;
    }

    [OnInjected]
    public async ValueTask OnInjected()
    {
        await GotoTitle();
    }

    private async ValueTask GotoTitle()
    {
        await SceneContextLoader.LoadAsync(titleSceneReference, active: true);
    }    
    ...
}

SceneContextLoader は、非同期インターフェースを持つため、GotoTitle メソッドも非同期メソッドとしてシグネチャを修正します。 同様に、OnInjected メソッドも非同期メソッドとして修正します。[OnInjected] アトリビュートは非同期メソッドにも適用できます。

Application シーンを再生すると、Title シーン側の依存が自動的に解決されて PlayerModel の名前が表示されることが確認できます。

img.png

Application / Title シーンとマルチシーンで動作するプロジェクトの雛形が完成しました。 次に、いよいよ、Title シーンを編集中に直接 Unity Editor を再生できるようにしていきます。

Titleシーンから直接 Unity Editor を再生する

まずは、Title シーンを開いて Unity Editor を再生してみましょう。 そうすると、あたりまえではありますが、Application シーンが読み込まれていないため、PlayerModel が見つからず、エラーが発生します。

img.png

Title コンポーネントに PlayerModel を依存注入しようとして失敗したという内容のエラーが表示されました。 Title シーンに対する Application シーンのような前提として必要になるシーンを、前提コンテクストと言います。

マルチシーンのプロジェクトにおいて、前提コンテクストが読み込まれていない状態で再生を行うと、プログラムの実行に必要なインスタンスが準備できておらず、正しくそのシーンを動かすことはできません。 Doinject では、この前提コンテクストを自動的に読み込む機能が備わっています。

ParentSceneContextRequirement コンポーネント

ParentSceneContextRequirement コンポーネントは、前提コンテクストを定義するためのコンポーネントです。

img.png

Unity のシーンを指定する方法は複数あり、すべての方法に対応するために、ParentSceneContextRequirement コンポーネントは、以下の3つのフィールドを持っています。

  • Addressable Assets に登録されたシーンへの参照
  • Build Profile に登録されたシーン
  • Addressable Assets に登録されたシーンの参照を表現する ScriptableObject

のいずれかを指定することでシーンを設定することができるようになっています。 今回は、Build Profile に Application シーンが登録されているので、2つ目のフィールドに Application シーンを指定します。

img.png

このような表示になります。

「Title シーンを再生するためには、前提コンテクストとして、Application シーンを必要とする」 ということを明示的に示すことができました。

Title シーンを再生すると…問題が発生

早速 Title シーンを再生してみましょう。 App シーンが自動的に読み込まれて、無事 SomePlayer の文字が Title 側で表示できることが確認できました。 しかし、なにかがおかしいです。

ヒエラルキを見てみると、なんと Title シーンが2つに増殖しています。

img_1.png

Application には、初期化時に Title シーンを読み込むコードが記述されています。 Application シーンがロードされた結果、その処理が走ってしまったのです。

この問題に対応するためには、Application シーン側に、専用の記述を必要とします。

Application コンテクストが ReverseLoading されたかどうかを判定して処理を切り替える

ParentSceneContextRequirement による前提コンテクストのロード処理を、リバースコンテクストローディング(Reverse Context Loading) と呼びます。 リバースコンテクストローディングは、シーンが読み込まれたときに、そのシーンの前提コンテクストを自動的に読み込む機能です。

前提コンテクストとして要求されたシーンが読み込まれたときは、子となるコンテクストが存在していることが保証されているので、 Title シーンを初期化時にロードする必要はありません。

そこで、Application.cs を以下のように修正して、リバースロードされたかどうかを判定し、リバースロードされた場合は、Title シーンをロードしないようにします。

public class Application : MonoBehaviour, IInjectableComponent
{
    ...
    private IContext Context { get; set; }

    [Inject]
    public void Construct(
        IContext context,
        SceneContextLoader sceneContextLoader)
    {
        Context = context;
        ...
    }

    [OnInjected]
    public async ValueTask OnInjected()
    {
        // ParentSceneContextRequirement によって初期化された場合は、初期シーンロードを行わない
        if (Context.IsReverseLoaded)
            return;

        await GotoTitle();
    }

    ...

属するコンテクストがリバースロードされたかどうかを判定するために、IContext.IsReverseLoaded インターフェースを使用します。 現在のコンテクスト空間を表現するインスタンスとして、IContext が自動的に DI Container に登録されているので、それを依存注入して使用します。

リバースロードされたコンテクストの初期化処理時に処理を分ける必要があるのは、基本的に、ここで紹介したケースのみです。 コンテクストの初期化時に別の子コンテクストをロードするような処理を行う場合には、このような処理を記述することを忘れないようにしましょう。

完成

ここまでの実装を終えると、Application シーンを開いている状態でも、Title シーンを開いている状態でも、Unity Editor を再生することで、 正しくアプリケーションを動かすことができるようになりました。

ParentSceneContextRequirement は親子関係の階層が紹介した例よりも深いケースであっても数珠つなぎに依存関係を自動解決してくれます。

例えば、Application シーンの前提コンテクストとして Boot シーンが必要なケースでは、Application シーンParentSceneContextRequirement を配置するだけで、 Title シーンを再生すると、Boot -> Application -> Title の順に Doinject が自動的にコンテクストをロードして初期化してくれます。

マルチシーン構成のプロジェクトに Doinject を導入して、どのシーンを編集していても、そのシーンから直接 Unity Editor を再生できるようにする方法を紹介しました。

開発効率が一気に向上するので、ぜひ気になった方は試してみてください。

コードの全体像

Application.cs

using System.Threading.Tasks;
using Doinject;
using Mew.Core.Assets;
using UnityEngine;

/// <summary>
/// Application calls initializes and controls states of the application.
/// </summary>
public class Application : MonoBehaviour, IInjectableComponent
{
    public SceneReference titleSceneReference;

    private IContext Context { get; set; }
    private SceneContextLoader SceneContextLoader { get; set; }

    [Inject]
    public void Construct(
        IContext context,
        SceneContextLoader sceneContextLoader)
    {
        Context = context;
        SceneContextLoader = sceneContextLoader;
    }

    [OnInjected]
    public async ValueTask OnInjected()
    {
        // ParentSceneContextRequirement によって初期化された場合は、初期シーンロードを行わない
        if (Context.IsReverseLoaded)
            return;

        await GotoTitle();
    }

    private async ValueTask GotoTitle()
    {
        await SceneContextLoader.LoadAsync(titleSceneReference, active: true);
    }
}

Title.cs

using Doinject;
using UnityEngine;
using UnityEngine.UI;

public class Title : MonoBehaviour, IInjectableComponent
{
    [SerializeField] private Text playerNameText;

    private PlayerModel PlayerModel { get; set; }

    [Inject]
    public void Construct(PlayerModel playerModel)
    {
        PlayerModel = playerModel;
    }

    [OnInjected]
    public void OnInjected()
    {
        playerNameText.text = PlayerModel.displayName;
    }
}

PlayerModel.cs

public class PlayerModel : MonoBehaviour
{
    public string displayName;
}

Share

About me

mewlist

Latest Posts

マルチシーン構成のプロジェクトに Doinject を導入して、いかなるシーンの作業中でもUnityEditor を直接再生できるようにする
マルチシーン構成のプロジェクトに Doinject を導入して、いかなるシーンの作業中でもUnityEditor を直接再生できるようにする

マルチシーン構成の Unity プロジェクトを開発しているとして、 以下のような構成を想定してみましょう。

非同期世代のUnity向けDIフレームワーク「Doinject」
非同期世代のUnity向けDIフレームワーク「Doinject」

Doinject は Unity 向け非同期DIフレームワークです (Github Repository)。
非同期DIが「実用上、何を解決してくれるのか?」について、わかりにくい点も多いため、実例を交えながら解説したいとおもいます。