2020/10/21 追記: だいぶ前にZenject卒業して、プロジェクトごとにService Locatorを書くようになりました。結局「シンプルイズベスト」です。でもZenjectや、他の言語の色んなDIフレームワークを使ってみたことで、「DIはフレームワーク無くてもできる」という(思い返せば至極当然な)結論に至れたのは良かったなと思います。
特に「どのオブジェクトを注入するか変幻自在」っていうDIフレームワークの中核の機能って、main関数でif文使って注入していくぐらいのベタな方法と比べて、ほとんどの場合は逆に見通しが悪くなってしまいました。if文は結局どこかにあって、その分の複雑性は解消されてないんです。じゃあ、見えるところに書いておいたほうが分かりやすいよね・・・という「シンプルイズベスト」に回帰しました。
もちろん必要があるときはまた使うかもしれません。
以下本文です。
ドキュメントの英語がどことなく変だし、既存のプロジェクトに導入するとリファクタリングの規模が半端じゃないし、Unity独特の落とし穴があるし、そもそもDIを使える人が少ないし、新しく覚えさすと時間かかるし、そこまでするほどなものかと言われると、、、分からん
個人的に思いつくまま書きます。他の記事が大勢書いてくれそうなことは避けました
まずDI関係ない便利機能から
引数無しInjectメソッドで便利な初期化
class Foo : MonoBehaviour
{
[Inject]
void OnInjected()
{
something1.Initialize();
something2.Initialize();
...
}
}
Unityのイベントループで、Awakeの後、他のスクリプトのStartの前に、active/enabledでなくても実行されます。シーンロード時にオブジェクトのオンオフにかかわらずインスタンスメソッドを呼びたいときに便利
Injectと言ってますが何もInjectされてきてなくてもメソッドは呼ばれます
内側ではSceneContextがシーン全体を探し回ってStartループの最初にすべて発火する感じになってます。なのでSceneContextがシーンに無ければ呼ばれません
ドキュメントでは同じことをするのにIInitializable
を使う方法が大きく紹介されてますが、それだとInstallerに載せる手間が増えるので、こっちしか使わないんじゃないかと思います
ScriptableObjectにStartメソッド
同じことをScriptableObjectでもできます。が、Sceneの外を探してくれるContextがないので、Scene内のInstallerにScriptableObjectの参照を渡して、そこからコンテナに登録します
class Foo : ScriptableObject
{
[Inject]
void OnInjected()
{
something1.Initialize();
something2.Initialize();
...
}
}
class MyInstaller : MonoInstaller
{
// エディタでドラッグ&ドロップ
[SerializeField]
Foo foo;
override public void InstallBindings()
{
// コンテナに登録
Container.QueueForInject(foo);
}
}
これでSceneのStartループの最初にScriptableObjectを初期化できます
ここらへんまではDI機能を使う気がないプロジェクトに徐々に侵略をかける用途に使えます
敵プレハブを任意の数Instantiate & Inject
DIです。具体的なコードも載せときます。公式はこちら
シーンにそもそも存在するオブジェクトにインジェクションかける手順はたぶん他の記事がカバーしてくれるので、ここでは動的にシーンに追加されるオブジェクトにどうインジェクションするのか、例を載せます
普通にObject.Instantiate
を使うと新しいオブジェクトにインジェクションはかかりません。コンテナがそのオブジェクトを知らないからです。なので、まずファクトリーを作り、それをコンテナに登録して、それを経由して新しいオブジェクトを作ります
まずコンストラクタ引数がいらない場合(Instantiate
そのまま互換)
// 敵プレハブのスクリプト
public class FooEnemy : MonoBehaviour
{
[Inject]
void OnInjected()
{
something1.Initialize();
something2.Initialize();
...
}
public void TalkShit()
{
...
}
// Zenject魔法のファクトリクラス
public class Factory : Factory<FooEnemy> {}
}
// ファクトリとプレハブを紐づけるクラス
class MyInstaller : MonoInstaller
{
// 敵プレハブの参照
[SerializeField]
FooEnemy fooEnemyPrefab;
// 敵のTransformをこのオブジェクトの子供にする
[SerializeField]
Transform fooEnemyRoot;
override public void InstallBindings()
{
// 敵プレハブとファクトリクラスを紐づけてコンテナにバインディング
Container.BindFactory<FooEnemy, FooEnemy.Factory>()
.FromComponentInNewPrefab(fooEnemyPrefab)
.UnderTransform(fooEnemyRoot);
}
}
// ファクトリを実際に使って敵を召喚するクラス
public class FooEnemyManager : MonoBehaviour
{
[Inject]
FooEnemy.Factory fooEnemyFactory;
public void SpawnAndSmile()
{
// 敵プレハブをInstantiate。`Create()`メソッドは勝手に実装されます
FooEnemy e = fooEnemyFactory.Create();
e.TalkShit();
// 普通にDestroy
Destroy(e);
}
}
プレイヤーをコンストラクタに渡して初期化と同時にプレイヤーのほうを向かせる場合
public class FooEnemy : MonoBehaviour
{
// コンストラクタ引数をもらう。OnInjected()に渡すのも可
[Inject]
Player player;
[Inject]
void OnInjected()
{
// 初期化と同時にプレイヤーのほうを向く
LookAt(player.transform.position);
}
void LookAt(Vector3 target)
{
...
}
// ファクトリクラス、Playerを引数にとる
public class Factory : Factory<Player, FooEnemy> {}
}
class MyInstaller : MonoInstaller
{
[SerializeField]
FooEnemy fooEnemyPrefab;
[SerializeField]
Transform fooEnemyRoot;
override public void InstallBindings()
{
// バインディング、Playerを参照にとる
Container.BindFactory<Player, FooEnemy, FooEnemy.Factory>()
.FromComponentInNewPrefab(fooEnemyPrefab)
.UnderTransform(fooEnemyRoot);
}
}
public class FooEnemyManager : MonoBehaviour
{
// ファクトリクラスをInject
[Inject]
FooEnemy.Factory fooEnemyFactory;
[SerializeField]
Player player;
public void SpawnAndSmile()
{
// 敵プレハブをInstantiate。同時に`LookAt(player)`が呼ばれます
FooEnemy e = fooEnemyFactory.Create(player);
}
}
引数は型引数の前側に追加していく感じです。ただし型引数が増えすぎるとコードが乱れるのでDTOパターンでまとめたらいいと思います
MonoMemoryPoolでFactory+プーリング
Factory
の兄弟にMonoMemoryPool
がありまして、こっちはオブジェクトプールも作ってくれます。重いプレハブを頻繁にコピーする場合はFactoryだとしゃれにならんので便利です
基本的な使い方はだいたい同じなのでこっちを参照。
Factoryと違ってDespawn()
してもオブジェクト自体はSceneに残って使いまわされるので、たとえばUniRxのAddTo(this)
はそのままだと使えないので工夫が必要です。僕のほうでは完全にボイラープレート化してます:
// プールされるクラス
public class MyItem : MonoBehaviour
{
// プールクラス
public class Pool : MonoMemoryPool<MyItem>
{
// 引数がほしい場合は`Reinitialize()`を`OnSpawned()`の代わりに使います
protected override void OnSpawned(MyItem item)
{
base.OnSpawned(item);
item.OnSpawned(); // インスタンスにイベント通達
}
protected override void OnDespawned(MyItem item)
{
base.OnDespawned(item);
item.OnDespawned(); // インスタンスにイベント通達
}
}
// ライフタイムイベント管理用
readonly CompositeDisposable life = new CompositeDisposable();
[Inject]
void OnInjected()
{
// `OnSpawned()`の前に呼ばれますが、初回だけです
// ここには載せてませんが`OnCreated()`も同じことをします
}
void OnSpawned()
{
life.AddTo(this); // Sceneが閉じる場合などDestroyされるので。
// ライフタイムイベントを登録
Observable.EveryUpdate()
.Subscribe(_ => Say("hoooo"))
.AddTo(life); // `this`ではなく`life`で管理します(間接的に`this`も打ちます)
}
void OnDespawned()
{
life.Clear(); // Despawnでイベントすべて破棄
}
}
Contextの入れ子
Context AのコンテナをContext Bからも使いたいという場合です。複数のSceneの間でコンテナを共有したいときに必要になります。基本的にエディタで名前を打ち込むだけです。日本語の記事
僕のほうではMulti-sceneでこういう構造になってました:
- Persistence Scene -- アプリが開いてるあいだ常に稼働
- Slam Scene -- ARKit/ARCoreの機能を使いたいSceneが開いてる間だけ稼働
- Dish Scene -- ARKit/ARCoreを使うSceneその1
- Hunt Scene -- その2
- その他
2,3,4,5のSceneContextはすべて1のSceneContextをParentに指定してあります。3,4は1に加えて2もParentにしてあります。2のSlam SceneをiPhone用・Android用・エディタ用で環境に応じて挿げ替えれるようになってます
ただよっぽどプラットフォーム別の実装の違いが大きかったりとか、ヒエラルキーが長大複雑になってたりとかしない限り、Sceneのロード・アンロードの管理とか面倒くさいので、必要になるまで使わない方向で。。。
Installerスクリプトの群れをSceneのどこに置くか
関心事がたくさんあるゲームだとInstallerもかなりの量になるので、散らばってると色々面倒です。個人的に「モデル・コントローラ用の束」と「View用の束」でScene毎に高々2つのオブジェクトにまとめて置いてます
UniRxとの併用
特に問題ないです
ストリームのインスタンスをそのままバインディングするのはやめたほうがいいです。ストリームの型が重複したときにIDで見分けることになりますが、これが面倒臭いし、型自体が変わったときにプロジェクト全体で細かい修正が必要になります。DTOでラップしておけば後で小回りが利きます
[Inject]
にクラスの型を使ってもいい場合
DIを使うと言うと、基本的にインターフェースをバインディングして、実装をInstallerで挿げ替える、という使い方が前提になると思います
たとえば最初から自動テスト・モックが必要なことが分かってる場合は最初からインターフェースでバインディングして、テストとモックから実装を始める感じです
でもUI層では、まずシングルトンクラスから始めて、必要に応じてインターフェースに移行したい、という場面がたまにあります
たとえば、UIの適当な範囲の状態管理を複数のReactiveProperty
+ヘルパーメソッドとしてシングルトンにまとめて複数のVCから使うパターンを僕は一番多用してるんですが(いわゆるMVVMというやつです)、これをDIの「シングルトン排除」の思想でインターフェースに抽出したところ、結局UIの自動テスト・モックはしようがないので実装はひとつしかない・・・するとインターフェースがむしろ邪魔になります。最初の段階ではシングルトンクラスでの運用で特に困らないし、そのほうが実装が早いという感じです
こういう場合、そのシングルトンへの参照は、いちおうstaticではなく[Inject]
として始めます。Installerでそのインスタンスをそのクラス自身の型にそのままバインディングします。後でやっぱりモックが必要になったとき初めて、インターフェースに抽出して、Installerで実装クラスをバインドして、参照側のコードは[Inject]
下の型名にIをつけて終いです
締め
Twitter見てる限りまだZenjectって何?みたいな状態なのでこの記事が誰かの役に立つのは二ヶ月ぐらい後のような気がします
僕はPokemonGoがZenject使ってると言ってたので少し先んじてプロジェクトで使い始めました。最初のリファクタリングと学習曲線がそこそこありましたが、落ち着くと快適です。MemoryPool便利なので広めたい
シェーダ勉強中です
https://twitter.com/ryoichirooka
どうもありがとうございました