もんりぃ is undefined.

育児ネタとか、技術ネタとか。

Zenject (Extenject) の FromSubContainerResolve() 完全に理解した。

はじめに

完全に狙ってるタイトルですが、深夜のテンションってコトでご容赦を。

この記事は、「何か分からんけど、Kernel 系のインタフェース( IInitializable とか ITickable とか IDisposable とか)が上手く動かないコトが多い Zenject (Extenject) の SubContainer」を良い感じに制御できるようになったので、それの備忘録として残すものであります。

同じ様な悩みを持つ方の一助となれば幸いです。

TL; DR

.ByInstaller() とか .ByMethod() の代わりに .ByNewGameObjectInstaller() とか .ByNewGameObjectMethod() を使えば OK です。

何の話し?

Zenject (Extenject) をちゃんと使っていると、よく出てくるのが「外側の Context に公開するインタフェースを実装するクラスが必要とする依存は外側の Context に公開したくない」という要求です。

例えば、以下のようなコードがあるとします。

using System;
using UnityEngine;

public interface IFoo
{
    double Result();
}

public interface IBar
{
    double PowerOfTwo();
}

public class Foo : IFoo
{
    [Inject] private IBar Bar { get; }

    double IFoo.Result()
    {
        return Bar.PowerOfTwo();
    }
}

public class Bar : IBar, IInitializable
{
    [Inject] private int Value { get; }

    double IBar.PowerOfTwo()
    {
        return Math.Pow(2, Value);
    }

    void IInitializable.Initialize()
    {
        Debug.Log("初期化されたよ!");
    }
}

このクラス群を Bind する Installer を凄くシンプルに書くなら以下のような感じになると思います。

using Zenject;

public class SomeInstaller : MonoInstaller<SomeInstaller>
{
    public override void InstallBindings()
    {
        // 下の2行は .BindInterfacesTo<T>() でも良いんですが、敢えて冗長に書いてます。
        Container.Bind<IFoo>().To<Foo>().AsSingle();
        Container.Bind(typeof(IBar), typeof(IInitializable)).To<Bar>().AsSingle();
        Container.BindInstance(10).AsSingle();
    }
}

で、この Installer は Zenject (Extenject) の入門記事としてならこれで良いんですが、実運用を考えると不都合が生じます。

具体的には、 Bar.Value に Inject する int のスコープが広すぎるために、他のクラスで別の意味の int を Inject したくなった時に困る という不都合です。

勿論、この不都合を回避するために .WithId() で一意にしたり、専用の型を用意したり、という対応も可能ですが、各クラスから見たときに ID とか専用型とかは無駄な情報になります。

で、それを回避するために Zenject (Extenject) に用意されているのが Context を狭くするために SubContainer を用いる という方法です。

SubContainer を用いる?

先ほどのクラス群に於いて、「 SomeInstaller が設定されている Context に公開したい型は IFoo のみ」だとすると、以下のように Installer を変更することで、 IBar とか int とかの Context を狭くすることができます。

using Zenject;

public class SomeInstaller : MonoInstaller<SomeInstaller>
{
    public override void InstallBindings()
    {
        Container
            .Bind<IFoo>()
            // ココがポイント。「SubContainer の中から探しますよー」という意味になる。
            .FromSubContainerResolve()
            // 別途 Installer クラス作って .ByInstaller<TInstaller>() とかでも OK です。
            .ByMethod(InstallBindingsToSubContainer)
            .AsSingle();
    }

    private void InstallBindingsToSubContainer(DiContainer subContainer)
    {
        subContainer.Bind<IFoo>().To<Foo>().AsSingle();
        subContainer.Bind(typeof(IBar), typeof(IInitializable)).To<Bar>().AsSingle();
        subContainer.BindInstance(10).AsSingle();
    }
}

こうすることで、 IBar であるとか int とかは IFoo 解決時にのみ用いられる Context 内での Bind となるため、他の Context に影響を及ぼすことがなくなるわけです。

で、これでめでたしめでたしとなれば、この記事は不要です。

