Zenjectの初期化処理の順番に潜む罠
はじめに
こんにちは。クライアントエンジニアの ichi です。
今回は、私が実装中に躓いた Zenject の初期化処理の順番に潜む罠ついてお話ししたいと思います。
GameObjectContext
, SceneContext
などの各コンテキストやKernel
に関する話を含むので、Zenject中級者向けの記事になります。
Zenjectによるクラスのライフサイクルの管理
まず、Zenjectで用意されているクラスのライフサイクルを管理するための枠組みについて軽くおさらいします。
MonoBehaviour
には Unity のライフサイクルに合わせて実行される Awake
, Start
, Update
, OnDestroy
などのイベント関数がいくつか定義されています。MonoBehaviour
ではない純粋なクラスはこれらの関数を使用することができないため、このライフサイクルに合わせて何か処理を行いたい場合はMonoBehaviour
を継承したクラスを利用する必要があります。
対して、Zenject ではMonoBehaviour
ではない純粋なクラス上でこれらの関数に相当するものを利用することができる枠組みが用意されています。Start
に相当するInitialize
関数を提供するIInitializable
、Update
に相当するTick
関数を提供するITickable
などのインターフェースがいくつか用意されており、これらをクラスで実装しコンテナにBind
することによってUnityのライフサイクルに合わせて処理を行うことができます。これによりMonoBehaviour
の仕様に縛られずに自由にクラスを制御することが可能になります。
初期化処理の実行順が逆転する?
Zenjectを用いてコーディングをするときは、非MonoBehaviour
クラスのコンストラクタとAwake
、IInitializable.Initialize
とStart
を併用してコンテキスト内のクラスの依存関係の解決と初期化を行うことが多いと思われます。コンテキストの初期化時、コンテナに Bind されているクラスのコンストラクタは該当のクラスが Inject されるとき(つまり Awake
より前)に呼ばれますが、そのほかのAwake
、Start
、Initialize
関数はZenjectの公式ドキュメントに書いてある通り Awake -> Start & Initialize
という順番で呼ばれます。そのため、コンテキスト内の初期化処理もこの順番で各関数が呼ばれることを想定して実装されると思われます。
しかし厄介なことに、特定の条件下でコンテキスト内の IInitializable.Initialize
がAwake
よりも先に呼ばれてしまう場合があるようです。
実際のコードで例をあげましょう。まず、以下の2つのクラスを定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public sealed class InitOrderCheckComponent : MonoBehaviour { [Inject] private void Check() { Debug.Log("Component: I'M INJECTED!!!") } private void Awake() { Debug.Log("Component: I'M AWAKE!!!") } private void Start() { Debug.Log("Component: I'M STARTED!!!") } } |
1 2 3 4 5 6 7 8 9 10 11 12 |
public sealed class InitOrderCheckClass : IInitializable { public InitOrderCheckClass() { Debug.Log("Class: I'M CONSTRUCTED!!!") } public void Initialize() { Debug.Log("Class: I'M INITIALIZED!!!") } } |
次にGameObjectContext
をアタッチした Prefab を用意し、GameObjectContext
のMono Installers
に以下のGameObjectInstaller
を追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public sealed class GameObjectInstaller : MonoInstaller { Container.Bind<Facade>().AsSingle(); //今回の例では使わないクラス Container.Bind<InitOrderCheckComponent>() .FromNewComponentOnRoot() .AsSingle() .NonLazy(); Container.BindInterfacesTo<InitOrderCheckClass>() .AsSingle() .NonLazy(); } |
最後に、SceneContext
に以下のInstaller
を追加し、Runner
クラスのStart
で上記Prefabを生成するようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public sealed class SceneInstaller : MonoInstaller { [SerializeField] private GameObject _initOrderCheckPrefab; //Inspector からPrefabをセットする public override void InstallBindings() { Container.BindFactory<Facade, Facade.Factory>() .FromSubContainerResolve() .ByNewContextPrefab(_initOrderCheckPrefab) .AsSingle(); Container.Bind<Runner>() .FromNewComponentOnNewGameObject() .AsSingle() .NonLazy(); } } |
1 2 3 4 5 6 7 8 9 10 |
public sealed classs Runner : MonoBehaviour { [Inject] private readonly Facade.Factory _facadeFactory; private void Start() { _facadeFactory.Create(); } } |
そうすると、以下のような出力結果が得られます。
Component: I'M INJECTED!!!
Class: I'M CONSTRUCTED!!!
Class: I'M INITIALIZED!!!
Component: I'M AWAKE!!!
Component: I'M STARTED!!!
出力結果からわかる通り、Factory
を使用して動的にGameObjectContext
を生成した場合のみIInitializable.Initialize
がAwake
よりも先に呼ばれてしまいます。この場合、非MonoBehaviour
クラスのInitialize
内で同コンテキストのMonoBehaviour
クラスを触りにいったときに依存関係の解決や初期化処理が完了しておらず、意図しないバグを引き起こす可能性があります。
実行順が逆転する原因
なぜ、このような現象が起きるのでしょうか?
基本的にSceneContext
やProjectContext
、シーン開始時にシーン上に存在しているGameObjectContext
にBindされているIInitializable
を実装したクラスのInitialize
関数は前項で説明した通り、UnityのStart
のタイミングに合わせて実行されます。
しかし、実は動的に生成されたGameObjectContext
のみコンテナにBindされたコンポーネントのInitialize
関数が呼ばれるタイミングが違います。動的に生成された場合のみ、そのコンテキストのInject処理が終わった直後に自身の持つKernel
のInitialize
を明示的に呼び出します。そのため、コンテキスト内の初期化処理はInject -> Initialize -> Awake -> Start
という順に呼び出されることになります。
このような挙動になっている理由については、GameObjectContext
のコード内のコメントで以下のように説明されています。
1 2 3 4 5 6 7 8 9 10 |
// Normally, the IInitializable.Initialize method would be called during MonoKernel.Start // However, this behaviour is undesirable for dynamically created objects, since Unity // has the strange behaviour of waiting until the end of the frame to call Start() on // dynamically created objects, which means that any GameObjectContext that is created // dynamically via a factory cannot be used immediately after calling Create(), since // it will not have been initialized // So we have chosen to diverge from Unity behaviour here and trigger IInitializable.Initialize // immediately - but only when the GameObjectContext is created dynamically. For any // GameObjectContext's that are placed in the scene, we still want to execute // IInitializable.Initialize during Start() |
ざっくりまとめると、
- Unityの仕様で動的に生成されたオブジェクトの
Start
はフレームの最後に呼ばれる - そのため、
Factory.Create()
で動的に生成されたGameObjectContext
は生成直後に使用することができない(初期化処理が間に合わない) - なので動的に生成されたときのみ Unity のライフサイクルから外れ、即座に
IInitializable.Initialize()
を呼ぶようにする
とのことらしいです。
避けるためにはどうすればよいか?
基本的に、
Awake
の中で依存性の解決のみではなく、何らかの初期化処理を行っているAwake
の中で Zenject で管理されていないクラスの依存性を解決している
以上の2点を行っていなければこの現象でエンバグする可能性は低くなると思われます。なのでZenjectの思想として推奨しているように
コンストラクタや
Awake
で依存関係の解決のみ行うようにして、Initialize
やStart
で初期化処理を行う
ようにしましょう。
もし、その上で特定の順番で初期化処理を呼ぶ(今回の例でいうと 非MonoBehaviour
が初期化処理時に初期化済みのMonoBehaviour
クラスを参照する)必要がある場合は、Start
の処理をInitialize
に移し替えてBindExecutionOrder
で呼び出し順を整理すれば解決できます。
また、Awake
内でクラスの生成やGetComponent
をしている場合はそれらをFromComponentOnHierarchy
などでコンテキストのコンテナに Bind しておき、フィールドインジェクションやメソッドインジェクションで該当のMonoBehaviour
に Inject するようにすれば、Initialize
が実行される前に依存関係の解決を行うことができるでしょう。
万が一、「使用しているライブラリがAwake
に初期化処理を含めている」などの理由で上記の対応をとれない場合はInitialize
の前に初期化処理を行いたいMonoBehaviour
に対して
- 該当の
GameObjectContext
のコンテナの親コンテナにBindする [Inject]
アトリビュートでマークした初期化用のメソッドを追加し、初期化処理をその中に記述する
いずれかの方法をとることによってInitialize
の前に必要な初期化処理が行われることを保証できます。(私の場合、これに該当していたためこの現象に引っかかってしまいました・・・)
ただし前者の方法をとるとBindしたコンテナのすべてのサブコンテナから該当のMonoBehaviour
が参照できてしまい、後者の方法をとると「Injectでは必要なクラスの注入だけ行い、初期化処理自体はInitialize
やStart
で行うようにする」という思想に反するため、どちらの方法もあまりおすすめはしません。
最後に
Zenjectのドキュメントではこの挙動に対して言及しておらず、他のドキュメンテーションもあまりなかったため原因を探すのにかなり苦労しました。
他の同じ現象に引っかかった方々にとって、この文章が少しでも助けになれば幸いです。
それでは、良い Zenject ライフを。
- Tag
- Unity