見出し画像

VContainer入門 – MVPパターンを組んでみる

今回は Unity の新しいDIライブラリ「VContainer」についてまとめてみました。

設計思想として、MVP (Model-View-Presenter) パターンというものをご存じでしょうか?
完結に一言で述べると、「Viewと Modelが直接やり取りせず、すべて Presenter 経由でのやり取りを行う」という設計パターンです。View と  Model が直接やり取りせず、すべて resenter 経由でのやり取りを行う設計パターンです。
View、Model、Presenter はそれぞれ下記のような役割を持ちます。

View:描画ロジックを持ち、渡されたデータを描画する。
Presenter:ユーザの入力を受け、具体的なアクションを実行する。データを Model から取得し、View が扱える形に加工して描画情報として渡す。
Modelビジネスロジックとデータ保持を担う。必要に応じて Presenter へ変更通知を送る。

MVPパターンの実装において、DI(Dependency Injection)も合わせて活用すると、よりクリーンな設計をもたらします。

DI(Dependency Injection)、依存性の注入

DIとは直訳すると依存性の注入を指します。
理解するために、先ほどのMVPパターンを見ていきます。

画像1

上記クラス図のように、
・View は Model を知らない
・Model は View を知らない
・Presenter は View と Model に依存している
という状態です。

この View と Model がお互いを知らない状況により、View を変更しても Model に変更が及ぶことはなく、逆もまた同様です。
このお互いを知らないという状況が生み出す柔軟さがMVPパターンの強みです。

ここからさらに話を発展させます。

仮に、この図の状態から
・Model のテストを任意のタイミングで行いたい
・状況に応じて Model を別の Model に置き換えたい
となった場合はどうでしょう。View に変更は及ばないので安心ですが、毎回 Presenter を書き換えるのは大変です。

これは、密結合という状態です。
Presenter が Model に依存しているため、Model の変更は Presenter に影響を及ぼします。

この状況を打開するには疎結合な設計が必要です。
今回の場合、疎結合により生み出したい状況は
・Presenter が Model を知らない(依存関係にない)
という状況です。

Presenter が Model に依存しない作りを実現するために、Interface を実装します。

クラス図で表すと下記です。

画像2

Presenter と Model の間に Interface が介入することで、Presenter と Model の依存関係を解消することができました。

しかし、ここである問題が発生します。
Presenter に Interface を渡すコードが必要となります。
しかし、そのコードが Model に依存してしまうので本末転倒です。
“さらに Interface を作って疎結合” にしてもまたその Interface を渡すコードが必要です。

これでは無限ループなので、誰かが必ず密結合を背負わなければなりません。そこで活躍するのがDIコンテナです。

DIコンテナはクラス間の依存関係を取り除いて肩代わりするフレームワークです。
利用することでプログラム実行時に、状況に応じて正しい処理(クラスのインスタンス)を割り当ててくれます。

DI周りをサポートしてくれるライブラリ「VContainer」

ここで登場するのが、タイトルにもなっている「VContainer」です。
DIコンテナですが、自前で実装するのはかなりの労力を使います。
言葉にするよりはるかに複雑で、プロジェクトごとにDIコンテナを自前で用意するくらいなら、諦めて密結合なコードと付き合っていく方が幸せになれる場合もありそうです。

そこで、そのややこしいDI周りをサポートする「VContainer」というライブラリを紹介します。

VContainer は、これまで Unity のDIライブラリとして活躍してきた Extenject(旧 Zenject) と比較して、軽量で機能を絞ったライブラリとなっています。

今回は、MVPパターンにDIを適用するサンプルを Extenject と VContainer のそれぞれで試していきます。

バージョン情報
・Unity 2019.4.8f1
・VContainer.1.4.3
・Extenject 9.2.0
・UniRx 7.1.0

Extenject で実装してみる

まずは Extenject で実装してみます。
サンプルは「マウスクリックを検知して Text を更新する」という内容で実装します。

下記クラス図の通りの依存関係で実装します。

画像3

Interface
それではコードを見ていきます。
まずは Model と Presenter を繋ぐ Interface です。

using UniRx;

/// <summary>
/// ModelとPresenterを繋ぐInterface
/// </summary>
public interface IInputModelInterface
{
   /// <summary>
   /// 値の監視に利用
   /// </summary>
   IReadOnlyReactiveProperty<string> InputTypeObservable { get; }

   /// <summary>
   /// 値の発行に利用
   /// </summary>
   void PublishValue();
}

Model
次に Model の実装です。先ほどの Interface を実装します。

using UniRx;
using UnityEngine;

/// <summary>
/// Editor上で使う入力Model
/// </summary>
public class EditorInputModel : IInputModelInterface
{
   /// <summary>
   /// 購読機能のみ外部に公開
   /// </summary>
   public IReadOnlyReactiveProperty<string> InputTypeObservable => inputType;
   private StringReactiveProperty inputType = new StringReactiveProperty();
   
   /// <summary>
   /// 値の発行(データの書き換え)
   /// </summary>
   public void PublishValue()
   {
       inputType.Value = Input.GetMouseButton(0) ? "Click" : "No Input";
   }
}

