🌿

Extenject(Zenject)で同じコンポーネントをアタッチした複数のGameObjectを管理する話

2022/10/24に公開約7,400字

なんの記事?

UnityにおけるDIの導入記事です。
同じコンポーネントをアタッチした複数のGameObjectを管理する方法を紹介します。
DIを導入してよかったこと、
UnityとDIを組み合わせて使うための工夫をまとめています。

※Extenject(ZenJect)を使用しています。

想定している読者

・DIってちょっと気になってるけど何がいいの? という方 (主に前半)
・同じコンポーネントを持った複数のGameObjectをDIパターンで管理したい、という方 (主に後半)

DIって結局何が良いの?

DIで解消したかったこと

プロジェクトが大きくなるにつれてクラスの置き場所に悩む時間が増えました。
参照の取り方が複雑になり、手軽に機能を実装できなくなってしまいました。
開発のフットワークを軽くするために抽象依存を達成したくなりました。
そのためにDIを利用しています。こう書くとシンプルですね。

プロジェクトの状況

ビフォーアフターを整理するために
現行プロジェクトの中を覗いてみましょう。
Singletonクラスを頂点とした参照が樹形図のように細分化されています。

▼イメージ

ここに考えなしに新しいクラスを作ると、
さらに細分化が進んでしまいます。
また、他のクラスでデータが必要になったときに参照を取りづらくなります。

いつかどこかで枝の先と先が依存しあうと
後の禍根は計り知れません。。。

これらの問題を予防できるクラスの正しい置き場所がわからない、、、
この苦悩が悩む時間を増やしていたのです…!

突如現れた希望の光 抽象依存

検索に検索を重ね、ついに希望の光を見出しました。
抽象依存です!

※指針となったコーディングに関する講演を置いておきます。自動翻訳でも十分わかると思います。

https://www.youtube.com/watch?v=eIf3-aDTOOA

私はこれ幸いと設計のイメージを膨らませました!
これで勝つる!!

なんでできないの?

さあイメージはできた!いざ参る抽象依存!
意気揚々とPCに向きました。しかし、まだ無理でした。。。
参照の取り方そのものに問題があったのです。

現状はシングルトンクラスを頂点として、
Inspecter上のアタッチで参照を作ってありました。

そもそもUnityはInterfaceのアタッチ機能が弱いですし、
なんとかできたとしても、
一つのクラスに実装したInterfaceを、大量にアタッチしなければなりません。

これは無理だ。。。

やっとDIの話

そこでDIです!
単機能に分割したInterfaceを、
適切な場所に注入してくれます!
これで勝つる!!

同じコンポーネントをアタッチした複数のGameObjectを管理する話

ケースのイメージ

ここから少し事例を変えてイメージしてみます。
この画像のように、
複数の背景を置いてそれぞれを切り替えるゲームを考えてみましょう。

BackGroundコントローラーなど、上位のクラスを作って各オブジェクトをアタッチし、
表示非表示を管理すればいけそうです。

しかし各BackGroundの実体を取得すると、元の具象クラス依存に戻ってしまいます。
かと言ってInspecterですべてのInterfaceをアタッチして使い分けるのは面倒です。

方針決定!!

今回は ヒエラルキーからInterfaceを自動取得して索引できるようにしてみます。
同じInterfaceを集めたディクショナリーをDIで自動的に作ってもらうようなイメージです。

階層関係は持ちません。
機能(interface)ごとに別々のディクショナリーを引き、
起動元から直接Interfaceを参照します。

ツール比較

現在UnityでDIをやる場合、
Extenject(Zenjectの後継)というツールと、
VContainerというツールがあるようです。
両方試したところ、やりたいことを実現できるツールはExtenjectでした。

具体的にやったこと

まずはプロジェクトを作り直しながら、
各機能をInterfaceに分離していきました。

その後、 Yano先生の記事を参考にさせていただいて
Extenjectを導入しました。
(記事はZenject の記事となっていますが、Extenjectでも同手順で導入できました)

Yano先生、いつも良質な記事ありがとうございます!

DIの導入記事自体はたくさん良質な記事がありますので、
ここでは省略します。

導入は済んだものとして、
ヒエラルキー上に大量に配置されたGameObjectたちをどう管理するか考えたいと思います。

Interfaceを自動取得して索引できるようにする

まず取得したい具象クラスにIBindableというInterfaceを実装します。

public interface IBindable
{
    GameObject gameObject { get; }
}
    public Class SpriteView : Monobehaviour , IChangeImage , IBindable
    {
        [SerializeField]
        private string _name = "SpriteView"; //インスペクター上で変更予定 後述
        public string Name => _name;
    
        public void ChangeImage(Sprite sprite)
        {
            // 画像を変更する処理
        }
    }  

続いてInstallerに一番大きい親となるGameObjectをアタッチします。
ここでいう一番大きい親とは、
取得したいオブジェクトすべてを、子や孫、または孫の孫の… という関係の中に含んだGameObjectです。

そして以下のような形で親オブジェクトの子、孫を検索してIBindableのリストを取得、Bind処理していきます。

public class CallingInstaller : MonoInstaller
{
    [SerializeField]
    private GameObject _grandParent;
    
