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とみた場合などにも応用できる。
参考: