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