時代に翻弄されるエンジニアのブログ

ゲームプログラマをやっています。仕事やゲームや趣味に関してつらつら書きたいと思います。

Zenject を使ってみて、良かったこと、注意したほうが良いこと

f:id:tkymx83:20210617230658p:plain

こんにちは、たくという名前でブログをやっています

最近仕事でも、プライベートでも Zenject を使うことが多いので、ゲームで使う場合の使用感を書いてみたいと思います。

ちなみに、以下Zenject のURL です。

assetstore.unity.com

要点をまとめると以下のようになります

f:id:tkymx83:20210617223524p:plain

また、今回は Zenject の使い方はわかったがどう使えばよいかを迷っている方向けに自分の知見をまとめたいと思い記事にしました。

前提

ゲームの開発と言っても、色々なスタイルがあると思います。僕の場合はだいたい以下のようになっています。

  • Unity を基本的に使用
  • マスタデータやプレイヤーデータはデータベースなりで別途管理
  • クリーンアーキテクチャのようなレイヤベースの設計を信奉している
  • DDD(ドメイン駆動設計)を信奉しており、クラスの分割は多め
  • Unity では Monobehaviour に極力ロジック部分を記載しないようにしている

基本的にSOLID原則に則って、View 周り を MonoBehaviour で実装して、ロジック部分はピュアなクラスで実装しています。
そのため、Zenjectを ピュアなクラスで実装するときの目線で見ていることが多いです。

Zenject の所感

Zenject は Dependency Injection (以下DI) をきれいに書けるというモチベーションで使い始めました。

普段のコードでは、DDDを採用しています。そのため、クラスの細分化を積極的に行っているためクラス数が多くなりがちです。また、一つクラスで一つの機能を実現するために、必要な要素をコンストラクタで注入することが多いので、コンストラクタの引数が多くなりがちです。

Zenject を実際に使ってみて、コンストラクタ部分やクラスの呼び出し部分をスッキリとすることはできたので、当初の目的をある程度達成できました。ですが、使い方によっては複雑性が増す可能性があると感じました。そんなこんなで、僕がZenjectを使った所感を以下3点にまとめたいと思います。

マスターデータなどアプリ中に一つのみ存在するものはうまく注入することで、可読性が上がった

まずは、コードの Before, After を示したいと思います。

Before

public class EnemyLevelUp
{
public class PlayerLevelUp
{
    public PlayerLevelUp(IPlayerRepository playerRepository, ILevelUpTable levelupTable, ILevelCalculatpor levelCalculatpor)
    {
        ...
    }

    public void LevelUp(Level next)
    { 

After

public class PlayerLevelUp
{
    [Inject] IPlayerRepository playerRepository;
    [Inject] ILevelUpTable levelupTable;
    [Inject] ILevelCalculatpor levelCalculatpor;

    public PlayerLevelUp()
    {
        ...
    }

    public void LevelUp(Level next)
    {
}

コードの内容としては、プレイヤーのレベルアップを行うクラスです。事前にレベルアップの計算クラスを注入しておいて、レベルアップメソッドの呼び出し時に使用するイメージです。このように、僕の書くコードでは、なにか処理を行うときにクラスを作成し、依存するものを注入することが多いです。

Before ではコンストラクタで必要なインスタンスを渡す必要があります。そのため、呼び出し元のクラスでも、それぞれのクラスのインスタンスを渡す必要がありました。クラスの階層が深くなるほど受け渡すだけの処理が増えていきます。

そのため、このクラスのコンストラクタの引数を増やす際に、呼び出し元やインスタンスの受け渡しの処理も変更になり、変更の手間が激増します。

After では、必要なクラスに Inject 属性を付けて、Zenject の仕組みを使用して注入しています。新しいクラスをこのクラスで使用したいときも、クラスのメンバ変数を増やすだけで対応できます。(Bind されていること前提です)

見た目もスッキリして、拡張する際の心理的障壁も少なく、Zenjectのメリットを享受できていると思います。

*Before のままでも、Zenject 経由でコンストラクタの引数にインスタンスが注入されるので、変更の手間はだいぶ減ります。Zenject の Readme ではコンストラクタに注入する方法が推奨されているようです。

ただ僕場合コンストラクタで指定した引数をメンバ変数に移して使用することが多いので、メンバ変数にInject を記載しています。

メソッドの引数を少なくする目的で使用するものではない。用法用量を守らないと複雑化する

先程のメリットの真反対のことを書いています。実は クラスのコンストラクタの引数を少なくしようというモチベーションで Zenject を使用すると逆に分かりづらいコードになってしまう可能性があります。

というのも、Zenject の仕組みで 注入できるのは基本的にアプリに一つだけ存在する要素になります。しかし、ゲームの場合は敵が複数いる可能性があります。そのため、敵を示すクラスを注入した際にどの敵が注入されたのかがわからなくなります(どの敵のインスタンスがBindされているのかわからなくなる)。

ただ対処方法はあります。SubContainer の仕組みを利用して、敵ごとに SubContainer を作成するというものです。ただこの場合SubContainerの作成 や 敵のBindの 処理がメインのInstaller 外に記載されることになります。

Zenject では基本的に 依存性の記載(Bind処理は)を Installer で行います。そうすることで依存性が一覧できるというメリットがあります。つまりSubContainer の作成や Bind の処理が分散すると 依存性の記載も分散することになりZenject のメリットが失われてしまします。
*Factory を作成して、生成部分を明示化するなど工夫をすることである程度の一覧性の担保はできます。

結論、アプリに一つしかない要素の場合はInstaller に依存性を集約させて一覧性を担保できます。そして、メソッドの引数をスッキリさせて可読性やコード変更の心理的障壁を下げることができます。ただ、アプリに複数ある要素を Zenject で注入する場合は処理が分散するため逆に複雑性が増す可能性があります。

MonoBehaiviour 向けの実装が多く、ロジック部分をPure クラスで実装する場合は機能の恩恵が受けられない

Zenject では、SceneContext や ProjectContext, GameObjectContext, ZenjectBinding, ZenAutoInjecter など、GameObject に対して、依存性の注入を自動的に行う仕組みがいろいろあり、とても便利です。

MonoBehaiviour にロジックが記載されているアプリの場合は良いと思います。ですがクリーンアーキテクチャなどの設計思想を採用する場合は、View と ロジックが分離するような設計の場合が多いです。

そのため、ロジック部分は ピュア クラスになります(MonoBehaiviour ではないクラス)。 ピュア クラスに Inject する際は MonoBehaiviour向けの 仕組みは使用できないため、Zenject の便利機能の恩恵が受けられないことになります。

Zenject はピュアクラスに対しても依存性を注入する仕組みはもちろんありますので、それを使用して自分の設計に合うように Container の管理を行う必要があります。

まとめ

まとめると、Zenject はアプリに一つしかない要素の依存性可視化、コードの可読性の向上にはとても便利です。

ただ、単純にメソッドの引数を少なくしたいといったモチベーションで始めると、管理方法によっては複雑性が増す可能性があったり、Zenject で管理する旨味がない場合もあるので注意が必要です。

次は Pure クラスでの Zenject の活用方法や、どの粒度までZenject で 管理 するのが良いのかなどを検討したいと思います。