概要
良いZenject/Extenjectの入門は
ZenjectチョットワカルBook
https://booth.pm/ja/items/1520608
にまとまっているので、それを読んで「Zenject/Extenject良いじゃん、今開発中のプロジェクトに入れよう」
と言う人の為の 最小の手間で、そこそこ効果が見込めるZenject/Extenject導入 です。
インストールとかは https://github.com/svermeulen/Extenject/releases ここからどうぞ。です。
今回は簡単な2ステップ式の導入講座です。
ステップ1:どのシーンからも使いそうな機構があるなら、ProjectContextを作る
Zenject/Extenjectを含むDIコンテナの仕組みは「A,B,CというクラスはDIコンテナ経由で使うよ」
と定義する部分と「このDというクラスはAとBというクラスを使うよ。AとBはDIコンテナ経由で埋め込むよ」と実際使う部分に分かれています。
今から作るのは定義する部分です。Zenject/Extenjectで言うとInstallerとかBindingsとか言う単語が出てきたら「あ、これは定義側の話ですね」と理解しておけばオッケーです。
Zenject/Extenjectの基本的な使い方は、シーンにSceneContextを配置して、オブジェクトにInstallerを配置していくという仕組みです。
でも、とりあえず 実装が簡単な割に効果が見込める機能としてProjectContext があります。
凄く大ざっぱに言うと「どのシーンから開始しても勝手に作られるシングルトン なPrefab」を作れます。
Projectビューの右クリック -> Create -> Zenject -> Project context
でProjectContextのプレハブを作成します。
場所はResourcesフォルダ以下である必要があります。(例: /Assets/_MyProject/Resources/ )
これを作れたら、空のシーンを作ってエディタの再生ボタンを押します。
う、嘘つき!!!何も生成されないじゃん!!!
…はい、僕もビックリしました。実は上の説明では不足していて「SceneContextがヒエラルキーに置いてあるシーンであれば、 どのシーンから開始しても勝手に作られるシングルトンなPrefab」がProjectContextです。
空のシーンを作って、右クリックからZenject/ExtenjectからSceneContextを作ります。
出来ました!
そして、改めてエディタから実行すると、ちゃんとProjectContextが無から生成されました!!!やった!!
このProjectContextに貼り付ける MonoInstallerを書いていきます。
例えばこんな感じのInstallerを書いて、ProjectContextに貼り付けます。
例でBindするように書いたのは大体どのシーンでも使いそうな筆頭である連続タップ防止とか、WebAPI通信とかです。
他にもオーディオマネージャ的なやつや**「プロジェクト全体で引き回すグローバル変数みたいなやつ」**をBindすることも多そうです。
ここで定義したクラスは、プロジェクトの至る所から参照される物、という制約を心の中に置いておかないと、プロジェクトがグッチャグチャになるので注意してください。
using Haritora.UI;
using UnityEngine;
using Zenject;
namespace MyProject.Installer
{
/// <summary>
/// Project全体のProjectInstaller
/// </summary>
public class MyProjectInstaller : MonoInstaller
{
public override void InstallBindings()
{
// インプットガード用コンポーネントを/Resources/UI/InputGuard.prefabから生成する
Container.Bind<InputGuard>()
.FromComponentInNewPrefabResource("UI/InputGuard")
.AsSingle()
.NonLazy();
//ダイアログコンポーネント
//グローバル変数みたいなやつのコンポーネント
//WebAPIコンポーネント
//MyWebApi.csというMonoBehaviour一個だけ付けた空のGameObjectを
//スクリプトから生成します。
Container.Bind<MyWebApi>()
.To<MyWebApi>()
.FromNewComponentOnNewGameObject()
.AsSingle()
.NonLazy();
}
}
}
ステップ2:ProjectContextでBindしたスクリプトを使う
上の方で話した「定義する方」と「使う方」で言うところの**「使う方」です。**
とりあえず、すべてのシーンに空のSceneContextを生成しましょう。
その後に以下のように使います。
シーンに最初から置いてある普通のMonoBehaviourから使う時
普段だったら [SerializeField] を付けたり FindObjectOfType();で引き当てるような変数を、[Inject]を付けた関数で割り当てます。
逆に言うと、それ以外はいつも通りに使って良いのです。
namespace MyProject.UI.Sample
{
/// <summary>
/// Zenject経由でバインドされる前提で書けるサンプル
/// </summary>
public class InputGuardUseSample : MonoBehaviour
{
//Zenject/Extenjectで注入する対象、ここではInputGuardとしています。
//private宣言して、SerializeFieldも付けないことで、Zenject/Extenject経由の処理をするぞ、と他のプログラマや数ヶ月後の自分にも意図を伝えられますね。
private InputGuard _inputGuard = default;
// Start では何もGetComponentしなくても良いし、FindObjectOfType()しなくても良い
void Start()
{
}
//このInjectアトリビュートを付けたメソッドが、初期化時に呼ばれて注入されます。引数付きStart()みたいな使い勝手。
//関数名は何でも良いんですが、Construct()に統一しておくと便利だと思います
[Inject]
public void Construct(InputGuard inputGuard)
{
_inputGuard = inputGuard;
Debug.Log("メソッドインジェクションテスト");
}
//呼び出すテストしたいならこんな感じ
private void Update()
{
if (Input.GetKeyDown(KeyCode.A))
{
_inputGuard.EnableInputGuard();
}
if (Input.GetKeyDown(KeyCode.B))
{
_inputGuard.DisableInputGuard();
}
}
}
}
シーン途中でInstantiateされるPrefab内のMonoBehaviourで使う時
基本的にZenjectのInjectを解決するタイミングはシーンの初期化時です。
と言う事は、例えばプロジェクト全体で使うLoggerを、シーンの途中で生成される敵キャラのPrefab上のEnemyController.csにInjectする、と言う使い方だとInjectされなくなって困ります。
あるいは、uGUIのScrollView内で動的に生成、破棄されるUIのButtonにInjectしたい、みたいなユースケースで困ります。
これに対する素朴な解決策として
「動的に生成される事が分かっているPrefabのRootにはZenAutoInjector.csをペタッとAttachしておく」だけでも、Prefabの生成時に[Inject]が動くようになります。
この 魔法のZenAutoInjector.cs をRootに貼り付けたプレハブ は、Instantiateされた瞬間に
- シーン上のヒエラルキーから適切なDIコンテナを探して
- prefabの子を再帰で探して[Inject]が付いている関数を探して、1.のコンテナを自動でInject
まで勝手にやってくれます。親切!魔法!すごい!
注意:上の処理内容を見て**「え…それ結構処理が重そう…」と思った人、正解です。**
このZenAutoInjector.cs は、弾幕シューティングの全敵に適用したりすると、メチャクチャ重いです。
そういう使い方をする場合はSpawnerを司るEnemySpawanControllerみたいな(Injectする)Prefabをシーンに設置しておきます。EnemySpawanControllerから敵を生成した後に、EnemySpawanControllerが手動でLoggerクラスを生成した敵に対してSetする、みたいな自前で注入する等の対策を取ってください。
でも、パフォーマンスクリティカルじゃない用途なら ZenAutoInjector.cs で十分に対応可能です。その方が楽ですし、コードに細工を増やさなくて良いです!
注意2: ZenAutoInjector.csによるInjectのタイミングについてです、Extenject最新バージョンと僕の手元環境ではAwakeより前にInjectが実行されていました が、Injectタイミングの保証は検証していません。もし意図せぬ挙動があったら、Injectが完了する前にStart()やUpdate()が走ったりしていないか確認してみてください。
非MonoBehaviourなクラスから使う時
本質的では無いので省略
ここまでで、何が出来るようになるの?
プロジェクト全体に存在し続けて欲しいSingletonMonoBehaviourとかで作っていたスクリプトや、ダイアログなどのUIコンポーネントを 非シングルトンでも使えるようになりました。
また、Awake()内部でFindOfjectOfType()したり、シングルトンの参照を取得する必要が無くなりました。
これの何が嬉しいかというと、
- 注入漏れや、null reference exceptionを吐く時はZenjectのエラーとして検知される
- シングルトンを無くせるので、テストが可能になる
- 初期化用シーン経由じゃ無いとエラーが起きるようなプロジェクトの作りではなく、どのシーンからエディタ再生しても動くように出来る
- [SerializeField]ではなくコードで意図を伝えられる
などがあります。
ここまでのプロジェクト修正をするだけなら、開発中のプロジェクトに追加しても半日くらいでも何とか出来るのでは無いかと思います。
それだけの手間を掛けると、上であげたようなメリットを得られます。十分に元が取れそう!と思いませんか。僕は元が取れると思いました。
ここまでで、何がまだ出来ないの
各シーン固有の「特定シーン内では、あらゆるところで参照される」スクリプトの参照解決が出来ていません。
これをなんとかするにはSceneContextごとのInstallerを書いていく必要があります。
ちゃんとした設計をしてないプロジェクトがZenject/Extenjectを入れると勝手に正しいアーキテクチャになる、みたいな話では無いです。
今回はZenject/Extenjectの強力な機能の一つであるInterfaceを切って使う、みたいな事には一切触れていません。この入門をして、プロジェクトにZenject/Extenjectを突っ込んでから追々とInterface切り分けとかも出来ると良いですね!
今回の記事で言いたかったこと
とりあえずZenject/Extenjectを入れて、ProjectContextだけを使って、グローバルなシングルトンを撲滅してみませんか。
というのが今回の記事のポイントです。ProjectContextを使うことで、staticなグローバル変数を詰めまくったクラス、とかはstaticから外すことが出来るようになります。マシなグローバル変数詰めまくりクラス を使うためだけにも導入しませんか。
(なぜマシかというと、グローバル変数なstaticクラスと比べてInjectを明示的に書かないとアクセス出来ないため、プログラマは意図を持ったクラスだけがグローバル変数みたいなやつにアクセス出来るようになります。また、Unity2019.3以降の
Fast Enter Playmodeに備える意味でも、staticなクラスは避ける価値があります)
ハンマーを持つ人には全てが釘に見えるので、なんでもZenject/Extenjectって思うかもしれないし、ちゃんと作らなきゃ、という圧はあるのですが、一旦「プロジェクト全体で使われるシングルトンを撲滅する為のZenject/Extenject」という最小の導入をしてみるのは良いかもしれません。
//いたる所で使われる、というのをZenject/Extenjectに任せるのはとても便利です。でも、そうじゃない物を全部Zenjectに渡すのは、バインドの順番や依存順問題とかを頭を使って解決する必要が出てくるので、Bindを多用するというときは注意