【Extenject】AsTransient, AsCached, AsSingle を使い分けよう!
概要
今回はZenjectのインスタンス生成の Scope である AsTransient
, AsCached
, AsSingle
の使い分けについて解説します。
この3つは生成されるインスタンスに明確なルール付けをすることができるので、上手に利用してあげてください!
例によって記事の最後にサンプルプロジェクトを置いておきます。
動作環境
- Unity 2019.4.12f1
- Extenject 9.2.0
- 例によって現在保守されているExtenjectの方を使用
関連
以前の記事です。 よかったらどうぞ!
インストール方法やテストについて紹介しています。
前知識
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つの場合は不要です。
以下のテストコードで動作確認してます。
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先には影響しないことになります。
以下のような用途に使えます。
- メモリ使用量を気にしないからとりあえずDIで挙動を変更したい
- DI先ごとに別のインスタンスとして持たせたい(他に影響させたくない)
AsTransient の 利用例
DI先にはICollection
として使用してもらい、バインドでList
や LinkedList
などを切り替えるなどの使用方法が考えられます。
// 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ごとに異なるインスタンスとなります。
インスタンスの単位は細かくは以下のようになります。
- 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"); } }
メッセージキューをグループで別インスタンスとして管理することが可能になります。
AsSingle
AsSingle
は ResultTypeのインスタンスが単一であることを保証する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でバインドしています。
よかったら動かしてみてください。
簡単なまとめ
AsTransient
は挙動は変えたいけど、インスタンスは共有したくないときAsCached
はIDグループ毎にインスタンスを共有したいときAsSingle
はResultTypeのインスタンスを必ず1つだけにしたいとき
に使うといい気がします(超大雑把)
サンプルプロジェクト
雑感
久々の記事投稿です。
ポートフォリオに集中してたんですが、さすがにネタが溜まりすぎて来たので簡単なやつを1つでも解消したいと思い記事にしました。
Zenjectは使ってて本当に気持ちいいですね~。
Zenjectを使ってて思ったメリットとして 「UnityでDIが実現できる」だけじゃなくて「UnityでMonoBehaviour以外のクラスが使いやすくなる」っていうのもあると思います! 疎結合にできるライブラリですが、自分自身はZenjectに密結合になっちゃってますね~。
それでは良いUnityライフを!