はじめに
今回はMVP開発におけるPresenterの実装における注意点について書いていきます。
開発における注意点
アセンブリを切ろう
Presenterフォルダ直下にPresenterという名前のアセンブリを作成しましょう。
アセンブリの参照の設定は以下のようにしてください。Modelにしか依存できないように強制できます。
1画面1Presenterにしよう
それではPresenterを作成してみましょう。Presenter/Puzzle/PuzzlePresenter.csを作成してください。Puzzleに深い意味はなく、単なる画面の名称です。タイトル画面とか設定画面とか画面が増えるごとに増やしてよいです。
このPuzzleフォルダーはViewにも同様に作成します。これはつまり、「ViewとPresenterのフォルダ構成は同じにすべし」ということです。その理由はPresenterは論理的な画面を作成し、演出などを論理的に行うものだからです。原理的に画面とは1対1の対応にすべきでしょう。一つの画面フォルダにViewフォルダ、Presenterフォルダを作る方針も考えられますが、Unityのアセンブリの判定が「アセンブリ定義ファイルの直下にあるものをまとめる」という挙動をするのでViewとPresenterのフォルダを分けます。
下位Presenterも活用しよう
画面が複雑になるにつれて、画面全体の情報をPresenterにすべて格納するのはやはり複雑化しますね。
その場合、新しいクラスとしてXXXPresenterを作成し、それに関連処理を詰め込みましょう。
実際、PuyoPresenterでは、ぷよ1つに関する論理的な表示データをすべてPuyoPresenterに委託しています。
画面の論理的なセクション単位でPresenterを切り離すのは全くおかしなことではありませんし、可読性を上げることにもつながります。もちろん、Presenterをそのまま映すのがViewなので、Presenterの切り分けがViewの切り分けに影響します。
具体例として、今回のぷよぷよにおけるサンプルコードでは、PuyoView.cs
と PuyoPresenter.cs
が対応しています。
Presenterはインスタンスとして利用できるようにしよう
Modelはstatic変数に格納して使いましたが、Presenterは単なるインスタンスとして使えるようにするべきです。
これは、Presenterの破棄が楽になるからです。通常、UnityのコードはSceneの破棄と連動して破棄できます(Don't destory On Loadを除く)。そしてPresenterは論理的な画面データを管理する存在です。つまり、Sceneに連動して破棄されてほしいし生成されてほしい存在なのです。したがって、ViewがStart()でPresenterのインスタンスを生成してMonobehaviour内部の変数に保存しておく設計にすれば、Presenterは自動的に破棄されます。
ちょっとはテストしてみよう
雑にコードを書いてシーンを作成していく場合に比べたMVP開発における欠点の一つとして、Model Presenter Viewの順に、つまりUnityから遠い抽象的で安定的な部分から開発するので、シーンを実際に動かしてみるまでの時間が長いことがあります。この欠点は、テストを書かない場合に最大の問題となります。なぜなら、Viewまで完成して初めて動作確認すると、バグの箇所が広すぎて特定できないからです。したがって、MVP開発をする場合には、出来ればPresenterも動作確認しておきましょう。Modelレベルにやらなくてもいいです。単純にイベントを呼んでどうなるかの確認ぐらいで良いのです。
レイヤーを分けた開発は、レイヤーごとが疎結合なので、レイヤーごとに動作確認が簡単にできることが利点なのです。レイヤーごとに動作確認しなければ、せっかくのメリットを享受できないです。逆に、動作確認をとると、驚くほどバグが少なくすんなり進みます。
入力処理における注意点
オペレーターを活用しよう
UniRxには大量のオペレーターがあり、これらを活用すると、入力の受け取りは非常に簡略化できます。
実際、PuzzleViewとPuzzlePresenterにおいては、WhereやSelect、DistinctUntilChangedなどのオペレーターを用いていい感じに処理しています。出力処理に比べれば演出があまりいらないので、入力処理の大半はオペレーターの活用をすることでほとんど何とかなる場合が多いと思います。
どこまでをViewにやらせるべきか
サンプルコードでは、PuzzleViewとPuzzlePresenter、両方で入力を処理していますね。じつはこれらの個々の処理の切り分けは実はあいまいです。
dragBoardEvent
.Where(_ => (!Model.Board.CanNext) && timeManager.IsWating)
.Where(_ => dragPositions.Count < Model.MaxSelectPuyoCount)
.Where(position => !dragPositions.Contains(position))
.Where(position => dragPositions.Count == 0 || dragPositions.Any(x => IsNextTo(x, position)))
.Subscribe(DragBoard);
dragStoppedEvent
.Where(_ => (!Model.Board.CanNext) && timeManager.IsWating)
.Where(_ => dragPositions.Count != 0)
.Subscribe(DragStopped);
this.UpdateAsObservable()
.Where(_ => Input.GetMouseButton(0))
.Select(_ => Input.mousePosition)
.Where(position => IsInBoard(position))
.Select(position => FromScreenToBoard(position))
.DistinctUntilChanged()
.Subscribe(position =>
{
presenter.DragBoardEvent.OnNext(position);
});
this.UpdateAsObservable().Where(_ => Input.GetMouseButtonUp(0)).Subscribe(_ =>
{
presenter.DragStoppedEvent.OnNext(Unit.Default);
});
もちろん、基準はあります。「Viewは画面座標が絡む処理などデザインに絡む部分を最低限抽象化する」「Presenterは演出変数が絡む処理など論理画面内で完結する部分を抽象化してModelに渡す」というものです。
とはいえ、完全に切り分けられるわけではないです。今回の場合では、ViewでやっているDistinctUntilChangedなどは別にPresenterでやろうがまったく問題のない処理です。
幸い、この切り分けが多少ずれていようが、それぞれのイベントは疎結合につながっているので大きな問題にはなりえません。さらに、オペレーターは好きに付け替えることが出来ますから、気になったら移動させることも簡単です。神経質になる必要はないでしょう。
出力処理における注意点
何をPresenterが独自に持つのか
ぷよぷよでは、「選択状態にあるぷよの個数」や「アニメーション中は操作できないようにアニメーションの時間を記録しておく」などをPresenterが持つ独自の変数にしています。
何度か書いていますが、これらのデータは「ぷよぷよと言うゲームの本質的なロジック・データではない」かつ「画面表示の制御に不可欠なデータ」です。ただし、Presenterの責務設定には慣れが必要であることは否めませんので、具体例に多く当たるようにしてください。
しかし、恐れる必要はありません。今回の例で言えば「いったん選択状態にしてからぷよを消す」という処理をすべてのゲームで使うロジックだと判断した場合、その処理はModel側で判断するように移せばいいのです。PresenterとModelは両方純粋なC#であるため、PresenterとModelの境界を移動させるのはそこまで難しいことではありません。単に今回私は「ぷよを消すこと自体は当然Modelだが、なぞっていったん選択状態にしてから消すやり方もあれば、Unityではなくてコマンドラインでやるなら座標を指定する方法だってできる。つまりぷよをどのように消すかはぷよぷよと言うゲームのModelではないのではないか?」と考えただけです。このような微妙な判断は状況によって変わります。
リレーするだけのPresenter
Presenterが演出する必要があまりないようなUIだった場合、Presenterは特に意味もなくリレーするだけの存在になります。例えば次のようなPresenterとViewを考えてみましょう。
public class SampleModel
{
private Subject<int> _modelValue1;
public IObservable<int> ModelValue1 => _modelValue1;
public SampleModel()
{
_modelValue1 = new Subject<int>();
Observable.Interval(TimeSpan.FromSeconds(1)).Subscribe(_ => _modelValue1.OnNext(5));
}
}
public static class Model
{
static SampleModel _sampleModel;
public static SampleModel sampleModel => _sampleMode;
static Model()
{
_sampleMode = new SampleModel();
}
}
public class SamplePresenter
{
public SamplePresenter()
{
Model.sampleModel.ModelValue1.Subscribe(Value1);
}
private Subject<int> PresenterValue1;
public IObservable<int> PresentrValue1Observable => PresenterValue1;
void Value1(int value1)
{
PresenterValue1.OnNext(value1);
}
}
public class SampleView : Monobehaviour
{
private SamplePresenter presenter;
void Start()
{
presenter = new SamplePresenter();
presenter.PresenterValue1Observable.Subscribe(DoSomething).AddTo(gameObject);
}
void DoSomething(int value1)
{
// value1をつかってごちゃごちゃする
}
}
このような場合にはPresenterはOnNextでつないでいるだけの存在なので、特段の存在意義はありません。
単にアセンブリの参照関係のためにPresenterがModelとViewの橋渡しをしているのです。もちろん、「Presenterがあってうれしいもの」と「Presenterがなくてもいいもの」が同じ方法でデータを渡せば統一性があり可読性が上がりますからこれはこれで利点はあります。
しかし、私はこの構造が誤解を生みがちだと思っています。というのも、単純なサンプルコードだと、上のようなリレーするだけの極限に小さいPresenterを見せて「これがPresenterです」という解説がされがちなんですね。この解説だとPresenterの価値がまったく理解されないと思います。
おわりに
Presenterの作成には慣れが必要なのも事実ですが、そこまで難しく考える必要もありません。気楽に書き始めてください。