すぎしーのXRと3DCG

主にXR, Unity, 3DCG系の記事を投稿していきます。

【Extenject】AsTransient, AsCached, AsSingle を使い分けよう!

f:id:tsgcpp:20201010091122j:plain

概要

今回はZenjectのインスタンス生成の Scope である AsTransient, AsCached, AsSingle の使い分けについて解説します。

この3つは生成されるインスタンスに明確なルール付けをすることができるので、上手に利用してあげてください!

例によって記事の最後にサンプルプロジェクトを置いておきます。

動作環境

  • Unity 2019.4.12f1
  • Extenject 9.2.0
    • 例によって現在保守されているExtenjectの方を使用

関連

以前の記事です。 よかったらどうぞ!
インストール方法やテストについて紹介しています。

tsgcpp.hateblo.jp

前知識

ContractType と ResultType について

Zenjectには ContractType と ResultType という概念があります。

  • ContractType は Bind<>に指定する型
  • ResultType は To<> に指定する型

以下を例にすると IRandomizer がContractType, FixedRandomizerがResultTypeです。

Container
    .Bind<IRandomizer>()
    .To<FixedRandomizer>()

Scopeを考える上でこの ContractType と ResultType は重要なので意識しておいてください!

Zenjectによるインスタンス生成

Containerにバインドすると基本的にZenjectはResultTypeのインスタンスを必要なときに生成してくれます。

public interface IGreeter
{
    string Greeting { get; }
}
public class ResultTypeGreeter : IGreeter
{
    private string _greeting;

    public ResultTypeGreeter(string message, string target)
    {
        _greeting = string.Format("{0} {1}!", message, target);
    }

    public string Greeting => _greeting;
}
Container
    .Bind<IGreeter>()
    .To<ResultTypeGreeter>()
    .AsTransient()
    .WithArguments("Hey", "Guys");

この例の場合、ResultTypeGreeter のコンストラクタの第1引数に"Hey"、第2引数に"Guys"が渡されてインスタンス生成されます。
結果、Zenjectによって生成されたResultTypeGreeterのプロパティ Greeting は "Hey Guys!" を返します。

FromInstanceで直接インスタンスを渡したりすることもできますが、Scopeのメリットが弱くなってしまいます(理由は後述)。

Scopeを使用する場合において、このインスタンス生成してくれる機能は非常に重要な意味を持ちます。

コンストラクタが複数ある場合

[Inject]で使用するコンストラクタをZenjectに明示してください。

コンストラクタが1つの場合は不要です。

ResultTypeGreeter.cs

以下のテストコードで動作確認してます。

ResultTypeGreeterZenjectConstructionTest.cs

ResultType に複数のコンストラクタがある場合はいずれかのコンストラクタ(大抵は一番上に宣言したコンストラクタ)が使用されます。
予期せぬ動作になるかもしれないのでコンストラクタが複数ある場合は[Inject]で明示したほうが良いと思います。

また、この[Inject]ですが複数のコンストラクターに指定した場合はエラーになります。

つまり、Zenjectがインスタンス生成に利用するコンストラクタは1つ ということになります。

WithArguments の引数の数について

基本的に使用するコンストラクタの引数と同じ数を指定する必要があります。

    public ResultTypeGreeter(string message, string target)
    {
        _greeting = string.Format("{0} {1}!", message, target);
    }

以下のようにコンストラクタの引数とWithArgumentsの数が一致していないとエラーになります。

    // エラー
    Container
        .Bind<IGreeter>()
        .To<ResultTypeGreeter>()
        .AsTransient();
        // WithArgumentsなし
    Container.Inject(this)

    // エラー
    Container
        .Bind<IGreeter>()
        .To<ResultTypeGreeter>()
        .AsTransient()
        .WithArguments("Hi");  // 引数1つ指定
    Container.Inject(this)

    // 正常
    Container
        .Bind<IGreeter>()
        .To<ResultTypeGreeter>()
        .AsTransient()
        .WithArguments("Hey", "Guys");  // 2つ指定
    Container.Inject(this)

ただし、デフォルト引数が指定されている場合はエラーにならないようです。

    [Inject]
    public ResultTypeGreeter(string message = "Hello", string target = "Everybody")
    {
        _greeting = string.Format("{0} {1}!", message, target);
    }

ご参考までに。

