Zenjectの初期化処理の順番に潜む罠

はじめに

こんにちは。クライアントエンジニアの ichi です。

今回は、私が実装中に躓いた Zenject の初期化処理の順番に潜む罠ついてお話ししたいと思います。
GameObjectContext, SceneContextなどの各コンテキストやKernelに関する話を含むので、Zenject中級者向けの記事になります。

Zenjectによるクラスのライフサイクルの管理

まず、Zenjectで用意されているクラスのライフサイクルを管理するための枠組みについて軽くおさらいします。

MonoBehaviourには Unity のライフサイクルに合わせて実行される Awake, Start, Update, OnDestroyなどのイベント関数がいくつか定義されています。MonoBehaviourではない純粋なクラスはこれらの関数を使用することができないため、このライフサイクルに合わせて何か処理を行いたい場合はMonoBehaviourを継承したクラスを利用する必要があります。

対して、Zenject ではMonoBehaviourではない純粋なクラス上でこれらの関数に相当するものを利用することができる枠組みが用意されています。Startに相当するInitialize関数を提供するIInitializableUpdateに相当するTick関数を提供するITickableなどのインターフェースがいくつか用意されており、これらをクラスで実装しコンテナにBindすることによってUnityのライフサイクルに合わせて処理を行うことができます。これによりMonoBehaviourの仕様に縛られずに自由にクラスを制御することが可能になります。

初期化処理の実行順が逆転する?

Zenjectを用いてコーディングをするときは、非MonoBehaviourクラスのコンストラクタとAwakeIInitializable.InitializeStartを併用してコンテキスト内のクラスの依存関係の解決と初期化を行うことが多いと思われます。コンテキストの初期化時、コンテナに Bind されているクラスのコンストラクタは該当のクラスが Inject されるとき(つまり Awake より前)に呼ばれますが、そのほかのAwakeStartInitialize関数はZenjectの公式ドキュメントに書いてある通り Awake -> Start & Initialize という順番で呼ばれます。そのため、コンテキスト内の初期化処理もこの順番で各関数が呼ばれることを想定して実装されると思われます。

しかし厄介なことに、特定の条件下でコンテキスト内の IInitializable.InitializeAwakeよりも先に呼ばれてしまう場合があるようです。

実際のコードで例をあげましょう。まず、以下の2つのクラスを定義します。

次にGameObjectContextをアタッチした Prefab を用意し、GameObjectContextMono Installersに以下のGameObjectInstallerを追加します。

最後に、SceneContextに以下のInstallerを追加し、RunnerクラスのStartで上記Prefabを生成するようにします。

そうすると、以下のような出力結果が得られます。

Component: I'M INJECTED!!!
Class: I'M CONSTRUCTED!!!
Class: I'M INITIALIZED!!!
Component: I'M AWAKE!!!
Component: I'M STARTED!!!

出力結果からわかる通り、Factoryを使用して動的にGameObjectContextを生成した場合のみIInitializable.InitializeAwakeよりも先に呼ばれてしまいます。この場合、非MonoBehaviourクラスのInitialize内で同コンテキストのMonoBehaviourクラスを触りにいったときに依存関係の解決や初期化処理が完了しておらず、意図しないバグを引き起こす可能性があります。

実行順が逆転する原因

なぜ、このような現象が起きるのでしょうか?

基本的にSceneContextProjectContext、シーン開始時にシーン上に存在しているGameObjectContextにBindされているIInitializableを実装したクラスのInitialize関数は前項で説明した通り、UnityのStartのタイミングに合わせて実行されます。

しかし、実は動的に生成されたGameObjectContextのみコンテナにBindされたコンポーネントのInitialize関数が呼ばれるタイミングが違います。動的に生成された場合のみ、そのコンテキストのInject処理が終わった直後に自身の持つKernelInitializeを明示的に呼び出します。そのため、コンテキスト内の初期化処理はInject -> Initialize -> Awake -> Startという順に呼び出されることになります。

このような挙動になっている理由については、GameObjectContextのコード内のコメントで以下のように説明されています。

ざっくりまとめると、

  • Unityの仕様で動的に生成されたオブジェクトのStartはフレームの最後に呼ばれる
  • そのため、Factory.Create()で動的に生成されたGameObjectContextは生成直後に使用することができない(初期化処理が間に合わない)
  • なので動的に生成されたときのみ Unity のライフサイクルから外れ、即座にIInitializable.Initialize()を呼ぶようにする

とのことらしいです。

避けるためにはどうすればよいか?

基本的に、

  • Awakeの中で依存性の解決のみではなく、何らかの初期化処理を行っている
  • Awakeの中で Zenject で管理されていないクラスの依存性を解決している

以上の2点を行っていなければこの現象でエンバグする可能性は低くなると思われます。なのでZenjectの思想として推奨しているように

コンストラクタやAwakeで依存関係の解決のみ行うようにして、InitializeStartで初期化処理を行う

ようにしましょう。

もし、その上で特定の順番で初期化処理を呼ぶ(今回の例でいうと 非MonoBehaviourが初期化処理時に初期化済みのMonoBehaviourクラスを参照する)必要がある場合は、Startの処理をInitializeに移し替えてBindExecutionOrderで呼び出し順を整理すれば解決できます。

また、Awake内でクラスの生成やGetComponentをしている場合はそれらをFromComponentOnHierarchyなどでコンテキストのコンテナに Bind しておき、フィールドインジェクションやメソッドインジェクションで該当のMonoBehaviourに Inject するようにすれば、Initializeが実行される前に依存関係の解決を行うことができるでしょう。

万が一、「使用しているライブラリがAwakeに初期化処理を含めている」などの理由で上記の対応をとれない場合はInitializeの前に初期化処理を行いたいMonoBehaviourに対して

  • 該当のGameObjectContextのコンテナの親コンテナにBindする
  • [Inject]アトリビュートでマークした初期化用のメソッドを追加し、初期化処理をその中に記述する

いずれかの方法をとることによってInitializeの前に必要な初期化処理が行われることを保証できます。(私の場合、これに該当していたためこの現象に引っかかってしまいました・・・)

ただし前者の方法をとるとBindしたコンテナのすべてのサブコンテナから該当のMonoBehaviourが参照できてしまい、後者の方法をとると「Injectでは必要なクラスの注入だけ行い、初期化処理自体はInitializeStartで行うようにする」という思想に反するため、どちらの方法もあまりおすすめはしません。

最後に

Zenjectのドキュメントではこの挙動に対して言及しておらず、他のドキュメンテーションもあまりなかったため原因を探すのにかなり苦労しました。
他の同じ現象に引っかかった方々にとって、この文章が少しでも助けになれば幸いです。

それでは、良い Zenject ライフを。