UnityでUniRxとZenjectを使ってMVRPパターンを実装した時のメモです。
この話題はたくさん先駆者がいらっしゃってわざわざ書くまでもないかと思ったのですが、自分で手を動かすとやはりいろいろ戸惑う部分があったので作業記録として残しておきます。
参考
今回のサンプルは上記のクラス図を想定しています。
Model(GameStatusModel)はゲーム内の各種ステータスを保持しています。
View(GUIViewとGameObjectView)は、それぞれシーン内のGUIと様々なGameObjectの参照を保持して、Modelの状態に応じて制御されるものとします。
Presenter(Presenter)はModelとViewの橋渡しをします。
クラス図の通り、ModelはPresenterの存在を知りません(=依存していません)
また、ViewもPresenterの存在を知りません。
ModelはModel内の事しか知らないので、画面の表示状態やGUIのイベントについて感知せず、ビジネスロジックのみを記述できます。
同じように、ViewはView内の事しか知らないので、ビジネスロジックの内容に関わらず表示のみを担当できるので、疎結合になります。
GameStatusModelがキャラクターのHP(体力)を保持しています。
GUIViewに「回復ボタン」を用意して、クリックすると回復が行われ、その結果のHPを、GameObjectViewを使ってテキストに表示します。
単純に作るならば、ボタンのOnClickイベントに「ModelのHPを増やす」というメソッドを割り当てたくなります。
また、変更されたHPを再表示するために、ModelからViewの更新イベントを読んだり、UpdateでModelの変更を監視したり、という処理が必要になるかもしれません。
しかしこれだと、ViewがModelをお互いに知っている(=依存している)事になり、密結合が発生し、Viewが増えた場合やModelに差し替えが発生した場合に作り直しになります。
そこで、GUIに用意した回復ボタンのクリックイベントを、「IObservable」で公開し、Presenterがそれを「購読」し、クリックされた場合にModelに伝える、という構造にします。
そして、Model内でHPが回復した(変更があった)ことをRectivePropertyにて公開し、またPresenterがそれを「購読」し、値に変更があったことをViewに伝える、という構造にします。(クリックイベントと逆の伝わり方)
MVPパターンにZenjectは必須ではないのですが、今回の「Model」の実現方法としてZenjectを使用します。
ゲーム全体の値を常に保持する作りとしてシングルトンが考えられますが、シングルトンを回避するため使用します。
使用するのはPresenterがModelに依存する部分です。
またその際に、Modelに直接依存するのではなく、Modelのインターフェースに依存するようにします(依存関係逆転の原則)
GameStatsModel.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using UniRx; public class GameStatusModel : IGameStatusModel { //HPの初期値 private const int initial_hp = 500; //HP用のReactivePropertyの作成 private IntReactiveProperty _hp_RP = new IntReactiveProperty(initial_hp); //公開用の読み取り用ReactivePropertyの作成 public IReadOnlyReactiveProperty<int> HP_RP { get => _hp_RP; } //回復メソッド public void Cure() { _hp_RP.Value += 100; //Valueを忘れずに } } public interface IGameStatusModel { IReadOnlyReactiveProperty<int> HP_RP { get; } void Cure(); }
Modelはinterfaceを実装します。
IntReactivePropertyを使ってHPを管理し、読み取り専用として公開します。
(後ほど、Presenterがこれを購読して値の変更を監視します)
GUIView.cs
using System; using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using UniRx; public class GUIView : MonoBehaviour { [SerializeField] Button CureButton; //ViewはPresenterを知らないので、クリックイベントを公開しておくだけ public IObservable<Unit> CureButton_OnClick() { return CureButton.onClick.AsObservable(); } }
GameObjectView.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class GameObjectView : MonoBehaviour { [SerializeField] TextMesh HPText; //HPテキストを変更するメソッドを公開しておくだけ public void SetHPText(int HP) { HPText.text = HP.ToString(); } }
GUIViewは、ボタンクリックのイベントをIObservableとして公開します。後ほどPresenterがそれを購読しますが、View側は誰が購読するかは感知しません。
GameStatusViewは、HPのテキストを更新するためのメソッドだけを用意します。同じくView側は誰が値を変えても構わない作りになっています。(実際はPresenterが値を変更します)
Presenter.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using Zenject; using UniRx; public class Presenter : MonoBehaviour { //PresenterはViewを知っている [SerializeField] GameObjectView _gameObjectView; [SerializeField] GUIView _guiView; //PresenterはModelを知っている(Zenject経由で取得) private IGameStatusModel _gameStatusModel = null; [Inject] public void Construct(IGameStatusModel injected) { _gameStatusModel = injected; } void Awake() { //Modelの変更をViewに通知 _gameStatusModel.HP_RP.Subscribe(x => { _gameObjectView.SetHPText(x); }); //Viewのボタンクリックを受け取りModelに通知 _guiView.CureButton_OnClick().Subscribe(_ => _gameStatusModel.Cure()); } }
GameStatusModelInstaller.cs
using UnityEngine; using Zenject; public class GameStatusModelInstaller : MonoInstaller { public override void InstallBindings() { Container .Bind<IGameStatusModel>() .To<GameStatusModel>() .FromNew() .AsSingle() .NonLazy(); } }
PresenterはViewに依存しているので、[SerializedField]経由でシーン上で参照を行います。
(Presenter自身もシーン上に置かれるので、MonoBehaviourを継承しています)
GameStatusModelはZenject経由で参照します。そのためのMonoInstallerがGameStatusModelInstaller.csになります。
Presenterは、GameStatusModelのHP_RP(HPを管理するReactiveProperty)を監視し、値の変更があった場合にGameObjectViewのSetHPTextを呼び出して値を更新します。
また、GUIViewのCureButton_OnClick()を監視し、クリックされた場合にModelに通知します。
これで、ViewでのボタンクリックがPresenterを経由してModelに行き値が更新され、その値の更新がPresenterを経由してViewまで流れる仕組みができました。Update()で常に監視する必要もなくなっています。