Scope (AsTransient, AsCached, AsSingle)

いよいよ本題です! ZenjectのScopeについて見ていきましょう。

Scope(スコープ)と聞くとやはりプログラミングにおけるスコープが思い当たりますが、Zenjectのスコープもほぼ同じ意味です。
インスタンスのスコープ、つまり参照される範囲を指定 することになります。

Scopeの違い

AsTransient AsCached AsSingle 備考
Context毎のインスタンス DI先のクラスごと BindしたContractType(WithId指定有りの場合はさらにID毎)に1つ Context毎に1つ Contextごとに管理

それぞれ掘り下げて見ましょう。

AsTransient

AsTransient は DI先のクラス毎に異なるインスタンスとして渡されます。
渡されたインスタンスはすべて別物なので好き勝手に変更しても、他のDI先には影響しないことになります。

AsTransient確認用UnitTest

以下のような用途に使えます。

  • メモリ使用量を気にしないからとりあえずDIで挙動を変更したい
  • DI先ごとに別のインスタンスとして持たせたい(他に影響させたくない)

AsTransient の 利用例

DI先にはICollectionとして使用してもらい、バインドでListLinkedListなどを切り替えるなどの使用方法が考えられます。

    // ListをDIしたい場合
    Container
        .Bind<ICollection<int>>()
        .To<List<int>>()
        .AsTransient();
    Container.Inject(this);
    // LinkedListをDIしたい場合
    Container
        .Bind<ICollection<int>>()
        .To<LinkedList<int>>()
        .AsTransient();
    Container.Inject(this);
    // SortedListをDIしたい場合
    Container
        .Bind<ICollection<int>>()
        .To<SortedList<int>>()
        .AsTransient();
    Container.Inject(this);

DI先のコードを変更することなく性能(LinkedList)や機能(SortedList)を変えることができます。

ICollectionを使用したAsTransientサンプル

AsCached

AsTransient は DI先のクラス毎に同一のインスタンスとして渡されます。
WithIdを指定している場合はIDごとに異なるインスタンスとなります。

AsCached確認用UnitTest

インスタンスの単位は細かくは以下のようになります。

  • ContractType毎に1つ
  • ID指定無しは「ID指定無しのグループ」として1つ生成
  • ID指定有りの場合は、IDごとに1つ生成
    // ID指定無し向けのインスタンス
    Container
        .Bind<IGreeter>()
        .To<ResultTypeGreeter>()
        .AsCached();

    // ID_01向けのインスタンス
    Container
        .Bind<IGreeter>()
        .WithId("ID_01")
        .To<ResultTypeGreeter>()
        .AsCached();

    // ID_02向けのインスタンス
    Container
        .Bind<IGreeter>()
        .WithId("ID_02")
        .To<ResultTypeGreeter>()
        .AsCached();

以下のような用途に使えます。

  • ResultTypeは同じだけど、DI先のグループごとに異なるインスタンスにしたい
    • 後述しますが、AsSingleの場合はResultTypeは1つしか作成されません

AsCachedの使用例

今回はとあるメッセージキューをグループに分けてインジェクションさせてみましょう。

    Container
        .Bind<Queue<string>>()  // Queueは残念ながら専用interfaceなし
        .WithId(Group01)
        // .To<Queue<string>>()  // ContractTypeとResultTypeが同じならToは不要
        .AsCached();

    Container
        .Bind<Queue<string>>()
        .WithId(Group02)
        .AsCached();

インスタンスを受け取る先は引数にId を指定

    public class DITarget01
    {
        private Queue<string> _queue;

        // MonoBehaviourでない場合はIdは引数で指定
        public DITarget01([Inject(Id = Group01)] Queue<string> queue)
        {
            _queue = queue;
        }

        public void Enqueue()
        {
            _queue.Enqueue("Hello");
        }
    }

メッセージキューをグループで別インスタンスとして管理することが可能になります。

Queueを使用したAsCachedサンプル

AsSingle

AsSingleResultTypeのインスタンスが単一であることを保証するScopeです。
Singleという単語がついている通りですね。

どう保証するのかというと、同じResultTypeがバインドされたときに例外(ZenjectException)を出すようになっています。

    Container
        .Bind<IGreeter>()
        .To<ResultTypeGreeter>()
        .AsSingle();

    // IDを指定しようが、AsTransientを指定しようが例外を出す
    Container  
        .Bind<IGreeter>()
        .WithId(Identifier01)
        .To<ResultTypeGreeter>()
        .AsTransient();