View
次に、View を担うクラスです。
ここではテキストコンポーネントに文字列を設定するだけの処理を記述します。

using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// View
/// </summary>
public class TestView : MonoBehaviour
{
   [SerializeField] private Text _text;

   /// <summary>
   /// テキストをセットする
   /// </summary>
   /// <param name="t">受け取った文字列</param>
   public void SetText(string t)
   {
       _text.text = t;
   }
}

Presenter
View と Interface を繋ぐ Presenter です。MonoBehavior は継承していません。

using UniRx;
using UniRx.Triggers;

/// <summary>
/// Extenject用Presenterクラス
/// </summary>
public class ExtenjectTestPresenter
{
   private IInputModelInterface _inputModelInterface;
   private TestView _testView;

   //コンストラクタインジェクション
   public ExtenjectTestPresenter(IInputModelInterface inputModelInterface, TestView testView)
   {
       _inputModelInterface = inputModelInterface;
       _testView = testView;

       //値の監視
       _inputModelInterface.InputTypeObservable
           .Subscribe(inputType => { _testView.SetText(inputType); });

       //入力を検知したら値を発行
       _testView.UpdateAsObservable().Subscribe(_ => { _inputModelInterface.PublishValue(); });
   }
}

Installer
最後に Installer です。Installer はDIコンテナとしての役割を担います。

using UnityEngine;
using Zenject;

/// <summary>
/// Extenject用のContainerクラス
/// </summary>
public class TestInstaller : MonoInstaller
{
   [SerializeField] private GameObject _testView;
   
   public override void InstallBindings()
   {
       Container.Bind<IInputModelInterface>().To<EditorInputModel>().AsCached();
       Container.Bind<TestView>().FromComponentOn(_testView).AsCached();
       Container.Bind<ExtenjectTestPresenter>().AsCached().NonLazy();
   }
}

VContainer で実装してみる

続いて、VContainer で実装してみます。
導入は非常にシンプルで、ドキュメントにていねいな解説が載っています。

サンプルは Extenject の際と同様に、「マウスクリックを検知して Text を更新する」という内容です。

下記クラス図の通りの依存関係で実装します。

画像4

Interface
Interface は Extenject の際と同様です。

using UniRx;

/// <summary>
/// ModelとPresenterを繋ぐInterface
/// </summary>
public interface IInputModelInterface
{
   /// <summary>
   /// 値の監視に利用
   /// </summary>
   IReadOnlyReactiveProperty<string> InputTypeObservable { get; }

   /// <summary>
   /// 値の発行に利用
   /// </summary>
   void PublishValue();
}

Model
Model も Extenject の際と同様です。

using UniRx;
using UnityEngine;

/// <summary>
/// Editor上で使う入力Model
/// </summary>
public class EditorInputModel : IInputModelInterface
{
   /// <summary>
   /// 購読機能のみ外部に公開
   /// </summary>
   public IReadOnlyReactiveProperty<string> InputTypeObservable => inputType;
   private StringReactiveProperty inputType = new StringReactiveProperty();
   
   /// <summary>
   /// 値の発行(データの書き換え)
   /// </summary>
   public void PublishValue()
   {
       inputType.Value = Input.GetMouseButton(0) ? "Click" : "No Input";
   }
}

View
View に関しても Extenject の際と同様です。

using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// View
/// </summary>
public class TestView : MonoBehaviour
{
   [SerializeField] private Text _text;

   /// <summary>
   /// テキストをセットする
   /// </summary>
   /// <param name="t">受け取った文字列</param>
   public void SetText(string t)
   {
       _text.text = t;
   }
}

Presenter
View と Interface を繋ぐ Presenter です。

using UniRx;
using UniRx.Triggers;
using VContainer.Unity;

/// <summary>
/// VContainer用Presenterクラス
/// IPostInitializableを実装することでライフサイクルイベントを与えることができる
/// </summary>
public class VContainerTestPresenter:IPostInitializable
{
   private readonly IInputModelInterface _inputModelInterface;
   private readonly TestView _testView;

   //コンストラクタインジェクション
   public VContainerTestPresenter(IInputModelInterface inputModelInterface, TestView testView)
   {
       _inputModelInterface = inputModelInterface;
       _testView = testView;
   }

   /// <summary>
   /// 初期化直後に呼ばれる
   /// </summary>
   public void PostInitialize()
   {
       //値の監視
       _inputModelInterface.InputTypeObservable
           .Subscribe(inputType => { _testView.SetText(inputType); });

       //入力を検知したら値を発行
       _testView.UpdateAsObservable().Subscribe(_ => { _inputModelInterface.PublishValue(); });
   }
}

IPostInitializable というインターフェースを継承しています。
IPostInitializable に実装されている PostInitialize メソッドの中で、Model と View を繋ぐ処理を行っています。

VContainer には Extenject 同様にライフサイクルイベントが実装されており、継承している IPostInitializable もそのうちの一つです。

このライフサイクルイベントによって、どのタイミングで Resolve する(要求された型のインスタンスを渡す)かを制御することができます。
すなわち任意のタイミングでエントリーポイントとして使用可能です。

