2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MVPパターンを「導出」する【Unity, R3】

2
Posted at

UnityにおけるMVPパターンの必要性を「導出」する

MVPパターンを最初に知ったとき、具体的にどう嬉しいのか腑に落ちなかった覚えがありました。
だんだん理解が深まってきたので、MVPパターンを「覚える」のではなく、なぜそうする必要があるのかを、神クラスから分離していくステップを踏んで、論理的に導出してみることにします。

Unity, C#, R3(またはUniRx)を知っていると理解しやすい。


ModelとViewは分けるべき

大前提として、ModelとViewを分離すべき理由を確認する。

Modelはゲームのルールや状態を表現する。プレイヤーのHP、所持金、インベントリの中身。これらは「UIがどう表示されるか」とは無関係に存在する概念。

Viewは画面上のUIコンポーネント、あるいはそれを制御するカスタムコンポーネントだ。TextMeshPro、Image、Slider。これらは「ゲームのルールが何か」を知る必要はない。実際、Unityが用意するButtonコンポーネントは、個々のゲームの仕様など知っている必要はない。

ユニットテストがどうとかの理由もあるが、Unityにおいては、Playerのふるまいはプレイヤーのオブジェクトについたコンポーネントにしたいし、UIのふるまいはUIのオブジェクトについたコンポーネントにしたいので、自然と分かれる。


誰がModelとViewを繋ぐのか?

ModelとViewを分けた。ではこの2つをどう連携させる?

実際のゲームでは、ModelとViewの関係は複雑だ。

[PlayerModel] ──┬── [HPBar]
                ├── [StatusPanel]
                └── [DeathEffect]

[InventoryModel] ──┬── [InventoryGrid]
                   └── [QuickSlot]

1対多、多対1、多対多の関係が普通に発生する。

この連携を誰に任せるかが問題になる。


失敗例① ― Model側に任せてみる

まずModelに連携の責務を持たせてみよう。

public class PlayerModel
{
    public int HP { get; private set; }
    
    // Modelが複数のViewを知っている
    private HPBar _hpBar;
    private StatusPanel _statusPanel;
    private DeathEffect _deathEffect;
    
    public void TakeDamage(int amount)
    {
        HP -= amount;
        
        // ModelがViewを直接更新
        _hpBar.SetValue(HP);
        _statusPanel.Refresh();
        if (HP <= 0) _deathEffect.Play();
    }
}

何が問題か?

  • Modelが具体的なView実装を知ってしまう
  • Viewが増えるたびにModelを修正
  • ユニットテストでView依存を排除できない
  • 「ゲームルールの実行」と「UI更新の指示」という2つの責務が混在

Modelは「HPが減った」というルールを実行するだけでいいはずなのに、「誰に通知するか」まで管理することになる。コンポーネントを分けたのに、結局ModelがViewを制御することになってしまっている。

失敗例② ― View側に任せてみる

ではView側に連携を任せるとどうなるか。

public class HPBar : MonoBehaviour
{
    // Viewが複数のModelを知っている
    private PlayerModel _player;
    private BuffModel _buff;
    
    void Update()
    {
        // ViewがModelを毎フレームポーリング
        int displayHP = _player.HP + _buff.HPBonus;
        SetValue(displayHP);
    }
}

何が問題か?

  • Viewがビジネスロジック(HPとBuffの合算)を持ってしまう
  • 毎フレームのアクセスは非効率
  • Viewの差し替え時にロジックも移植が必要
  • 「表示」と「データの解釈」という2つの責務が混在

Viewは「与えられた値を表示する」だけでいいはずなのに、「どこから値を取ってくるか」「どう計算するか」まで管理することになる。

依存を媒介するためのPresenter

そもそも、ModelとViewという主体しか存在しない場合、「ModelがViewに依存する」か、「ViewがModelに依存する」かのいずれかを選択せざるをえない状態に陥る。

ModelとViewを分けたところで、その「関連付け」をどちらか一方に任せると、その側が本来の責務を超えた仕事を持ってしまう。

  • Modelに任せると → ModelがViewの管理責務を持つ
  • Viewに任せると → Viewがロジックの解釈責務を持つ

つまり、この両者が互いに押し付けたがっている責務を担ってくれる、関連付けそのものを責務とする第三者が必ず必要となる。