以下のような用途に使うと良いと思います。

  • 複数のインスタンスを作成したくないResultTypeのバインド
    • 複数インスタンスが存在するとおかしくなる(通信に関わる部分など)
    • メモリを無駄に消費したくない
  • 複数のインスタンスを作る必要がないもの
    • 引数に対して必ず同じ結果を返す(副作用のない)メソッド(関数)を持つResultType
    • Strategyパターン、Factoryパターンのクラスなど
  • Singletonパターンからの移行(理由は後述)

ちなみにですが、FromInstanceを使用するとAsSingleの恩恵がなくなってしまうので、
インスタンスの単一性を保証したいのであればTo<>を使用して、Zenjectにインスタンスを管理させましょう。

個人的には疎結合化がZenjectの主な使用目的のため、AsSingleを一番使用しています。

AsSingleの使用例

いろいろな使用例があるのですが、今回はシンプルに疎結合化の実現で使ってみましょう。

前回の記事IRandomizerのようにインスタンスは全体で1つのみでいいようなResultTypeに使用すると良いと思います。

余談:Singletonパターンからの移行

AsSingleを見て「Singletonパターンで良くない?」って思った方もいるかもしれません。
DIにすることで様々な恩恵があると考えていますので取り上げたいと思います。

Singletonパターンのデメリット

  • 参照先のクラスと密結合になりテスタビリティが損なわれる
    • 参照先の挙動をUnitTest時に変更できない
    • 例: StaticClass.Instance.Method()の場合はStaticClassと密結合
  • Unityで同一シーンの読み込み毎に前回の状態に引きずられてしまう
    • 1回目と2回目以降で動作が変わるリスクが生まれる
    • 実装が簡単だが、シーンの切り替え毎にリセット処理など注意する点が多い
  • 生成したインスタンスがいつまでも残り続ける(static変数として使用する場合)
    • staticオブジェクトが解放されない
    • ヒープ領域の整理の際に障害になり得る(極稀な話だと思いますが)
  • インスタンス生成のタイミングが不定(個人的に嫌いな性質)
    • 大抵は初回アクセス時にインスタンス生成
      • if (_instance == null) _instance = new StaticClass();

色々と書きましたが「実装は簡単だけど、注意がかなり必要」なデザインパターンだと考えています。

特にUnityやiOS, Androidのネイティブアプリのように "ライフサイクル" を持つアプリケーション開発においては特に注意が必要です。

Zenject + AsSingleでSingletonの問題を解決

さて、このSingletonパターンですが大体はDIによる移行が可能だと考えています

Singletonパターンを使う理由の大体は「同一のインスタンスにアクセスしたい」だと思います。

Zenjectを使っているならResultTypeをAsSingleでバインドしてDIすることで、上記デメリットを解消した上で同一インスタンスにアクセスするという要望を満たせると思います。

どうしてもSingletonでないといけないパターンがあるかもしれませんが、
Singletonを使用する理由が「簡単だから」であれば、一度DIの使用を検討してみてください。

プロジェクトが大きくなると密結合のSingletonパターンは。。。

AsSingleの注意点

ExtenjectのREADMEにも記載されていますが、AsSingleは同一のContainer(Context)毎にインスタンスが管理されます。

ProjectContext と SceneContext両方に同じResultTypeをバインドしても例外はでないのでご留意ください。

サンプルシーン Assets/Scenes/GreeterDISample.unity に ProjectContext と SceneContext両方でGreeterをAsSingleでバインドしています。
よかったら動かしてみてください。

簡単なまとめ

に使うといい気がします(超大雑把)

サンプルプロジェクト

github.com

雑感

久々の記事投稿です。
ポートフォリオに集中してたんですが、さすがにネタが溜まりすぎて来たので簡単なやつを1つでも解消したいと思い記事にしました。

Zenjectは使ってて本当に気持ちいいですね~。

Zenjectを使ってて思ったメリットとして 「UnityでDIが実現できる」だけじゃなくて「UnityでMonoBehaviour以外のクラスが使いやすくなる」っていうのもあると思います! 疎結合にできるライブラリですが、自分自身はZenjectに密結合になっちゃってますね~。

それでは良いUnityライフを!