ライフサイクルイベントについてはドキュメントに一覧があります。

LifeTimeScope
さらにDIコンテナの役割を持つ LifeTimeScope においても違いが出ます。
Extenject で言うところの Installer です。

using UnityEngine;
using VContainer;
using VContainer.Unity;

/// <summary>
/// VContainer用のContainerクラス
/// </summary>
public class TestLifetimeScope : LifetimeScope
{
   protected override void Configure(IContainerBuilder builder)
   {
       builder.Register<IInputModelInterface,EditorInputModel>(Lifetime.Scoped);
     
       builder.RegisterComponentInHierarchy<TestView>();
       //VContainerTestPresenterはどこからもResolveされないので明示的にエントリーポイントとする
       builder.RegisterEntryPoint<VContainerTestPresenter>(Lifetime.Scoped);
   }
}

Extenject では Interface を Bind して任意の実体へと流し込む処理は下記のように書きました。

Container.Bind<IInputModelInterface>().To<EditorInputModel>().AsCached();

VContainer においては

builder.Register<IInputModelInterface,EditorInputModel>(Lifetime.Scoped);

と書きます。

この辺りの対応表はドキュメントに詳細に記載があるのでとても助かりました。

MonoBehaviour に関するDIはやり方が複数パターン存在します。
ドキュメントの下記ページが参考になりました。

VContainer には NonLazy の役割を持つ機能は存在しません。
“どこからも依存されていないクラス”のコンストラクタで副作用を実装することを避けるためのようです。
ですので、NonLazy と同様の処理を行いたい場合は、Initializable 等のライフサイクルイベントを実装したクラスを RegisterEntryPoint で登録し、Resolve するという流れが推奨されています。

サンプルアプリを確認

どちらも意図した挙動となりました。

画像5

Modelを切り替えてみる

では、DIらしいことをしてみましょう。
サンプルコードの Model は人間からの入力に応じたロジックを引き受けています。
これをエディタ上と実機での Model が自動的に切り替わるようにしてみましょう。
これまでの疎結合な実装で簡単に実現可能になっているはずです。
有用性はどこにあるかというと、実行環境がエディタ上と実機上で異なる場合などに役立ちます。

例えば、スマートフォンでのタップ入力やVRデバイスのハンドトラッキングでの入力などです。
Unity の新しい Input System でもここまでのことはできますが、ビジネスロジックも含めてとなると、そうはいかないと思います。

また、マルチプラットフォームなアプリ制作においても、とある機能だけ切り離して状況に応じて切り替える、という実装は開発効率を高める意味で大きな力を発揮します。

具体的にクラス図に落とし込むと下記のようになります。

画像6

こちらの実装をVContainerの力を借りて実装します。

Model
それではまず追加したコードを見ていきます。
新しく増えた、実機上で使用する Model クラスです。

using UniRx;
using UnityEngine;

/// <summary>
/// Device上で使う入力Model
/// </summary>
public class DeviceInputModel : IInputModelInterface
{
   /// <summary>
   /// 購読機能のみ外部に公開
   /// </summary>
   public IReadOnlyReactiveProperty<string> InputTypeObservable => inputType;
   private StringReactiveProperty inputType = new StringReactiveProperty();
   
   /// <summary>
   /// 値の発行
   /// </summary>
   public void PublishValue()
   {
       inputType.Value = Input.touchCount > 0  ? "Touch" : "No Input";
   }
}

LifeTimeScope
さていよいよ依存性注入の切り替えを実際に行う箇所に来ました。
LifeTimeScope の中で Application.isEditor の判定を利用し、注入先を切り替えています。

using UnityEngine;
using VContainer;
using VContainer.Unity;

/// <summary>
/// VContainer用のContainerクラス
/// </summary>
public class TestLifetimeScope : LifetimeScope
{
   protected override void Configure(IContainerBuilder builder)
   {
       if (Application.isEditor)
       {
           builder.Register<IInputModelInterface,EditorInputModel>(Lifetime.Scoped);
       }
       else
       {
           builder.Register<IInputModelInterface,DeviceInputModel>(Lifetime.Scoped);
       }
       
       builder.RegisterComponentInHierarchy<TestView>();
       //VContainerTestPresenterはどこからもResolveされないので明示的にエントリーポイントとする
       builder.RegisterEntryPoint<VContainerTestPresenter>(Lifetime.Scoped);
   }
}

デモを実行してみる

それでは実機で実行してみましょう。
Editor 上でクリックした際には Click、スマートフォン上でタップした際には Touch と表示されるようになりました

画像7

View や Presenter に変更を加えることなく Model の差し替えが実現しています。

おわりに

今回はMVPパターン、DIの基本的な理解、Extenject と VContainer でのそれぞれの実装の違いについてまとめました。

DIライブラリは使えば設計がきれいになるという魔法ではありません。
あくまで、設計をするのはエンジニア自身なので、設計について学ぶことも重要となるでしょう。

VContainer を使いながら設計も学びつつ、レベルアップしていきたいと思います。

この記事が気に入ったらサポートをしてみませんか?