はじめに
VContainerを使い始めてしばらく経ち、実際のプロジェクトで触れる中でさまざまな知見が蓄積されてきました。
一方で、VContainer の基本的な使い方は理解したものの、「結局どこが良いのか」「どう設計に落とし込めばよいのか」がまだ腑に落ちていない方も少なくないのではないでしょうか。
そこで今回は、自分が実際に得た所見や気づきを交えながら、VContainer を中心とした設計について掘り下げてお話ししていきたいと思います。
MVP設計
今回はVContainerを用いたMVP設計を中心に解説していきます。
MVPは主にUI実装を対象としたアーキテクチャのため、この記事ではアウトゲーム領域を中心に扱います。
インゲームにおけるVContainerの活用については、私自身まだ設計経験がないため、今回は触れずに進めさせてください。
なおインゲームに関しては、以前VContainerを使って リバーシ(オセロ)のサンプル実装を行った際の記事がありますので、併せて参考にしていただければ嬉しいです!
MVP設計とは
MVPとは、Model-View-Presenterの略称です。
ざっくり図にするとこのような関係になります。
| 名称 | 説明 | 例 |
|---|---|---|
| Model | データやそれに関連するロジック担当 | プレイヤーの情報を取得して、必要に応じてデータを保持 |
| View | UI | プロフィール画面 |
| Presenter | ModelとViewを仲介する役割 | Modelからプレイヤー名を取得して、プロフィール画面に反映する |
私の設計では、PresenterはModelとViewの橋渡しに加えて、Viewの表示・非表示や遷移といった制御も担います。
またUnityではSceneが処理の起点になるため、Sceneを管理するクラスもPresenterとして扱います。
詳細は後ほど説明しますが、LifetimeScopeの生成まわりもPresenterが担当します。
アセンブリ分割
ModelとViewは互いに直接参照できないようにしておくと、設計の崩壊を防ぐことができます。
そのため、以下のようにAssemblyDefinitionで分割しておくことをおすすめします。
矢印の向き先が、それぞれの参照しているアセンブリです。
Commonという新しい概念がでてきました。
Commonアセンブリ
ModelとViewは直接参照しない設計にする一方で、両者が共有して扱う「共通定義」が必要になることがあります。
そこで、これらの共通要素をまとめておくための領域としてCommonを用意するのが有効です。
Commonには 具体的なclassの実装は置かず、インターフェースやenumなど「定義のみ」を配置します。
もし実体となるclassをCommonに置いてしまうと、View側からデータを書き換えられてしまったり、Viewが特定の実装に依存してしまうなど、設計が崩れやすくなります。
Viewに必要なデータはインターフェースとして定義しておき、その実装はModel側で自由に行うようにすることで、依存方向が明確で健全な構造を維持できます。
Viewに必要なデータをどう渡すか
MVP設計としては、Viewが必要とするデータを定義し、PresenterがModelのデータをView用に加工して渡すのが基本的な流れです。
ただし、状況によってはこの加工処理が単調で手間ばかり増えることがあり、あまり効率的ではありません。
そのため、場合によってはModel側のデータにView用のインターフェースを実装し、Presenterの処理を最小限にする方法も有効です。
画面ごとにどちらの方法が適しているかを判断して使い分けるのが良いと考えています。
DI(Dependency Injection)の何がいいのか
ここまでMVP設計について触れてきましたが、VContainer を用いてDI(依存性注入)を導入することで得られる最大のメリットは何でしょうか。
一般的には以下がよく挙げられます。
-
Model のテストが容易になる
- 依存関係を切り離せるため、ユニットテストが書きやすくなる
-
インターフェースによる実装の差し替えが柔軟になる
- クラス同士の密結合を避けられるため、挙動の切り替えやリファクタリングが行いやすくなる
こうしたメリットももちろん重要ですが、実際の業務で VContainer を使っていて最も実感したのは、「必要な場所で、必要な依存を、迷わず安全に利用できる」 という点です。
必要なところで必要なものを使える
VContainer を導入していない MVP 設計のプロジェクトで開発していたとき、よく次のような状況に陥っていました。
「あ、この処理には別のクラスが必要だな」
「じゃあコンストラクタに引数を追加して…」
「あれ、このクラスを呼んでいる側もその依存を持っていないぞ」
「じゃあそっちの引数にも追加して…」
「必要なクラスはどこで生成されてるんだっけ?…ああ、ここか」
「やっと準備できた…え、そもそも何を実装しようとしてたんだっけ?」
このように、実装よりも依存関係の捜索と配線に時間が取られ、なかなか生産的とは言えない状態になっていました。
しかし、VContainer を導入して LifetimeScope で依存関係を明確に管理するようにしてからは状況が一変しました。
必要な依存が増えたとしても、コンストラクタに引数を1つ追加するか、プロパティやフィールドに [Inject] を付けるだけで準備が整うため、余計な配線作業に気を取られることがなくなります。
結果として、依存を探し回ったりコンストラクタの伝搬に悩まされることが減り、本来注力すべきロジックの実装に集中できるようになりました。
機能や役割を分割しようという気持ちになる
機能や役割を適切に分割することは、設計において重要です。
VContainer導入前は、この分割作業が手間に感じられ、「一つのPresenterやModelにまとめてしまえばいい」と処理を集約しがちで、結果としてクラスが肥大化していきました。
一方、VContainerがあれば、LifetimeScopeに登録しておき、必要な箇所でInjectするだけで依存関係を扱えます。
そのため、機能分割に対する心理的な負担が減り、コードをクリーンな状態で維持しやすくなります。
VContainerでMVP設計を進めるうえで欠かせないライブラリ
次の内容に入る前に、まずはVContainerを使ってMVP設計を行う際に、ぜひ押さえておきたいライブラリを紹介します。これらを組み合わせることで、より実践的でスムーズなアーキテクチャを構築できるようになります。
R3(UniRx)
ViewのイベントをPresenterに通知するときや、Modelのデータ変更のイベントをPresenterで受け取るために外せないライブラリです。
MessagePipe
イベントを柔軟に扱えるようにするライブラリです。
後ほど詳しく説明します。
VContainerを用いたMVP設計のおさえどころ
ここからは、VContainerを使ってMVP設計を行う際に「ここだけ押さえておけば十分」 と言える重要なポイントを紹介します。
LifetimeScope
個人的に、LifetimeScopeはVContainerの中核そのものだと思っています。ここでは、その要点をシンプルにまとめていきます。
登録と解決
VContainerの基本はとてもシンプルで、
必要なクラスをLifetimeScopeでRegisterし、使いたい場所でResolveして利用する 、これに尽きます。
初めて触れたときは、機能が多くて「難しそう」と感じたり、どこから手をつければいいのか迷うこともあるかもしれません。しかし、根本的な流れはとてもシンプルで、RegisterしてResolve という基本さえ押さえておけば問題ありません。
RegisterやResolveにはいくつかの書き方がありますが、ここではMVP設計で最低限覚えておくだけで十分なポイントに絞って紹介していきます。
Resolving
コンストラクタインジェクション
public class InventoryModel
{
private readonly INetwork _network;
private readonly IItemMaster _itemMaster;
[Inject]
public InventoryModel(
INetwork network,
IItemMaster itemMaster
)
{
_network = network;
_itemMaster = itemMaster;
}
}
プロパティ・フィールドインジェクション
public class InventoryModel
{
[Inject]
private readonly INetwork _network;
[Inject]
private readonly IItemMaster _itemMaster;
}
テストを重視するなら、基本的には コンストラクタインジェクション を採用するのが理想です。依存関係が明確になり、モックの差し替えもしやすく、ユニットテストの品質が向上します。
とはいえ、個人的にはプロパティ・フィールドインジェクションの方が書きやすく、実装もスムーズ だと感じています。実際の開発では、以下のように役割ごとに使い分けるのも十分アリです。
-
Model:コンストラクタインジェクション
→ テスト対象になる機会が多く、依存関係を明確にしたい層 -
Presenter:プロパティ・フィールドインジェクション
→ テストを行うことが少なく、実装の書きやすさ・柔軟さを優先してもよい層
このように、層の責務やテスト方針に合わせてインジェクション方式を選ぶことで、バランスの取れた構成にできます。
Registering
基本的なRegister
一番基本的なRegister
builder.Register<Network>(Lifetime.Singleton);
Networkは公開せずに、そのインターフェースのみ公開したい場合
builder.Register<Network>(Lifetime.Singleton).As<INetwork>();
NetworkとINetworkも公開したい場合
builder.Register<Network>(Lifetime.Singleton).AsSelf().As<INetwork>();
RegisterEntryPoint
基本的に、PresenterはMonoBehaviourを継承しない純粋なC#クラスとして実装されることがほとんどです。しかし、場合によってはStartやUpdateといったUnityのイベント関数が必要になる場面もあります。
そのような場合に便利なのがRegisterEntryPointです。Presenterのような通常のC#クラスでも、Unityライフサイクルにフックした処理を記述できるようになります。
詳細な使い方については、公式ドキュメントをご参照ください。
Lifetime
Registerの引数に、
- Lifetime.Singleton
- Lifetime.Scoped
- LifeTime.Transient
のいずれかを指定することが出来ます。
詳細は公式のドキュメントをご参照ください。
明確な意図がない場合は、Singletonを指定することを推奨します。
IDisposableを実装している場合、LifetimeScopeの破棄時にRegisterされたインスタンスは自動的にDisposeが呼ばれます。ただしTransientは自動でDisposeされないため、取り扱いには注意が必要です。
ModelとPresenterでInject
Modelには、Modelアセンブリから参照可能な依存関係のみをInjectします。Presenterには他のPresenterに加えてModelをInjectし、Viewの中では原則としてInjectは行いません。
VContainerのドキュメントでもViewのMonoBehaviourへのInjectは基本的に推奨されておらず、必要な場合にのみ限定してInjectする方針が示されています。
MonoBehaviourへロジックへの参照をインジェクトするよりは、MonoBehaviourを参照をインジェクトされる側とすることをどちらかというと推奨しています。
LifetimeScopeの分割
概要
LifetimeScopeの分割については、意外と詳しく触れられる機会が少ないと感じています。
すべてを一つのLifetimeScopeに集約すると、本来は破棄できるはずの状態やインスタンスまでメモリ上に残り続け、想定していない挙動につながる可能性があります。
機能単位で適切にLifetimeScopeを分割できれば、機能が閉じられたタイミングでそのLifetimeScopeをDisposeするだけで、登録されているインスタンスもまとめて破棄できます。これにより不要なインスタンスを確実に削除し、状態の持ち越しを防ぎやすくなります。
(ただし、IDisposableを実装していること、かつLifeTime.Transientで登録していないことが前提です)
文章だけではイメージしづらい部分もあると思うので、よくあるソーシャルゲームのホーム画面を例に、LifetimeScopeを分割した構成図を作成しました。
AppLifetimeScopeはアプリ起動中ずっと存在します。一方で、それ以外のLifetimeScopeは「必要なタイミングで生成され、不要になったら破棄される」ものであり、図に描かれたすべてが常に同時に存在しているわけではありません。
ギフトボックスやインベントリーを閉じた時点で、内部で保持していたリストデータや状態はHomeSceneでは不要になります。そこで、これらの機能はHomeSceneとは別LifetimeScopeとして切り出し、画面を閉じるタイミングでLifetimeScopeごとDisposeできるようにします。
また、ギフトボックスやインベントリーからアイテム詳細画面を開くケースもよくありますが、単一アイテムの表示に必要な情報も、画面を閉じれば不要になります。アイテム詳細も同様にLifetimeScopeを分割しておくことで、閉じた瞬間に関連インスタンスをまとめて破棄できます。
さらにLifetimeScopeが入れ子になっている場合、子のLifetimeScopeから親で登録されたインスタンスをInjectして利用できます。
(親でLifetime.Scopedで登録されたものは、子で改めて登録しなくても参照できます。ただし子側でInjectした場合は、親とは別インスタンスになります。)
例えばアイテム詳細からアイテムマスタへアクセスしたい場合、AppLifetimeScopeにマスタを登録しておけば、アイテム詳細側では登録せずにアイテムマスタをInjectで利用できます。
逆に、ギフトボックスのLifetimeScopeで登録したインスタンスはインベントリー側でInjectできません。機能間の不要な参照を自然に遮断できる点も、LifetimeScopeを分割するメリットです。
実装方法
私の設計ではLifetimeScopeの生成をPresenterの責務として扱っています。
以下は、ホーム画面からギフトボックスを開いた際の、LifetimeScope生成の簡単な例です。
using VContainer;
using VContainer.Unity;
public class HomePresenter
{
[Inject]
private readonly LifetimeScope _parentScope;
private void OpenGiftbox()
{
var scope = _parentScope.CreateChild<GiftboxLifetimeScope>(installer =>
{
installer.RegisterBuildCallback(resolver =>
{
var giftboxPresenter = resolver.Resolve<GiftboxPresenter>();
giftboxPresenter.OpenGiftbox();
});
}, "GiftboxLifetimeScope");
}
}
HomeSceneのLifetimeScopeにHomePresenterが登録されている場合、HomePresenterでLifetimeScopeをInjectすると、そのHomeSceneのLifetimeScopeが渡されます。
この親スコープを起点にCreateChildで子のLifetimeScopeを生成し、生成したスコープ内でResolveを行うことで、登録されたインスタンス(この例ではGiftboxPresenter)を取得して処理を開始できます。
なお、今回のコードはあくまで説明用の簡略例です。実際の設計では、GiftboxPresenter側でLifetimeScopeを生成するなど、別の責務分担にしても問題ありません。
長くなってしまいましたが、以上がLifetimeScopeの説明になります。
MessagePipe(Pub/Sub)
ギフトボックスでアイテムを受け取ったときのことを例に考えていきます。
(あくまで一例なので、MessagePipe以外の良いアプローチも存在します)
GiftboxModelで受け取り通信を行い、アイテムの所持数が変化したことを他へ伝えるには、どうするのが適切でしょうか?
- GiftboxModelをInjectしているGiftboxPresenter側で、通知したいPresenterやModelもInjectして直接伝える
- GiftboxModel側でR3(UniRx)を使って変更通知を発行し、通知を受け取る必要があるPresenterやModelがGiftboxModelをInjectして購読する
ただ、どちらの方法でも依存関係が強くなりやすく、スコープをまたいだ通知が難しいという問題があります。
そこでMessagePipeが有効です。
MessagePipeを使えば、GiftboxModelは「アイテム所持数が変化した」というイベントを発行するだけでよく、「誰に届けるべきか」を考える必要がありません。逆に受け取る側もGiftboxModelを知る必要がなく、疎結合のまま通知を扱えるようになります。
VContainerでの導入方法については、MessagePipeの公式ドキュメントに記載されているため、そちらをご参照ください。
Factory
デザインパターンでよく取り上げられるFactoryパターンも、VContainerと相性が良いです。FactoryクラスをLifetimeScopeに登録しておけば、Factory内で必要な依存関係をInjectして利用できるようになります。
一方で、データクラスのようなものはLifetimeScopeに登録せず、必要なタイミングで生成して使うことが多いと思います。ただ、各所でバラバラに生成してしまうと、生成に必要な依存関係の解決が難しくなったり、生成ルールが散らばって管理しづらくなったりするケースがあります。
次の失敗例を題材に考えてみましょう。
実装初期は、アイテムデータクラスについて「必要なデータをアイテムマスタから取得し、それをコンストラクタに渡して生成する」という方針で進めていました。
実装が進むにつれ、インベントリーやアイテムショップなど、さまざまな機能で同じアイテムデータクラスを使うようになりました。そんな中、「アイテムの入手可能な場所を表示したい」という仕様が追加され、アイテムデータにもその情報を持たせる必要が出てきました。
追加の情報を渡すために、アイテムデータのコンストラクタ引数を増やすことになります。しかし、そのコンストラクタを呼び出している箇所は100箇所…。
各所で追加データを用意して渡す処理を都度書いていった結果、それだけで1日が終わってしまいました。
アイテムデータを各所で直接生成せず、生成処理をFactoryに集約していれば、修正だけで1日が終わる事態は避けられたはずです。
たとえば、アイテムデータの生成にアイテムマスタだけでなく入手経路マスタも必要になったとしても、FactoryがそれらをInjectして参照すればよく、呼び出し側の修正は最小限で済みます。
また、Factoryを使う側が渡す情報を最小限(アイテムIdなど)にしておけば、アイテムデータに新しい情報を追加することになったとしても、対応箇所はFactoryの1か所だけで完結します。結果として、改修コストや影響範囲を大きく抑えられます。
ただし、何でもかんでもFactory経由にすると、実装が回りくどくなって効率が落ちる場合もあります。
そのため、初期実装の段階でFactoryとして切り出すべき生成処理かどうかを見極めることが重要です。
以上がVContainerを用いたMVP設計のおさえどころの内容になります。
最後に設計で大事だと思うこと
Unityでの開発に携わって7年目になりますが、この記事を書いている時点でも「完璧で穴のない設計」のプロジェクトに出会ったことはありません。
だからこそ、新しく何かを作るたびに過去の反省を活かし、より良い設計を考えながら、悪い設計を可能な限り減らしていくことが大切だと感じています。
またVContainerやMVPのような設計手法は、開発初期に取り入れておくことが重要です。途中から導入しようとすると、既存コードとの整合を取ったり責務を切り直したりする必要が出てきて、結果的に断念せざるを得ないこともあります。
新規アプリ開発の初期フェーズは、その後の拡張性や保守性を左右する、将来の設計を決める非常に重要な段階だと言えます。
また、過度に複雑な設計は避けるべきだとも感じています。
シンプルで柔軟性のある設計を心がけ、自分だけでなくチームのエンジニア全員が納得して開発を進められる環境を作ることも大切です。
設計は奥が深く、定年を迎えるまでに「これが100%正解だ」と言い切れる設計に到達できる気は正直しません。
それでも地道に経験を積み重ねながら、より良い設計ができるエンジニアを目指していきたいです。


