📌

Zenject を使ったプロジェクト運用

2022/10/18に公開約7,700字

Qiitaより転載
2019/12/14 初投稿

【unityプロ技】 Advent Calendar 2019 | 15日目


最近仕事のプロジェクトで Zenject を使うようになって、個人的に実運用をしていくのに有用そうなワークフローを確立しつつあるので、それを紹介します。基本的に UI をベースにしたゲームを作っているので、そのような書き方が多くなると思いますが、大体は普遍的にゲームに適用できるかと思います。ちなみに DOTS は範囲外とします(DI じゃなくて ECS を使おう!)。

Zenject の前に

Zenject に関する説明はネット上にころころあるので既に知っている方が多いかと思うのですが、必要最低限な前提を記述しておきます。

Zenject は、いわゆる DI(Dependency Injection)フレームワークと呼ばれるもので、DI を行うためのツールになっています。

DI は、オブジェクト指向で言われるSOLIDの「依存性逆転の原則」を実現するための手法です。例えば以下のような、モデルとビューの依存関係があるとします。(簡素化しています)

モデルがビューを所有し、モデルに変更があった場合ビューを直接呼び出しています。これはビューに変更に非常に弱い形になります。SetHp の引数が変われば破綻しますし、View クラスが削除されても破綻します。これを解消するために、依存性を逆転させて変更への弱さを解消します。

View クラスへの矢印が逆転し、モデルは間接的に View クラスを参照するようになりました。これにより、ビューへの変更は IView の実装を脅かさなければモデルに影響を与えませんし、モデルもビューの変更を気にすることなく実装を変更することができます。

これが大まかに DI を行うメリットとその手法なのですが、実際には「誰が View を生成し、Model に渡すのか?」という部分が欠落しています。それを達成するために、たとえば MVP や MVC、MVVM など様々な手法が開発されています。それを手助けするのが DI の仕組みです。

なぜ Unity で DI したいのか

Unity で開発を行う場合、一番重要なパーツはシーンに配置されるゲームオブジェクトです。ゲームオブジェクトがなければ何も画面上に表示されません(DOTS を除く)。ですので、各スクリプトはゲームオブジェクトが存在する前提で動作しますし、また必要になるパーツも大体はゲームオブジェクトが付随しています。

しかし、ゲームオブジェクト自体が管理できないもの(するのが不便なもの)もあります。例えば主人公の体力などはゲームオブジェクトで保持してもいいかもしれませんが、ステージの数や出てくる敵、装備画面の説明文など、ゲームオブジェクトに付随させると大変になるデータも存在します。そのようなデータを MonoBehaviour に所持させ、DontDestroyOnLoad などを使って使い回したり、static な変数でどこかに保持したりしていると、不便な場面がやってきます。

例えば、アクションゲームを作っていて、主人公キャラを作っているとしましょう。Input を使って移動させたり、弾を発射させたり。いざ実装が終わって実行してみると、なぜか NullReferenceException だけがコンソールに表示され何も動きません。なぜか? static で保持しているプレイヤーの初期 HP が初期化されていませんでした。仕方ないので、Start メソッド内に #if UNITY_EDITOR で括った範囲を作り、そこに static 変数を初期化するメソッドを書きます。また実行してみますが、今度は Hp が -1 になっていて、すぐにプレイヤーが死んでしまいました。どうやら、DontDestroyOnLoad な他のスクリプトで初期化が走っていて、-1 に設定してしまっているようです。更に #if UNITY_EDITOR のブロックの中に StaticClass.IsInit = true; と記述し、再度初期化が行われないようにしたのです。さて、これ動いたプログラムが実際のアプリケーションでちゃんと動作すると自信を持って言えるでしょうか? 私はこのような状況に陥ったとき、デプロイされたコードに対して非常に不安になります。

このような状況をなるべく緩和してくれるのが、Zenject の仕組みになるわけです。

Let's Zenject