この役割を担うクラスを、MVPパターンにおいてはPresenterと呼ぶ。

public class PlayerPresenter : MonoBehaviour
{
    [SerializeField] private PlayerModel _model;
    [SerializeField] private HPBar _hpBar;
    [SerializeField] private StatusPanel _statusPanel;
    [SerializeField] private DeathEffect _deathEffect;
    
    void Start()
    {
        // ModelのイベントをViewの更新に変換
        _model.HP
            .Subscribe(hp =>
            {
                _hpBar.SetValue(hp);
            })
            .AddTo(this);
    }
}

そして、もともとModelとViewの依存性を断ち切りたいのだから、Presenterがすべての依存を背負うのが道理となる。これはR3などを使えば実際に可能。

ModelとViewを関連づける専門のインスタンスが存在することで、Model, Viewの責務が純粋になり、お互いに依存しなくなる。極端な話、ModelとViewのアセンブリを分けても問題なくなるし、HPBarではなく単に"ValueBar"としても何の問題もない。

要素 責務 知っていること
Model ゲームルールの実行、状態管理 自分の状態だけ
View 受け取った値の表示 表示方法だけ
Presenter ModelとViewの関連付け Model, Viewのpublic部分
(interface)

ここまでで、ModelとViewが互いに依存しない、MVPパターンが無から再発明された。

おまけ: Observable/Observerとしての解釈

MVPにおけるデータの流れを整理する。

Model <──( Subscribe )── Presenter ──( UI操作 )──> View

Modelの役割は、状態が変わったら「変わった」と通知すること。誰が聞いているかは知らない。

public class PlayerModel : MonoBehaviour
{
    [SerializeField] private SerializableReactiveProperty<int> _hp = new(100);
    public ReadOnlyReactiveProperty<int> HP => _hp;
    
    public void TakeDamage(int amount)
    {
        _hp.Value -= amount;  // 値を変更するだけで自動的に通知される
    }
}

Viewの役割は、値を受け取って表示すること。値がどこから来たかは知らない。

public class HPBar : MonoBehaviour
{
    [SerializeField] private Slider _slider;
    
    public void SetValue(int hp)  // 呼ばれたら表示するだけ
    {
        _slider.value = hp;
    }
}

Modelは監視されることを許容する(Observable)。
Viewは更新されることを許容する(操作のためのAPIを提供する)。
Presenterは、ModelをSubscribeし、Viewを更新する(Observer)

この解釈をすると、UI(View)側からイベント(Buttonがクリックされたなど)が発行される場合も、Observableを持つのがViewになればよいだけであると気づく。実際、ModelとViewの周りの依存性は、どちらもPresenterにのみ依存されるという対照的なものなので、入れ替えても問題ない。

【表示の更新】
Model <──(Subscribe)── Presenter ──(メソッド呼び出し)──> View

【入力の処理】
Model <──(メソッド呼び出し)── Presenter ──(Subscribe)──> View

Viewは「ボタンが押された」「スライダーが動いた」というイベントを発火する。Presenterがそれを受けてModelのメソッドを呼ぶ。

public class ShopPresenter : MonoBehaviour
{
    [SerializeField] private ShopModel _model;
    [SerializeField] private ShopView _view;
    
    void Start()
    {
        // Model → View(表示の更新)
        _model.Gold
            .Subscribe(gold => _view.UpdateGold(gold))
            .AddTo(this);
        
        _model.Items
            .Subscribe(items => _view.UpdateItems(items))
            .AddTo(this);
        
        // View → Model(入力の処理)
        _view.OnBuyClicked
            .Subscribe(itemId => _model.BuyItem(itemId))
            .AddTo(this);
        
        _view.OnSellClicked
            .Subscribe(itemId => _model.SellItem(itemId))
            .AddTo(this);
    }
}

どちらの方向も、イベントを発火する側メソッドを呼ばれる側がいる。その中継をPresenterが担う。より厳密に細分化するなら、入出力に関して別のPresenter(あるいはそれに相当するクラス)がいても良いかもしれない。

方向 イベント発火側 メソッド呼び出し側
表示 Model View
入力 View Model

この考え方は、キーボードからの入力をObservableとみた場合などにも応用できる。

参考:

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?