この Installer でもまだ問題があって、このままだと Kernel 系のインタフェースが呼び出されない のです。

どういうコトかというと、 Bar が実装している Zenject.IInitializable は Zenject (Extenject) の機能により、「初期化時に Initialize() メソッドを勝手に呼んでくれる」という便利インタフェースなんですが、これが発動しません。

Kernel って?

Zeject に於ける Kernel の役割としては、凄く雑に言うと「 MonoBehaviour のプレイヤーループ的なモノを管理してくれる」機能のコトです。
詳しく仕組みを説明すると長くなるので割愛しますが、 MonoBehaviour で言うところの Start() とか Update() とか LateUpdate() とか FixedUpdate() とか OnDestroy() とかを Pure Class でも呼び出せるようになると理解してもらって OK です。

以下に列挙したインタフェースを実装すると、Zenject (Extenject) がそれぞれ適したタイミングで呼び出してくれます。

  • Zenject.IInitilizable: クラス初期化時点で呼び出される。 Start() 相当。
  • Zenject.ITickable: フレームごとに呼び出される。 Update() 相当。
  • Zenject.ILateTickable: フレームごとの最後に呼び出される。 LateUpdate() 相当。
  • Zenject.IFixedTickable: 物理フレームごとに呼び出される。 FixedUpdate() 相当。
  • System.IDisposable: クラス破棄時点で呼び出される。 OnDestroy() 相当。

んで、この Zenject.IInitializable.Initialize() が呼ばれない理由としては、「 Kernel が Bind されていないから。」というシンプルな事由になります。

これを有効にするためには、Zenject (Extenject) のドキュメントに依れば以下のようにすれば良いらしいです。

using Zenject;

public class SomeInstaller : MonoInstaller<SomeInstaller>
{
    public override void InstallBindings()
    {
        Container
            .Bind<IFoo>()
            .FromSubContainerResolve()
            .ByMethod(InstallBindingsToSubContainer)
            // ココがポイント。これにより、SubContainer に Kernel の機能を生やせるらしい。
            .WithKernel()
            .AsSingle();
    }

    private void InstallBindingsToSubContainer(DiContainer subContainer)
    {
        subContainer.Bind<IFoo>().To<Foo>().AsSingle();
        subContainer.Bind(typeof(IBar), typeof(IInitializable)).To<Bar>().AsSingle();
        subContainer.BindInstance(10).AsSingle();
    }
}

はい。これで、無事に Bar.Initialize() が呼ばれてログが出るハズです。

………。

はい。これ、動きません。

理由は分からんのですが、少なくとも私の環境では動きませんでした。ぐぬぬ

解決策

物凄く回りくどい記事になってますが、ココがオチです。

色々調べた結果、「普通に ByInstaller とか ByMethod で呼ぶと何やっても駄目」というコトが判明しました。

以下のように Installer を変更すると、期待した動きになりました!

using Zenject;

public class SomeInstaller : MonoInstaller<SomeInstaller>
{
    public override void InstallBindings()
    {
        Container
            .Bind<IFoo>()
            .FromSubContainerResolve()
            // ココがポイント。NewGameObject を付けて、ランタイムに GameObjectContext を生成させる。
            .ByNewGameObjectMethod(InstallBindingsToSubContainer)
            // その場合、そもそも Kernel 生えてるので WithKernel() は不要
            .AsSingle();
    }

    private void InstallBindingsToSubContainer(DiContainer subContainer)
    {
        subContainer.Bind<IFoo>().To<Foo>().AsSingle();
        subContainer.Bind(typeof(IBar), typeof(IInitializable)).To<Bar>().AsSingle();
        subContainer.BindInstance(10).AsSingle();
    }
}

要点としては GameObjectContext の Kernel に処理させれば良い ってコトですね。

あ、勿論 IFoo がどこからも依存されていない場合は発動しないので、その場合は .AsSingle() の後ろに .NonLazy() 付けてください。

まとめ

Zenject (Extenject) の Context を適切に管理することは、キレイなシステムを作るためには不可欠なので、こういうイディオムを覚えておくと良いでしょう。