Zenject 自体の使い方については、他にいろいろ具体的で詳細な使用方法が記載されている記事にお任せして、こちらでは主に設計と、それを考えていく道筋を紹介したいと思います。

ただ、何も知らない状況の方が見ている可能性もありますので、ひとまず読み進められるよう簡単に使い方を説明しておきます。

Zenject では、以下のような流れでインジェクションを行っていきます。

  1. シーンに、SceneContext というコンポーネントのついたゲームオブジェクトを設置します(これはコンテキストメニューから行います)
  2. SceneContext に対して、MonoInstaller というクラスを継承したゲームオブジェクトを指定します。
  3. 指定された MonoInstaller は、DiContainer というクラスのインスタンスにバインド情報を登録します。
  4. シーンがロードされる際、シーン内に存在するスクリプトの [Inject] 属性がついているメンバ全てにインジェクションを行います。

ざっくりとこんな感じの流れになります。

また、Zenject を使う際、SceneContext について以下の前提条件を知っておく必要があります。

  1. シーンごとに1つ設置できる。
  2. 親子関係が作れる
  3. 子のシーン(Additive なシーン)にのみインジェクトすることができる

SceneContext のこの仕様を利用して、インジェクションを行っていきます。

実践

以下のようなコードを、Zenject を使って開発しやすいコードにしていきましょう。

public class WeaponList : MonoBehaviour
{
    [SerializeField] private ListDisplay list;

    private async void Start()
    {
        IEnumerable<Weapons> weapons = await ApiManager.GetWeaponList();
        foreach(var weapon in weapons)
        {
            list.AddItem(new ListDisplayItem { Text = weapon.Name });
        }
    }
}

このコードには、ListDisplay というリストを UI に表示するクラスと、武器データのモデルである Weapons クラス、API を管理し呼び出しを行う ApiManager リストがあります。これらを使用し、武器の一覧を表示する UI だとします。(API を使用するで、きっと所持アイテムリストとかでしょう)

このコードの問題点は、ApiManager.GetWeaponList が確実に動作していることが前提になっているという点です。これは個人開発であれば、特に問題にはならないかもしれませんが、チーム開発の場合問題になる可能性があります。

このコードは、以下のような条件でのみ成立するコードになっています。

  1. API の開発が終了している
  2. API を取得するコードの開発が終了している
  3. API の仕様が変わっておらず、Name を取得できることが確定している

2 と 3 に関しては、ギリギリ自分でなんとかできます。API は自分で実装すればいいですし、仕様が確定せずとも、仮のデータを Name に入れるような実装にしてしまえば一応動きます。ただ 1 はどうしようもありません。サーバが C# で実装されているならともかく、知らない言語で書かれている場合はかなり大変ですし、また実際に開発を行っているメンバーと軋轢を招く可能性もあります。このような状況を避けるために、ApiManager は自分でコントロールできる範囲内に収めるほうが利口だと思われます。

まず、ApiManager はインターフェイス化してしまいましょう。

public interface IWeaponApi
{
    UniTask<IEnumerable<Weapons>> GetWeaponList(); 
}

public class WeaponList : MonoBehaviour
{
    [SerializeField] private ListDisplay list;

    private IWeaponApi weaponApi;

    private async void Start()
    {
        IEnumerable<Weapons> weapons = await weaponApi.GetWeaponList();
        foreach(var weapon in weapons)
        {
            list.AddItem(new ListDisplayItem { Text = weapon.Name });
        }
    }
}

この状態だと、もちろん Start でぬるぽになってしまうので、Zenject を使いインジェクションを行います。

IWeaponApi には、[Inject] 属性をつけてしまいましょう。

    [Inject] private IWeaponApi weaponApi;

次に、MonoInstaller を実装します。(ついでに ApiManager もインスタンス化するように変更しました)


public class ApiInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.Bind<ApiManager>().To<IWeaponApi>().AsSingle();
    }
}

これで、WeaponList クラスは ApiManager を参照するのではなく IWeaponApi を参照するようになりました。ここまで来れば、以下のようなクラスを作成し

