こんにちは! 無職に近いフリーランスでプログラマをしているガミタです。
CySharp(neueccさん)たちが作るライブラリってほんとすごいですよね。ついライブラリ作ろって気持ちにさせてくれます。
今回はそんな熱気が冷めないうちにDI(dependency injection 依存性注入)を作ったというお話です。
ところでDIってめんどくさい
Unityから離れて数年。戻ってみればZenjectからVContainarに主流が変わり戸惑っております。
自作のゲームライブラリを作っている中で、「なんかDI使ってないとダメみたいな雰囲気」があるので、さくっと作ってみました。
でもやっぱり、DIってめんどくさいですよね。
- 主流のインターフェイスが共通になっていない
- Lifetimeが別にある(Unityとは別のサイクルを覚えなきゃ)
- LifetimeScopeってのもある。なんかGameObjectの生存期間とかかな
- LifetimeScopeのContainerBuilderがあるみたい。ふむ
- LifetimeScopeのルートであるRootLifetimeScopeがあるようだ。なるほど
- 利用するにはLifetimeScopeを継承したmonoBehaviourをシーンにアタッチする。まだわかる
- Configureにbuilder.Register(Lifetime.Singleton)を設定する
- マイクロソフトが推奨するやり方で、コンストラクタ側で参照を受け取るようにする
-
{ this.helloWorldService = helloWorldService; }
- EntryPointって概念を覚えて。 はあ
- ITickable.Tick()ってのがって、AwakeやStartなんてないんや
- RegisterEntryPointってのがあってだね ぐぬぬ
息できないです
書いていて頭が痛くなりました。でも、機能がいっぱいで洗練されているのはわかります。神です。
でも、使いこなせる自信がないのです。
僕らが欲しいのって、デスマーチの段階で入れたりするDI
ゲーム開発は時間がないんですよね。最初に仕様を決めたとしても、新しい技術を取り入れたりします。UniRxからUniTask、R3とライブラリも複雑になっていきます。Unityも非同期に力を入れ始めています。
だからこそ、単純な機能で、サクッと取り入れたり。
みなさんも、おれおれフレームワークを作っているでしょう。そこにVContainerを入れるとなると、大幅な設計変更が起きるはずです。それをなんとか避けたい。
ここから本題。作ってみよう。秋イカ釣りの出発までに
この記事を書いている数時間後にはイカ釣りに行くので、それまでにDIを完成させ記事を書いています。
ソースコードは希望がありましたらすべて掲載します。
使い方
private async UniTaskVoid Start(){
var instance = await this.Resolve<TestComponent>();
}
終わりです。なんと、これでDIになっています。(ほんまか?)
この見慣れない
private async UniTaskVoid Start(){
という部分はStartメソッドを非同期にするというもの。
objectの拡張メソッドであるResolve<>メソッドを呼び出して、MonoBehaviourを継承したクラスを取得してるんですね。
private async UniTaskVoid Start(){
実行すると
クラス名と同名のGameObjectが生成されて、自動的にアタッチされましたね。
もちろん
- 通常のクラス
- MonoBehavierクラス
に対応しています。WebシステムやデスクトップアプリなどにもDIは利用されますが、ライフサイクルが極端に短いので、コンストラクタを使って依存解決とかするんですが、Unityって特殊なのでそこまで推奨スタイルに乗っ取る必要はないのかなと。
隠れた機能がたくさん
もちろんこれで終わりじゃないですよ。
public class TestComponent : MonoBehaviour, IInitializable
{
public async UniTask WaitForInitializedAsync()
{
Debug.Log("にゃーん");
await UniTask.CompletedTask;
}
}
IInitializableインターフェイスを継承させます。VContainerに同名のものがありますが異なります。移植が簡単になるように寄せただけですね。
DIが初期化を処理するのではなく、初期化タイミングは作り手に任せる
これが僕のライブラリが選んだ手法なわけです。VContainerが全て担う代わりに、初期化が終わったら教えてね。って感じにするんです。依存関係逆転の原則ってやつです(ほんとかな)
次の能力は初期化
さて、DIと言いながら、ここまではコンテナと初期化機能です。DIの本気は
インターフェイスと具象クラスを結ぶことです。
私が考えたのは以下のこと
- いきなりインターフェイス設計から入るやつは上級者。デスマーチに慣れた脳なら普通のクラスから作るはず
- 全部をDI基盤に完全依存させる必要はない。とこっとだけDIを活用したい場合がメインのハズ
- それなら、途中からインターフェイスに切り替える前提のDIにしちゃえ
そう。
private async UniTaskVoid Start(){
var instance = await this.Resolve<ITestComponent>();
}
TestComponentをITestComponentに変えてみました。まだ継承はしていません。すると?
エラーになりません。
実行してみましょう。
実行時にエラーが出ましたね。タイプが登録されていない。
ではここでDIの次の機能を試してみましょう。
実装しました。ITestComponentインターフェイスはただの空です。
public class TestComponent : MonoBehaviour, IInitializable, ITestComponent
{
public async UniTask WaitForInitializedAsync()
{
Debug.Log("にゃーん");
await UniTask.CompletedTask;
}
}
さらに実行コードに1行追加します。
Registry.Register<ITestComponent>().To<TestComponent>();
var instance = await this.Resolve<ITestComponent>();
はい! ちゃんと動きましたね。いったんまとめたコードを載せます。
//実装
public class TestComponent : MonoBehaviour, IInitializable, ITestComponent
{
public async UniTask WaitForInitializedAsync()
{
Debug.Log("にゃーん");
await UniTask.CompletedTask;
}
}
//呼び出し
Registry.Register<ITestComponent>().To<TestComponent>();
var instance = await this.Resolve<ITestComponent>();
疲れた頭でも読めるでしょう? これが無職が行き着くお気楽コードです。
Registry.Register<ITestComponent>().To<TestComponent>();
これはまさに、インターフェイスと具象クラスをつなげる処理です。どこに書いても上書きされるので、好きなタイミングで変えることができます。(普通はエントリーポイントで初期化時に一括指定)
もういっちょいってみよう
MonoBehaviourではなく、普通のクラスの場合
Registry.Register<ITestClass>().To<TestClass>().AsFactory(() => new TestClass());
おしりにAsFactoryを生やすことができます。これはコンストラクタで初期値設定ができますね。
次にMonoBehaviourと普通のクラスに共通しているもの
Registry.Register<ITestClassWithInit>()
.To<TestClassWithInit>()
.InitializeWith<TestClassWithInit>(c => c.Initialize(99));
InitializeWithメソッドはプロパティ初期化子です。
.InitializeWith<TestClassWithInit>(async c => {
cInitialize(99);
await c.DownLoadDatabase();
});
こんなことも。要は、コンストラクタだけが初期化の正義じゃなくて、プロパティいじったりするのも含めて初期化でしょって感じです。
お気づきですか?
そう、IInitializableを継承していればInitializeWithはWaitForInitializedAsyncより前に非同期で完了する。つまり、
- InitializeWithでURLだけプロパティで差し替え
- WaitForInitializedAsyncで隠蔽された初期化はそのまま
という、わかりやすい仕様になる。
「ちょっとマスターデータのダウンロード先をローカルからリモートへ変えてね」
「はいー。クラスは触らず、InitializeWithでURLだけ変えてっと。」
これが可能になる。めちゃ便利。
もうそろそろ行かなきゃ
もっと伝えたいことがあるんだが、イカ釣りにいかないと。そう。大事なことを忘れていた。
using System;
...
namespace SceneManager
{
public class HomeManager : SceneBaseManager
{
protected override async UniTask InitializeScene(INavigationArgument argument,
CancellationToken cancellationToken)
{
}
protected override async UniTask SceneStart(CancellationToken cancellationToken)
{
Registry.Register<ITestComponent>().To<TestComponent>();
var instance = await this.Resolve<ITestComponent>();
Debug.Log("ホームシーン SceneStart");
return;
}
}
}
この自作DIを応用すれば、SceneBaseManagerを継承するだけで、マネージャー間で同期を合わすことができる。つまり、SceneStartが実行されると、すべてのマネージャーがエントリーポイントを持つことができるのだ。
ライフスコープや特殊な管理はいらない。単純なMonoVehaviorの非同期Awake,Startだけで成立している。この初期化タイミングをDIは強制しない。
実際にはこの上のグローバールマネージャーや、デバッグ機能、UIが全て連携している。シーン移動イベントすらできる。恐ろしいのは、DIに依存するのは2行。
Registry.Register<ITestComponent>().To<TestComponent>();
var instance = await this.Resolve<ITestComponent>();
これだけで、コンテナ、バインディング、LifeScope管理、イベント発行、初期化タイミング、値の差し替え、コンストラクタリフレクションが行われている。
終わりに
DIって難しいイメージがあると思う。実際には簡単なんだけれども、Unityの特殊な事情や連携を考慮するとどうしても巨大ライブラリになってしまう。(実際に複雑怪奇なんだが!)
みんなも、もっと気軽にDIしよう。VContainerも鬼素晴らしいから、率先して使おう!
ではレッツフィッシィング!