    public override void InstallBindings()
    {
        foreach( var bind in _grandParent.GetComponentsInChildren<IBindable>(includeInactive:true) )
        {
            Type bindType = bind.GetType(); // IBindableを実装している具象クラスの型を取得
        
            Container
                .BindInterfacesAndSelfTo(bindType) //具象クラスをそれ自身とInterfaceでバインド
                .FromComponentOn(bind.gameObject) //具象クラスがアタッチされているGameObjectから取得する設定
                .AsCached();

        }
        
        // ~省略~
        //ここにInterface利用時の索引用オブジェクトのバインド処理を書きます(後述)
    }

索引用のクラスを用意する

次にInterfaceを入れておき、索引するためのクラスを用意します。
ピュアクラスでOKです!

public class ChangeImageDictionary
{
        readonly List<IChangeImage> _changeImages; //Injectを受け取るための変数
        readonly IReadOnlyDictionary<string, IChangeImage> _changeImageDictionary; //保管、索引のためのディクショナリー

        [Inject]
        public ChangeImagePresenter( List<IChangeImage> changeImage ) //複数のInterfaceをInjectで取得する
        {
            this._changeImages = changeImage;
            _changeImageDictionary = (IReadOnlyDictionary<string, IChangeImage>)_changeImages 
                .Distinct() //理屈上重複しないはずだけど一応重複削除
                .ToDictionary(x => x.Name); //NameをキーにしてDictionaryに変える
        }

        //索引できるようにGetterを書いておく
        public IChangeImage Get(string name) => name == null ? null : _changeImageDictionary[name];
    }  

索引用クラスをバインドする

インストーラーに索引クラスのバインド処理を書き足しましょう。

    public override void InstallBindings()
    {

        //省略

        Container
        .Bind<ChangeImageDictionary>()
        .To<ChangeImageDictionary>()
        .FromNew() // new() によりインスタンスを生成する
        .AsSingle(); //索引用クラスはシングルトンにする
    }

Nameを登録する

最後にキーとなるNameの設定について解説します。
NameはInterfaceからアクセスする必要があるため、
Interface実装時にプロパティとして指定しておいてください。

    public interface IChangeImage
    {
        string Name{get;} //後述
        public void ChangeImage(Sprite sprite);
    }

具象クラスでは、先ほど見た通り、以下のようなプロパティを実装しておきます。

        [SerializeField]
        private string _name = "SpriteView"; //インスペクター上で変更するための値
        public string Name => _name;
    

索引用の名前をInspecterで登録しましょう。
こんな感じで書いておきます。

これで、Inspecterで設定したNameをキーにして、
Interfaceの索引クラスが使えるようになりました!

使う

せっかく作ったので使いましょう!

以下のように索引用クラスをインジェクトし、そこから引っ張ったInterfaceを使います。
具体的な実装は具象クラスに任せることができるため、
起動側からは受け手側の具体的な実装を意識する必要がありません。

    public class Commander : MonoBehaviour
    {

        private ChangeImageDictionary _changeImageDictionary;

        [Inject] //インジェクション
        public ResolveDependencies( ChangeImageDictionary changeImageDictionary )
        {
            _changeImageDictionary = changeImageDictionary;
        }

        //ひとまずDefaultBackを変更するだけの処理
        public void ChangeDefaultImage()
        {
            Sprite image = //image の収得処理
            var changeImage = _changeImageDictionary.Get("DefaultBack");
            changeImage?.ChangeImage(image);
        }
    }

例えば指示先がSpriteRenderereを対象としていても、
Imageを対象とししていても、MeshRendererを対象としていても
利用側は同じ書き方をすることが可能です!

具象クラスを別々に用意しておき、
それぞれにIChangeImageを実装しておくだけで一律に画像の変更を扱うことができます。

具象クラス側では、自分の使用シーンに限定された処理だけを書けば良いので、
スッキリとコードを保つことができます。

Lookup型を利用する

※ 22/11/05更新 ※
複数のインスタンスを取得する場合は、Lookup型を使うと便利です!
別記事にまとめていますので、よろしければご覧ください。

具体的なコードの書き換え例も掲載しています。

まとめ

これでこの記事はおしまいです。
お読みいただいてありがとうございました。

無事、当初の目的を達成することができました。

とは言え全く階層を持たない実装は別の歪みを生みそうな気もするので、
バランスを探りながら運用してみようと思います。

DIパターンは画期的なパターンだと思いますが、
良くも悪くもチームの全員に特定の実装方法を強制するものになります。

また、Singleton×Unityの組み合わせは視覚的にも非常にわかりやすく、
プロジェクトの性質によってはまだまだ現役だと思います。

ご自身のプロジェクトにDIパターンが合いそうか、
考える一助になれましたら幸いです。

※同じくDIパターンのVContainerも検討しましたが、
 Monobehaviourをバインドする際に
 特定LifeTime内で単一のオブジェクトとしてしかバインドできませんでした。
 今回のような複数、同種のオブジェクトを扱うには不向きと考えて、Extenjectを導入しています。
 どなたか良い方法をご存知の方がいれば、教えて頂けますとありがたいです。

謝辞

以下サイトさまから素材をお借りしました。

ヒューマンピクトグラム2.0 
https://pictogram2.com/
ICONBOX
https://iconbox.fun/

Discussion

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