public class DebugApiInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.Bind<DebugWeaponApi>().To<IWeaponApi>().AsSingle();
    }

    private class DebugWeaponApi : IWeaponApi
    {
        public async UniTask<IEnumerable<Weapons>> GetWeaponList()
        {
            return new [] 
            {
                new Weapon { Name = "マスターソード" }
            }  
        }
    }
}

ApiInstaller の代わりに SceneContext に設定してあげればデバッグ用のデータになりました。

運用ポイント① シーンツリーは簡単に開けるようにしよう

さて、ここまでの Zenject を使用したデータの切り替えは、多分 Zenject の入門的な記事を読めばどこにでも書いてあるのですが、これを実運用しようとすると結構面倒くさかったりします。

このコンテキストの切り替え、どう行うのがいいのでしょうか? 毎回設定を変えるごとにシーンを読み直しますか? 依存が多くなった場合、どうしましょう? シーンを正しい順番にロードするために手動で毎回フォルダビューからシーンをロードしているとめんどくさくなってきますよね。

現在私の携わっているプロジェクトには、シーンをロードするための拡張を行いました。コード自体は公開できませんが、イメージ図でご紹介いたします。

まず、ツールバーのような EditorWindow を作りましょう。そこには、以下のようなコードを書きます(C# 風ニセコードです)

interface IExtension
    string Name { get }
    Action Action { get }

IEnumerable<IExtension> extensions = Assembly.LoadAllClassWithInterface<IExtension>();

foreach(var extension in extensions)
    if (GUI.Button(extension.Name))
        extension.Action()

class DebugWeaponSceneExtension : IExtension
    public string Name => "DebugWeaponScene"
    public Action Action => () =>
        SceneManager.NewScene()
        var gameObject = new GameObject()
        var sceneContext = gameObject.AddComponent<SceneContext>()       
        var installer = gameObject.AddComponent<DebugApiInstaller>()
        sceneContext.AddInstaller(installer)
        
        SceneManager.LoadScene("WeaponScene", Additive)

class ProductWeaponSceneExtension : IExtension
    public string Name => "DebugWeaponScene"
    public Action Action => () =>
        SceneManager.NewScene()
        var gameObject = new GameObject()
        var sceneContext = gameObject.AddComponent<SceneContext>()       
        var installer = gameObject.AddComponent<ApiInstaller>()
        sceneContext.AddInstaller(installer)
        
        SceneManager.LoadScene("WeaponScene", Additive)

こんな感じで、エディタのツールバーにあるボタンを押すと、必要な組み合わせのシーンができあがるようなものを作りました。

運用ポイント② 「マネジャークラス」は作ってもいい

最初は、何でもかんでもインターフェイスを噛ませていましたが、ある程度動作が安定している場合や(マスターのデータなど)、そもそも外部に影響されないようなものは、マネジャークラスをインジェクトしてもいいと思います。

例えば、シーン遷移を司るクラスは便利です。以下のようなシーンを作ります。

Scene
  -> DebugApiInstallerScene
  -> SceneManagerScene
  -> WeaponListScene

WeaponListScene は 上2つの SceneContext に依存します。あくまでインジェクションを使っているので、SceneManager は必要なくなったら破棄できます。

他にも背景を切り替える BackgroundManager なんかもよく作ってます。

最後に

DI や Zenject の説明にほとんど使い、運用の部分が結構短くなってしまいましたが、実は「シーンの組み合わせを簡単に開けるようにしよう」と「マネジャーはシングルトンじゃなきゃ作ってもいいんだよ」だけで済む話だったりします。個人的にこの方法を使ってからだいぶ安心してコードをデプロイできるようになりました。もちろん実装漏れは出たりするんですが、実装した部分に関しては自信をもてます。みんなで安定したコードを書きましょう!

参考

依存性逆転の原則 - Wikipedia

Discussion

ログインするとコメントできます