はじめに
先日CyberAgent様の下に載せているインターンに参加し、UniRxを用いたMVPパターンの実装手法を学習しました。
インターンでは、主にインゲームに対しての応用方法を学びましたが、今回はUIのボタンを例としてまとめていきたいと思います。
(途中でUniRxおよびMVPパターンの説明を挟みますので、理解ができていない方もぜひ最後まで見ていただければと思います!)
実装
今回の目標としてはボタンをクリックしたときにあるテキストの値が+1ずつされるようにします。
まず、UniRXもMVPパターンも用いずに実装することを考えます。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class InGameView : MonoBehaviour
{
/// <summary>
/// ボタン
/// </summary>
[SerializeField]
private Button _button;
/// <summary>
/// テキスト
/// </summary>
[SerializeField]
private Text _text;
private int _currentTextValue = 0;
private void Start()
{
// ボタンのクリックイベントにメソッドを登録
_button.onClick.AddListener(OnButtonClick);
UpdateText();
}
/// <summary>
/// ボタンをクリックされたときのイベント
/// </summary>
private void OnButtonClick()
{
_currentTextValue++;
UpdateText();
}
/// <summary>
/// テキストを更新する
/// </summary>
private void UpdateText()
{
// テキストの現在値を反映
_text.text = _currentTextValue.ToString();
}
}
では、上記のスクリプトをヒエラルキー上の空のゲームオブジェクトのアタッチします。
UIからButtonとTextを用意して、インスペクターから変数にアタッチしましょう(今回はTextMeshProは使用していませんが、どちらでも大丈夫です!)。
そうすると以下のgifのようにボタンをクリックすることでテキストの値が+1ずつされるようになります(なんか挙動が少し怪しい感じがしますがちゃんとボタンをクリックして値が+1ずつされています)。
ここからが、問題です。UniRxとMVPパターンを用いて実装していきます。
まず上記に対してなぜUniRxやMVPパターンを用いて実装をしないといけないのか、現状の問題点を挙げながら説明していきます。
まず、現在の実装方法では以下の問題が存在します
- 密結合
- InGameViewクラスはUI要素(ボタン、テキスト)とロジック(ボタンクリック時の動作)が密接に結合されている。これにより、UI要素を変更する際にロジックも影響を受けやすくなってしまう(変数のボタン, テキストやUpdateTextメソッドがUI要素、OnButtonClickメソッドがロジック部分)。
- 拡張性と保守性の低さ
- 新しい機能を追加する際や既存の機能を変更する際に、既存のコードを大幅に改変する必要が生じることがある。これは、長期的なプロジェクトでの保守性や拡張性を損なう原因となる。
- テストの難しさ
- UI要素とビジネスロジックが混在しているため、自動テストを書くのが難しくなる。特にUIに依存する部分は、分離せずにテストするのが困難になる。
"拡張性と保守性の低さ" に関して実際の例で考えてみます。例えば今回のテストコードでは、1つのボタンに対して1つのテキストの値を変更するというものでしたが、こういった機能がさらに増えていくことを考えます。
public class InGameView : MonoBehaviour
{
[SerializeField]
private Button _button1;
[SerializeField]
private Button _button2;
[SerializeField]
private Button _button3;
[SerializeField]
private Text _text1;
[SerializeField]
private Text _text2;
[SerializeField]
private Image _image;
private int _text1CountValue = 0;
private float _text2CountValue = 0;
private void Start()
{
_button1.onClick.AddListener(OnButton1Click);
_button2.onClick.AddListener(OnButton2Click);
_button3.onClick.AddListener(OnButtonClick);
UpdateText();
}
/// <summary>
/// ボタン1をクリックされたときのイベント
/// </summary>
private void OnButton1Click()
{
_currentTextValue++;
UpdateText();
}
/// <summary>
/// ボタン2をクリックされたときのイベント
/// </summary>
private void OnButton2Click()
{
StartCoroutine(Text2Event());
}
private void OnButton3Click()
{
StartCoroutine(ImageEvent());
}
/// <summary>
/// テキスト2の値を変更するコルーチン
/// </summary>
private IEnumerator Text2Event()
{
"非同期で毎フレーム_text2CountValueの値を変更し続ける"
}
/// <summary>
/// _imageの位置を変更するコルーチン
/// </summary>
private IEnumerator ImageEvent()
{
"非同期で毎フレーム_imageの位置を変更する"
}
/// <summary>
/// テキスト1を更新する
/// </summary>
private void UpdateText()
{
// テキストの現在値を反映
_text.text = _text1CountValue.ToString();
}
}
上記のようにボタンを2つ、テキストを1つ、画像を1つ追加しました。メソッド名などが分かりづらいという問題もあると思いますが、それ以上に良くなさそうな感じがします。例えば、1か月間何もコードを触らずに久しぶりに「_text1CountValueの値を3ずつ増やしたいな」となったときにどこを変更すればいいのか時間がかかってしまいます。
また、現在は他に干渉していないため問題ありませんが、さらに処理が増えて_text1CountValueが別のところに干渉した場合、値を変えるだけで済むかどうかわかりません。
チーム開発ならなおさらこのコードを知らない人が変更する場合に時間がかかってしまいますし、懸念点が多くなります(値だけ変えればいいのか、ほかに変更する部分があるのか)。
なので、UI部分とロジック部分を分けて保守性、拡張性を高くしたいのです。
じゃあ、どうやってそれを解決するかという時にMVPパターンの実装です。
まずMVP(Model-View-Presenter)パターンとは何だというところですが、簡単に言うとUI部分とロジック部分を別のスクリプトで管理し個別にテストしやすくしたり保守性を高めるソフトウェア設計パターンです。
まずは図を用意したのでそれで理解してみましょう(少し汚いです...)。
図だけではわからないかもしれないので、追加で補足します。
- Model
- アプリケーションのデータとビジネスロジック(ビジネスルール、データの保存と取得、計算など)を担当
- モデルは通常、プレゼンターやビューとは独立しており、直接的なユーザーインターフェースのロジックを含まない
- View
- ユーザーに表示されるUI要素(ボタン、テキストボックス、ラベルなど)を管理
- ユーザーの入力を受け取り、それをプレゼンターに渡すが、どのようにデータを表示または操作するかについての決定は行わない(ロジック部分を知らないため)。
- Presenter
- ビューとモデルの間の仲介役として機能し、ビューからの入力を受け取り、モデルを操作
- モデルからデータを取得し、それをビューに渡して表示する方法を指示
- ビューのインターフェースに依存するが、具体的なビューの実装には依存しません。
上記のように機能を分けることによって以下のメリットがあります
- 分離と再利用性
- テストの容易さ
では実際に最初の例に対して実装してみます
View
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class InGameView : MonoBehaviour
{
/// <summary>
/// ボタン
/// </summary>
[SerializeField]
private Button _button;
/// <summary>
/// テキスト
/// </summary>
[SerializeField]
private Text _text;
/// <summary>
/// イベントを定義
/// </summary>
public event Action OnButtonClicked;
public void Initialize()
{
// ボタンのクリックイベントにメソッドを登録
_button.onClick.AddListener(OnButtonClick);
UpdateText(0);
}
/// <summary>
/// ボタンをクリックされたときのイベント
/// </summary>
private void OnButtonClick()
{
OnButtonClicked?.Invoke();
}
/// <summary>
/// テキストを更新する
/// </summary>
public void UpdateText(int textValue)
{
// テキストの現在値を反映
_text.text = textValue.ToString();
}
}
Model
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class InGameModel
{
/// <summary>
/// テキストの現在の値
/// </summary>
public int CurrentTextValue { get; private set; }
/// <summary>
/// コンストラクタ
/// </summary>
public InGameModel()
{
CurrentTextValue = 0;
}
/// <summary>
/// _currentTextValueの値を変更する
/// Viewでボタンがクリックされたことによって呼び出される
/// </summary>
public void PlusCurrentTextValue()
{
CurrentTextValue++;
}
}
Presenter
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class InGamePresenter : MonoBehaviour
{
/// <summary>
/// View
/// </summary>
[SerializeField]
private InGameView _view;
/// <summary>
/// Model
/// </summary>
private InGameModel _model;
private void Start()
{
_view.Initialize();
_model = new InGameModel();
// イベントを購読
_view.OnButtonClicked += OnButtonClick;
}
private void OnDestroy()
{
// 忘れずにイベントの購読解除を行う
_view.OnButtonClicked -= OnButtonClick;
}
/// <summary>
/// Modelにボタンがクリックされたことを通知する
/// Modelで値が変更されたことをViewに通知する
/// </summary>
private void OnButtonClick()
{
_model.PlusCurrentTextValue();
_view.UpdateText(_model.CurrentTextValue);
}
}
はい、やり方はいろいろあると思いますが、こちらで実装終了です。見た目としてはコード量が増えて余計見づらいんじゃないか?となる方もいると思いますが、大規模なプロジェクトになるほど便利さに気付けると思います!
では一つずつ説明していきます
- PresenterでView, Modelの初期化を行う。
- Viewでボタンがクリックされたときの処理を登録する。PresenterでViewのボタンがクリックされたときの処理を購読する
- Viewでボタンをクリックする。Presenterに通知をする
- PresenterからModelにボタンがクリックされたことを通知する
- Modelで値を変更する。Presenterで変更された値を取得し、Viewに通知する
- Viewでテキストの値を変更する
処理の流れとしては上記のようになっています。これでUI部分とロジック部分を分けることができたため、保守性やテストがしやすくなるはずです。
しかし、こちらをUniRxを使ってさらに使いやすくします。細かい説明する前に先に実装を行います。
View
using System;
using System.Collections;
using System.Collections.Generic;
using UniRx;
using UnityEngine;
using UnityEngine.UI;
public class InGameView : MonoBehaviour
{
/// <summary>
/// ボタン
/// </summary>
[SerializeField]
private Button _button;
/// <summary>
/// テキスト
/// </summary>
[SerializeField]
private Text _text;
/// <summary>
/// イベントを定義
/// </summary>
public IObservable<Unit> OnButtonClickAsObservable
=> _button.OnClickAsObservable();
public void Initialize()
{
UpdateText(0);
}
/// <summary>
/// テキストを更新する
/// </summary>
public void UpdateText(int textValue)
{
// テキストの現在値を反映
_text.text = textValue.ToString();
}
}
Model
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;
public class InGameModel
{
/// <summary>
/// テキストの現在の値
/// </summary>
public ReactiveProperty<int> CurrentTextValueProp;
/// <summary>
/// コンストラクタ
/// </summary>
public InGameModel()
{
CurrentTextValueProp = new ReactiveProperty<int>(0);
}
/// <summary>
/// _currentTextValueの値を変更する
/// Viewでボタンがクリックされたことによって呼び出される
/// </summary>
public void PlusCurrentTextValue()
{
CurrentTextValueProp.Value++;
}
}
Presenter
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;
public class InGamePresenter : MonoBehaviour
{
/// <summary>
/// View
/// </summary>
[SerializeField]
private InGameView _view;
/// <summary>
/// Model
/// </summary>
private InGameModel _model;
private void Start()
{
_view.Initialize();
_model = new InGameModel();
Bind();
}
/// <summary>
/// バインド
/// </summary>
private void Bind()
{
// イベントを購読
_view.OnButtonClickAsObservable
.Subscribe(_ => _model.PlusCurrentTextValue()).AddTo(this);
_model.CurrentTextValueProp
.Subscribe(_view.UpdateText).AddTo(this);
}
}
はい、以上でUniRxを利用してリファクタリングすることができました。使い慣れていない人からすると、何やってんだって感じだと思いますが、直感的にさっきよりは見やすくなったと思います。
ではUniRxを使うことで具体的にどのようによくなるのかというところですが、以下のことが挙げられます。
- コードの簡潔さ
- イベントの購読や処理がストリームとして表現され、より簡潔なコードで記述できる
_button.onClick.AsObservable()
.Subscribe(_ => {
// ボタンクリック時の処理
Debug.Log("Button clicked");
});
//_button.onClickイベントをAsObservableメソッドを使ってオブザーバブルに変換し、
//Subscribeメソッドでそのイベントを購読
- エラー処理
- エラー処理もストリームの一部として組み込むことができる。これにより、エラーが発生した場合の振る舞いを簡単に定義できる
Observable.EveryUpdate()
.Select(_ => {
if (Time.frameCount % 60 == 0) // 60フレームごとにエラーを発生させる
throw new Exception("Error occurred");
return Time.frameCount;
})
.Subscribe(
x => Debug.Log(x),
ex => Debug.LogError(ex.Message) // エラーが発生した場合の処理
);
//エラーをストリームの一部として扱うことができる
- 購読の管理
- 購読のライフサイクルを簡単に管理できる。Disposableを使用して、不要になった購読を適切に解除することが可能
private IDisposable _subscription;
void Start() {
_subscription = Observable.EveryUpdate()
.Where(_ => Input.GetMouseButtonDown(0))
.Subscribe(_ => Debug.Log("Mouse clicked"));
}
void OnDestroy() {
_subscription?.Dispose(); // オブジェクトの破棄時に購読を解除
}
もしくは
void Start() {
Observable.EveryUpdate()
.Where(_ => Input.GetMouseButtonDown(0))
.Subscribe(_ => Debug.Log("Mouse clicked"))
.AddTo(this); // このGameObjectが破棄されたときに自動的に購読を解除
}
今回の例だけではまだUniRxの便利さが伝わらないかもしれませんが、これ以上に使用方法もあるので是非実際に調べながら使っていくことをお勧めします!
使い方としては、AssetStoreにて無料でインポートするだけです!
おわりに
こんな風に記事を書いている僕ですが、まだ使い始めて5か月ほどしか経っていないため自分でもまだまだ知らない機能は多くあります。まだまだ勉強して追加で記事を投稿していこうと思います!