Unity中規模プロジェクトに『ちょうどいい設計』を入れる — MVVM × R3 × Extenject
Unity中規模プロジェクトに『ちょうどいい設計』を入れる — MVVM × R3 × Extenject
Unityでデザインパターンが必要になる瞬間
Unityはゲームやインタラクティブアプリの入り口を限界まで下げてくれる優れた環境ですが、それは『動くものをすぐ作れる』という話にとどまり、コードベースが育ったときの設計問題は別レイヤーとして残り続けます。プロトタイプ段階では1枚のMonoBehaviourに状態管理・入力処理・UI更新・通信処理をまとめても問題なく動きますし、その方が速度も出ます。
破綻が顕在化するのは、たいていシーン数が二桁を超え、QAから機能修正依頼が並行で飛んでくるあたりからです。Updateに書かれた条件分岐がねずみ算式に増え、シーン切替で参照が切れてヌルポが頻発し、テストを書こうにもMonoBehaviourと密結合していて単体で動かせない。このタイミングで初めて、設計パターンを『あとから』入れる議論が始まります。
ここで重要なのは、設計パターンは『導入する/しない』の二択ではないという点です。プロジェクト規模・チーム人数・想定運用期間に対して、どの程度の抽象度を持ち込むかという『程度』の判断になります。
クリーンアーキテクチャやオニオンパターンは万能ではない
サーバサイドで実績のあるクリーンアーキテクチャやオニオンアーキテクチャは、依存関係の方向を内向きに固定しドメインを最内層に置くことで長期保守性を確保する強力な手法です。ただしUnityの中小規模プロジェクトに『丁寧に』適用しようとすると、ほぼ確実にオーバーエンジニアリングに転びます。
理由は単純で、Unityのコードの大半は『フレームに乗ったUI更新』と『シーン上のオブジェクト操作』が占めるからです。UseCase / Repository / Entity / Gatewayといった層を律儀に切り出しても、ドメインロジックが薄いプロジェクトでは形骸化したインタフェースが量産され、ファイル間ジャンプばかり増えて保守性はむしろ下がります。
層を厚く積むのが効くのは、ドメインルールが豊かで、UIやインフラと独立に成長させたい場合です。逆にUnity案件の多くはUIとドメインがほぼ同じ厚みなので、抽象度はもう一段下げた方が現実的です。
MVVMをUnityで採用する理由
弊社が中小規模のUnity案件で多く採用しているのは、Model / View / ViewModelの3層に絞ったMVVMパターンです。各層の責務は次のとおりです。
- Model: アプリのデータと、それに対する純粋なロジック。Unity APIには依存させません。
- ViewModel: Viewに表示するための状態(公開プロパティ)と入力に応じた状態遷移を持つ層。MonoBehaviourに依存させません。
- View: MonoBehaviour側のクラス。ViewModelの状態を購読してUIに反映し、入力イベントはViewModelのメソッド呼び出しに変換するだけに留めます。
これだけでも、Updateに詰め込まれていた条件分岐はViewModel側の状態遷移に整理され、Viewは『見た目を作る』ことだけに専念できます。ModelとViewModelはPure C#なので、PlayModeを起動せず通常のNUnitテストで検証できる点は実務上のインパクトが大きいです。CI上でも数秒で回るため、状態遷移のリグレッション検出が現実的になります。
抽象度はクリーンアーキテクチャより一段低く、素のMonoBehaviourの密結合よりは明らかに高い。中小規模ではこの『中庸』が割に合います。
R3とExtenjectでMVVMを実装する
UnityでMVVMを実装する具体的な道具立てとして、弊社ではR3(旧UniRxの後継)とExtenject(Zenjectの後継フォーク、DIコンテナ)を組み合わせて使うことが多いです。
R3はリアクティブプログラミングのライブラリで、ReactiveProperty<T>やObservable<T>をViewModelの公開プロパティとして使います。Viewはそれを購読してUIを更新します。
// ViewModel 側
public ReactiveProperty<int> Score { get; } = new(0);
// View 側 (MonoBehaviour)
viewModel.Score
.Subscribe(v => scoreText.text = v.ToString())
.AddTo(this);
R3はUniRxの設計思想を継承しつつ、Observableの抽象クラス化、IScheduler→TimeProvider、フレーム処理用のFrameProvider抽象などを導入して.NET 8 / C# 12世代向けに再設計されたライブラリで、メモリアロケーションやパフォーマンス特性が改善されています。新規採用ならR3を選び、既存プロジェクトがUniRxの場合はエイリアス互換層を活用しながら段階移行する判断が現実的です。
ViewModelとModelの依存解決はExtenjectに任せます。UnityではSceneContext(シーン単位)、ProjectContext(アプリ全体)、GameObjectContext(特定のGameObject配下)という3種のスコープを使い分けることで、ライフタイムが暴れにくくなります。InstallerでContainer.Bind<IFooService>().To<FooService>().AsSingle()のように束ねればViewModelのコンストラクタに依存を渡せるため、テスト時はモックやStubに差し替え可能です。
採用判断のトレードオフも添えておきます。Extenjectはランタイムリフレクションを多用するため、IL2CPPビルドではAOT周りの罠(型登録の取りこぼしなど)が稀に発生します。チームにDI経験者が薄い、あるいは超小規模プロトタイプであれば、ServiceLocatorや手書きのコンストラクタ注入でも十分です。R3についても、シンプルなUIイベントしか扱わない画面ではC#のeventで事足ります。『どこまでリアクティブにするか』は状態の種類と数で判断するのが実務的です。
中規模以上で運用期間が年単位、複数人で触る前提があるなら、MVVM × R3 × Extenjectの組み合わせは費用対効果が高い選択肢です。逆にハッカソンや短命プロトには、これらを入れる前にまずスコープを絞ることをおすすめします。
弊社ではUnityのC#実装・システム設計・パフォーマンス最適化を成果物単位で承っています。既存プロジェクトに後付けでMVVMを入れたい、R3 / Extenjectの導入で詰まっている、といったご相談にも対応しているので、設計の一次相談だけでもお気軽にどうぞ。