UnityにおけるMVPパターンについて
はじめに
こんにちは!クライアントチームの♂Natsuki♂です。
以前は野生のUnityプログラマーとして我流でコードを書いていましたが、今は昔、
最近では設計・実装の知識も身についてスーパーインテリジェントプログラマーになりましたので、そのあたりの話をしようかと思います。
真面目な話をすると、今回はUnityにおけるMVPパターンの話をしたいと思います。
MVPパターンを用いることで、クラスの責務や依存関係が整理されて「いい感じ」のコードが書けるということをお伝えできればと思います。
対象読者
この記事は以下のような方を対象としています。
- 我流でコードを書いてる人
- 何らかのパターンを使って設計・実装してみたい人
- クラスの責務や依存関係を整理したい人
MVPパターンとは
MVPパターンとは、ざっくり言うとModel(データ)、View(表示)、Presenter(両者の仲介役)という役割に分割して処理を行おうという考え方のことです。
これは特に何かしらのデータをユーザーに表示する場面で役に立ちます。
MVPパターンを用いることで以下のようなメリットが得られます。
- クラスの責務が明確になる
- 改修が簡単になる
- クラス同士(ModelとView)が疎結合になる
- クラスの差し替えやテストが簡単になる
さて、MVPパターンという言葉をWeb系のフレームワーク等で聞いたことがある方も居るかもしれません。
それを(割と何でもアリな)Unityの世界に部分的に持ち込むことで、一定の秩序をもたらすことがでるというお話になります。
文章で書いても分かりづらいかと思うので、次の章から具体例を載せつつ説明していきます。
具体例
この章ではMVPパターンを用いた設計・実装の例として、仮の音楽プレイヤーを題材に説明します。
以下のようなアプリケーションをMVPパターンを用いて作ってみましょう。
仕様は以下としました。
- Play/Stopボタンで再生/一時停止ができる
- Loopのオンオフができる
- 曲を最後まで再生した場合
- Loopがオンの場合: 最初から再生
- Loopがオフの場合: 最後で停止
- 曲を最後まで再生した場合
また、ループ再生が分かりやすいように曲の長さは3秒としました。
設計
早速ですが、MVPパターンを用いて設計してみましょう。
Model、View、Presenterの解釈の仕方は色々あるかと思いますが、今回は
- Model: 音楽プレイヤーの実態
- View: UI(入出力含む)
- Presenter: ModelとViewを仲介するだけ
としました。
(今回は分かりやすくUIをViewとして解釈しましたが、場合によっては3DモデルやキャラクターがViewになることもあるかと思います)
さて、MVPパターンを意識してクラス図を書くと以下のようになりました。
(今回伝えたいことをここに詰め込みました)
まず、見て分かる通りクラスが3つ(Model, View, Presenterに相当)に分割されています。
MVPパターンを意識して設計することで、各クラスの責務が明確になります。
例えば、「UIを変更したい」という場合にはMusicPlayerView
クラスのみを修正すれば良いといった具合です。
MusicPlayerModel
とMusicPlayerPresenter
はUIのことを知らないので、この2つのクラスが影響を受けることはありません。
これが前章のメリット1です。
また、クラス図の矢印の向きから分かるようにPresenterはModelとViewに依存していますが、ModelとViewは互いに依存していません(疎結合)。
疎結合なのでクラスの差し替えが簡単です。
例えば、「MusicPlayerModel
をテストしたい」という場合に、「テスト用のPresenterクラスに差し替えてテストする」というようなことが簡単にできます。
これが前章のメリット2です。
実装
この節では、前節の設計を実装したコードを載せます。
やはり実装は気になるところだと思うので、省略せずに全部載せちゃいます。
まずはコードをざっと見て「あ、綺麗/簡潔だな」「責務が分かれてるな」と思って貰えれば嬉しいです。
ただやはり、ここで一番見て欲しいのは、各クラスは責務が明確であり、ModelとViewは互いに依存していないというところです。
例えば、前節で言ったような以下の内容はコードからも読み取れるかと思います。
- Modelは音楽を再生し内部状態を発行するが、UIのことは知らない
- ViewはUIでの入出力を司るが、音楽がどう再生されるかは知らない
もう少し具体的なところで言うと、以下の実装はMVPパターンの考え方として分かりやすいかもしれません。
- Viewの
SetPlaybackTime
メソッドではplaybackTime
が渡されるが、その値をシークバーにセットしたり、文字列としてフォーマットしてテキストにセットしたり、という処理はView内で完結している - Modelはあくまで内部状態発行の一環として
playbackTime
を発行しているが、上記のようなUI表示用の文字列を発行したりはしない
余談ですが、自分が昔に書いていたコードでは「ModelにOnPlay
的な命名のメソッドを定義して、ViewのOnPlayButtonClicked
時に呼ぶ」みたいなことをしてたんですが、
OnPlay
という命名だとModelとしての動作を表せていないのでMVPパターン的にも良くないんだろうなと思いました。
↓以下コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
using UnityEngine; public sealed class MusicPlayerModel : MonoBehaviour { // 曲の長さは決まっていると仮定 public readonly float MusicLength = 3f; public PlayerStateEvent OnPlayerStateChanged = new PlayerStateEvent(); public BoolEvent OnLoopChanged = new BoolEvent(); public FloatEvent OnPlaybackTimeChanged = new FloatEvent(); private PlayerState _playerState; private float _playbackTime; private bool _isLoop = true; // あくまで例としての実装なので、本当に音楽を再生するのではなく再生時間を加算するだけ private void Update() { if (_playerState == PlayerState.Playing) { _playbackTime += Time.deltaTime; // 再生時間が曲の長さを超えたとき if (MusicLength < _playbackTime) { if (_isLoop) { _playbackTime = 0f; } else { _playbackTime = MusicLength; Stop(); } } OnPlaybackTimeChanged.Invoke(_playbackTime); } } public void Play() { _playerState = PlayerState.Playing; OnPlayerStateChanged.Invoke(_playerState); } public void Stop() { _playerState = PlayerState.Stopped; OnPlayerStateChanged.Invoke(_playerState); } public void SetLoop(bool isLoop) { _isLoop = isLoop; OnLoopChanged.Invoke(_isLoop); } } public enum PlayerState { Stopped, Playing } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
using System; using UnityEngine; using UnityEngine.Events; using UnityEngine.UI; public sealed class MusicPlayerView : MonoBehaviour { [SerializeField] private Button _playButton; [SerializeField] private Button _stopButton; [SerializeField] private Toggle _loopToggle; [SerializeField] private Slider _seekBar; [SerializeField] private Text _playbackTime; public UnityEvent OnPlayButtonClicked = new UnityEvent(); public UnityEvent OnStopButtonClicked = new UnityEvent(); public BoolEvent OnLoopToggleChanged = new BoolEvent(); private void Awake() { // UIが変化(ボタンがクリックされたりトグルが変化したり)したらイベントを発行する _playButton.onClick.AddListener(() => OnPlayButtonClicked.Invoke()); _stopButton.onClick.AddListener(() => OnStopButtonClicked.Invoke()); _loopToggle.onValueChanged.AddListener(isOn => OnLoopToggleChanged.Invoke(isOn)); } public void SetMusicLength(float musicLength) { _seekBar.maxValue = musicLength; } public void TogglePlayStopButton(bool showPlayButton) { _playButton.gameObject.SetActive(showPlayButton); _stopButton.gameObject.SetActive(!showPlayButton); } public void SetLoopToggle(bool isOn) { _loopToggle.isOn = isOn; } public void SetPlaybackTime(float playbackTime) { _seekBar.value = playbackTime; // シークバーに再生時間をセットする _playbackTime.text = TimeSpan.FromSeconds(playbackTime).ToString(@"ss\:fff"); // 表示用に整形してテキストをセット } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
using UnityEngine; public sealed class MusicPlayerPresenter : MonoBehaviour { [SerializeField] private MusicPlayerModel _model; [SerializeField] private MusicPlayerView _view; private void Awake() { // 曲の長さ(モデルが持っているデータ)をViewにセットする _view.SetMusicLength(_model.MusicLength); #region Model→View // モデルのデータが変化したら、ビューに伝える _model.OnPlaybackTimeChanged.AddListener(playbackTime => _view.SetPlaybackTime(playbackTime)); _model.OnPlayerStateChanged.AddListener(state => _view.TogglePlayStopButton(state != PlayerState.Playing)); _model.OnLoopChanged.AddListener(isOn => _view.SetLoopToggle(isOn)); #endregion #region View→Model // ビューが変化したら、モデルに伝える _view.OnPlayButtonClicked.AddListener(() => _model.Play()); _view.OnStopButtonClicked.AddListener(() => _model.Stop()); _view.OnLoopToggleChanged.AddListener(isOn => _model.SetLoop(isOn)); #endregion } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 |
using UnityEngine.Events; public sealed class PlayerStateEvent : UnityEvent<PlayerState> { } public sealed class BoolEvent : UnityEvent<bool> { } public sealed class FloatEvent : UnityEvent<float> { } |
(引数を取るUnityEventは継承しないと使えないためこのファイルで定義)
※記事の本質から外れないようにイベントまわりはUnityの標準機能(UnityEvent)で実装しましたが、実践的にはUniRxを使うと更に簡潔に便利に書けます。
まとめ
- MVPパターンと、Unityにおける解釈の例について説明した
- MVPパターンを用いると以下のメリットがある
- クラスの責務が明確になる
- 改修が簡単になる
- クラス同士(ModelとView)が疎結合になる
- クラスの差し替えやテストが簡単になる
- クラスの責務が明確になる
- MVPパターンはいいぞ(時と場所によります)
- Tag
- Unity