非同期世代のUnity向けDIフレームワーク「Doinject」

Published: Feb 24, 2024 by mewlist

Doinject は Unity 向け非同期DIフレームワークです (Github Repository)。
非同期DIが「実用上、何を解決してくれるのか?」について、わかりにくい点も多いため、実例を交えながら解説したいとおもいます。

非同期に生成されるインスタンスとDIコンテナ

「非同期に生成されるインスタンスをDIコンテナは扱うことができるのか?」 について考えてみます。

例として、プレハブを Addressables からロードし、そこにアタッチされているコンポーネントを取得する流れを考えてみます。 この処理を手続き的に記述すると以下のようになります。

AssetReference<GameObject> prefabAssetReference = ...;

var prefab = await Addressalbes.LoadAssetAsync<GameObject>(prefabAssetReference);
var instance = Instantiate(prefab);
var component = instance.GetComponent<SomeComponent>();

本質的な関心は SomeComponent にありますが、そのインスタンスを得るためには、Addressables のロードという非同期処理が必要となります。 この生成手続きを DI コンテナで扱うにはどうすればよいでしょうか。

同期的にインスタンスを提供する DIコンテナのみでは、この問題は解決することができません。

そこでいくつかの解決策を考えてみます。

案1: 予め必要なリソースをロードし、そのインスタンスを DIへ登録する

非同期処理が必要な Addressables のロード部分だけを先行して行うことができれば、問題は解決します。 独自のリソース管理基盤を持つアプリケーションでは、この方法で解決できるかもしれません。

しかし、Unity のライフサイクル、そして、DI コンテナのライフサイクルと協調して動作する必要があり、 難易度の高い実装となることが予測されます。

DI コンテナはあるコンテクストが生成されたとき、なるべく早い段階でその構築を終わらせようとする性質があります。 DI フレームワーク側の手続きよりも先にリソースをロードするタイミングをどうやって作るのか?という点も懸念があり、 フレームワーク自体の実装に手を加えていく必要が出てくるかもしれません。

案2: カスタムファクトリを DI に登録し、生成手続きを非同期で行う

コンテナから直接 SomeComponent 型を注入してもらうことは諦める方法です。 案1と比較して難易度が低く、見通しが良いため、個人的な経験に於いても、この方法をとってきました。

コードは以下のようなものとなります。

private CustomAsyncFactory<SomeComponent> Factory { get; set;}
[Inject]
void Construct(CustomAsyncFactory<SomeComponent> factory)
{
    Factory = factory;
}

...

private async Task SomeMethodAsync()
{
    var instance = await Factory.CreateAsync();
}

本質的な要求としては SomeComponent を DIコンテナから直接提供してもらいたいのですが、仕方なくファクトリで代用しているという状況です。

その代償として、SomeComponent のインスタンスは、DI コンテナの管理から外れ、Singleton や Cached といった DI コンテナ特有の戦略を取ることはできず、 自分でなんとかするしかありません。

非同期生成を伴うインスタンスを 一般的な DI コンテナで扱うことは、難しそうであるということがわかります。

非同期 DI コンテナのデザイン

これらの問題点を踏まえ、非同期世代の DIコンテナはどうあるべきかという点を考えてみます。

案1で提示された

DI フレームワーク側の手続きよりも先にリソースをロードするタイミングをどうやって作るのか?

この点が解決できれば、非同期生成を伴うインスタンスであっても、DI コンテナが管理できるようになるので、そこを中心に考えてみます。

結果、コンテクストの生成から、DIコンテナの初期化、コンテナによる型の解決、注入に至る流れすべてが非同期関数で記述されている必要があるということがわかります。

しかし、これは、フレームワークとしてのコアな設計に関わっており、ゼロから再設計が必要となる性質の問題です。

こういった流れがあり、フルスクラッチでコードを書き始めることとなりました。

非同期 DI コンテナの実装

DI コンテナの内部実装は、一般的な DI コンテナと大きく矛盾するものではありません。

しかし、DIコンテナのコア部分となる、Resolver インターフェースが非同期化されています。同期インターフェースは提供されていません。

public interface IResolver<T> : IResolver
{
    ValueTask<T> ResolveAsync(IReadOnlyDIContainer container, object[] args = null);
}

ここを起点として、リゾルバへと連なる、インスタンス生成処理、注入処理、コンテクストの生成処理すべてが非同期化を余儀なくされ、自然と非同期インターフェースをもった DI コンテナが出来上がります。

これが 非同期DIコンテナの本質です。

リゾルバが非同期化されたことにより、この記事の先頭にしめした、Addressables のロードが絡む Prefab の生成が可能となりまます。

container.BindPrefabAssetReference<SomeComponent>(prefabAssetReference);

DI への登録記述は、他のバインディングと同等の簡潔さとなり、インジェクションについても、特別な記述を必要としなくなりました。

余談: Addressables と DIコンテナの相性

Addressables を最初に触ったときに誤解していたのは、 「必要なリソースを指定して、ロードして、ロードされたものを使う」という考え方が、本質ではないということでした。

Addressables は、その設計思想に Lazy さがうかがえます。「必要とあらば、ロードしてみる」という制御の反転がそこにはあります。 Unity はオブジェクト間の参照関係を自由に組み上げられるということが特徴なので、設計上自然なことだと言えます。

この事実は、DIフレームワークとの相性の良さともつながっています。 DIフレームワークも、必要ならインスタンスを提供するという考え方だからです。

幸いにして、Addressables は参照カウントを持っており、眼前のインスタンスをアンロードするだけでは、 そのリソースの実態がアンロードされるかどうかはわかりません。 つまり、関心の範囲を決め、その中だけで小さくリソース管理をするだけで、うまく機能するようになっています。

Doinject のコンテクスト空間は、この関心の空間と矛盾しません。 コンテクスト空間を閉じれば、ロードしたリソースが自動的に解放されますが、 参照カウントがどうなっているかまでは気にしなくてよいため、安全に Addressables 由来のリソースを解放できる様になっています。

DI フレームワークを導入する意義の一つに、関心の範囲を決めて、その中で小さく正しく動くようにする。 モジュラリティの高い設計を心がけるようになるというものがあります。

私は、「スモールアプリケーションの集合がアプリケーションをなす」という考え方が好きで、普段の開発において、小さな部品であっても、簡単に起動でき、簡単にテストできるような基盤構造をつくることを意識しています。

Unity で例えるなら、ランタイムで、プレハブをドラッグアンドドロップしてインスタンス化するだけで、そのプレハブが完全に機能する。 こんな気楽さを理想と考えています。

まとめ

最後は余談となりましたが、Doinject の簡単な設計コンセプト解説でした。

Share

About me

mewlist

Latest Posts

非同期世代のUnity向けDIフレームワーク「Doinject」
非同期世代のUnity向けDIフレームワーク「Doinject」

Doinject は Unity 向け非同期DIフレームワークです (Github Repository)。
非同期DIが「実用上、何を解決してくれるのか?」について、わかりにくい点も多いため、実例を交えながら解説したいとおもいます。