はじめに

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

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

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


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


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

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

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

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

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

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

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

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

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

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

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

これでは無限ループなので、誰かが必ず密結合を背負わなければなりません。

そこで活躍するのがDIコンテナです。

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


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を更新するという内容で実装します。

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


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です。
導入は非常にシンプルで下記ドキュメントに丁寧に載っています。
【参考リンク】:Installation

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

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


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する(要求された型のインスタンスを渡す)かを制御することができます。
すなわち任意のタイミングでエントリーポイントとして使用可能です。

ライフサイクルイベントについてはドキュメントに一覧があります。
【参考リンク】:Plain C# Entry point


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);
と書きます。

この辺りの対応表はドキュメントに詳細に記載があるのでとても助かりました。
【参考リンク】:Comparing to Zenject

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

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


デモ

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


Modelを切り替えてみる

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

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

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

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

こちらの実装を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と表示されるようになりました。

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

おわりに

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

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

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