【Unity(C#)】UniRxとExtenjectでMV(R)Pやってみる

##デモ
早速ですがデモです。


<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">それっぽく実装できたので記事にまとめます😎 <a href="https://t.co/yISTofPako">pic.twitter.com/yISTofPako</a></p>&mdash; KENTO⚽️XRエンジニア😎Zenn100記事マラソン挑戦中24/100 (@okprogramming) <a href="https://twitter.com/okprogramming/status/1359780612878462978?ref_src=twsrc%5Etfw">February 11, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

音楽プレーヤーです。シークバーで再生箇所を変更できます。

こちらを**Extenject**と**UniRx**をうまいこと使って**MV(R)Pパターン**で実装していきます。

下記参考にさせていただきました。
【参考リンク】：[UnityにおけるMVPパターンについて](https://virtualcast.jp/blog/2019/11/mvp_pattern_on_unity/)

具体的な例を挙げて図解したものが下記です。

![MV(R)P＿Ex.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/102be677-a267-a531-f38d-9aa4e0a2b152.png)

ViewとModelどちらかを変更すればもう片方に変更に応じた処理が反映されます。

途中寄り道しながらいろいろメモしていきます。

## View
まずはViewです。

```C#
using UnityEngine;
using UnityEngine.UI;

namespace Ono.MVP.View
{
    /// <summary>
    /// View
    /// </summary>
    public class MusicPlayerView : MonoBehaviour
    {
        [SerializeField] private Button _playButton, _stopButton;
        [SerializeField] private Slider _seekBar;
        [SerializeField] private Text _playTimeText;

        /// <summary>
        /// 再生ボタン
        /// </summary>
        public Button PlayButton => _playButton;

        /// <summary>
        /// 停止ボタン
        /// </summary>
        public Button StopButton => _stopButton;

        /// <summary>
        /// シークバー
        /// </summary>
        public Slider SeekBar => _seekBar;

        /// <summary>
        /// 再生時間をセット
        /// </summary>
        /// <param name="timeText">表示される時間</param>
        public void SetPlayTime(string timeText)
        {
            _playTimeText.text = timeText;
        }

        /// <summary>
        /// ボタン切り替え
        /// </summary>
        public void SwitchButton()
        {
            _playButton.gameObject.SetActive(!_playButton.gameObject.activeInHierarchy);
            _stopButton.gameObject.SetActive(!_stopButton.gameObject.activeInHierarchy);
        }
    }
}
```

UIコンポーネントを`SerializeField`に設定して
`public Button PlayButton => _playButton;`で公開するというやり方は下記を参考にしました。

【参考リンク】：[Web出身のUnityエンジニアによる大規模ゲームの基盤設計](https://developers.cyberagent.co.jp/blog/archives/4262/#mvp_pattern)

やや冗長に感じましたが、**View以外のクラスを肥大化させないため**や
**MonoBehaviorが必要のないクラスにMonoBehaviorの継承をさせないため**には有効な手法のようです。
(教えてくださった方ありがとうございます！)

##Model
続いてロジックを担うModelです。

```C#
using Ono.MVP.CustomRP;
using UniRx;
using UniRx.Triggers;
using UnityEngine;

namespace Ono.MVP.Model
{
    /// <summary>
    /// Model
    /// </summary>
    public class MusicPlayerModel : MonoBehaviour
    {
        [SerializeField] private AudioSource _bgm;

        /// <summary>
        /// 音楽再生モード
        /// </summary>
        public IReadOnlyReactiveProperty<MusicPlayMode> MusicPlayModeRP => _musicPlayModeRP;

        private readonly MusicPlayModeReactiveProperty _musicPlayModeRP =
            new MusicPlayModeReactiveProperty(MusicPlayMode.Stop);

        /// <summary>
        /// 再生時間
        /// </summary>
        public IReadOnlyReactiveProperty<float> MusicPlayTimeRP => _musicPlayTimeRP;

        private readonly FloatReactiveProperty _musicPlayTimeRP = new FloatReactiveProperty();

        private void Start()
        {
            this.UpdateAsObservable()
                .Where(_ => _musicPlayModeRP.Value == MusicPlayMode.Play)
                .Subscribe(_ => { _musicPlayTimeRP.Value = _bgm.time / _bgm.clip.length; })
                .AddTo(this);
        }

        /// <summary>
        /// 再生時間を時分として取得
        /// </summary>
        /// <returns>再生時間</returns>
        public string GetMusicTime()
        {
            var totalMinute = (int) _bgm.clip.length / 60;
            var totalSecond = (int) _bgm.clip.length % 60;
            var currentMinute = (int) _bgm.time / 60;
            var currentSecond = (int) _bgm.time % 60;
            return $"{currentMinute}:{currentSecond:00} / {totalMinute}:{totalSecond}";
        }

        /// <summary>
        /// 再生し再生モードに切り替え
        /// </summary>
        /// <param name="playTimeNormalizedValue">正規化された再生箇所の値</param>
        public void PlayMusic(float playTimeNormalizedValue)
        {
            _bgm.time = playTimeNormalizedValue * _bgm.clip.length;
            _bgm.Play();
            _musicPlayModeRP.Value = MusicPlayMode.Play;
        }

        /// <summary>
        /// 停止し停止モードに切り替え
        /// </summary>
        public void StopMusic()
        {
            _bgm.Pause();
            _musicPlayModeRP.Value = MusicPlayMode.Stop;
        }
    }
}
```

###EnumのReactiveProperty
今回は再生中、停止中の切り替えにEnumのReactivePropertyを定義して利用しました。
(後々思いましたが、別にEnumの定義した値が2つならBoolReactivePropertyでも良かったかもです。)

```C#
using System;
using UniRx;

namespace Ono.MVP.CustomRP
{
    /// <summary>
    /// 音楽再生に関する状態
    /// </summary>
    public enum MusicPlayMode
    {
        Play,
        Stop
    }
    
    [Serializable]
    public class MusicPlayModeReactiveProperty : ReactiveProperty<MusicPlayMode>
    {
        public MusicPlayModeReactiveProperty (){}
        public MusicPlayModeReactiveProperty (MusicPlayMode initialValue) : base (initialValue) {}
    }
} 
```

##Presenter
ViewとModelを繋ぐPresenterです。
MonoBehaviorは継承していません。
ですので、ここでExtenjectの機能を利用します。

```C#
using System;
using Ono.MVP.Model;
using Ono.MVP.View;
using UniRx;
using UniRx.Triggers;
using Zenject;

namespace Ono.MVP.Presenter
{
    /// <summary>
    /// Presenter
    /// </summary>
    public class MusicPlayerPresenter : IInitializable, IDisposable
    {
        private readonly MusicPlayerView _musicPlayerView;
        private readonly MusicPlayerModel _musicPlayerModel;
        private CompositeDisposable _disposables;

        /// <summary>
        /// コンストラクタインジェクション
        /// </summary>
        /// <param name="musicPlayerView">View</param>
        /// <param name="musicPlayerModel">Model</param>
        public MusicPlayerPresenter(MusicPlayerView musicPlayerView, MusicPlayerModel musicPlayerModel)
        {
            _musicPlayerView = musicPlayerView;
            _musicPlayerModel = musicPlayerModel;
        }

        /// <summary>
        /// "MonoBehaviorで言うところのStart"にあたるライフサイクルイベントとして呼ばれるメソッド
        /// </summary>
        public void Initialize()
        {
            _disposables = new CompositeDisposable();

            //==========================
            // View → Modelへの反映
            //==========================

            //再生ボタン押下
            _musicPlayerView.PlayButton.OnClickAsObservable()
                .Subscribe(_ => { _musicPlayerModel.PlayMusic(_musicPlayerView.SeekBar.value); }).AddTo(_disposables);

            //停止ボタン押下
            _musicPlayerView.StopButton.OnClickAsObservable()
                .Subscribe(_ => { _musicPlayerModel.StopMusic(); }).AddTo(_disposables);

            //シークバーのドラッグ開始
            _musicPlayerView.SeekBar.OnPointerDownAsObservable()
                .Subscribe(_ => { _musicPlayerModel.StopMusic(); }).AddTo(_disposables);

            //シークバーのドラッグ終了
            _musicPlayerView.SeekBar.OnPointerUpAsObservable()
                .Subscribe(_ => { _musicPlayerModel.PlayMusic(_musicPlayerView.SeekBar.value); }).AddTo(_disposables);

            //==========================
            // Model → Viewへの反映
            //==========================

            //再生時間を反映
            _musicPlayerModel.MusicPlayTimeRP
                .Subscribe(time =>
                {
                    //シークバーに反映
                    _musicPlayerView.SeekBar.value = time;
                    //テキストに反映
                    _musicPlayerView.SetPlayTime(_musicPlayerModel.GetMusicTime());
                }).AddTo(_disposables);

            //再生モード変更に応じてボタンの表示を切り替え
            _musicPlayerModel.MusicPlayModeRP
                .SkipLatestValueOnSubscribe()
                .Subscribe(_ => { _musicPlayerView.SwitchButton(); }).AddTo(_disposables);
        }

        /// <summary>
        /// 破棄する処理
        /// </summary>
        public void Dispose()
        {
            _disposables.Dispose();
        }
    }
}
```

###IInitializable
Extenjectには非MonoBehaviourをDIする際にMonoBehaviourのように振舞わせる仕組みがあるそうです。

InitializableはStart関数と同様なタイミングのようです。
下記が対応表です。

| 名前 | 実装するメソッド | 対応するMonoBehaviourのメソッド |
|:-----------|:------------:|:------------:|
| IInitializable      | Initialize       | Start      |
| ITickable     | Tick| Update      |
| ILateTickable       | LateTick        | LateUpdate|
| IFixedTickable         | FixedTick          | FixedUpdate           |

【引用元】：[【Unity】【Zenject】非MonoBehaviourをDIする際にMonoBehaviourのように振舞わせる](https://light11.hatenadiary.com/entry/2019/02/28/001135)

IInitializableの中で購読を開始することで、
Start関数で購読を開始するというお馴染みの流れを
非MonoBehavior継承クラスにて疑似的に実現できました。

###IDisposable
IDisposableはインスタンスが破棄されるタイミングで呼び出されます。

`AddTo(this)`とすることで
Monobihaviorを継承したオブジェクトが破棄される際に
購読を自動的に停止することができます。

これはAddToに下記のようなオーバーロードが実装されているからです。

| シグネチャ | 挙動 |
|:----|:----|
| `AddTo(GameObject gameObject)` | GameObjectの`OnDestroy()`のタイミングでレシーバの`Dispose()`が呼ばれる |
| `AddTo(Component gameObjectComponent)` | `component.gameObject`の`OnDestroy()`のタイミングでレシーバの`Dispose()`が呼ばれる |
| `AddTo(ICollection<IDisposable> container)` | `container.Dispose()`のタイミングでレシーバの`Dispose()`が呼ばれる（内部的に`container.Add(レシーバ)`される） |
| `AddTo(ICollection<IDisposable> container, GameObject gameObject)` | `container.Dispose()`とGameObjectの`OnDestroy()`のタイミングでレシーバの`Dispose()`が呼ばれる（内部的には`AddTo(container).AddTo(gameObject)`している） |

【引用元】：[AddToメソッド](https://qiita.com/su10/items/4e01023a7a1607e8323e#addto%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89)


しかし、非Monobihaviorの場合、上記表を見てわかる通りAddToの引数にthisは使えません。
なので`IDisposable.Dipose()`の中で明示的に購読を停止される必要があるということです。

##Installer

Extenjectでお馴染みのInstallerです。
ViewとModelのインスタンスはヒエラルキー上のオブジェクトから参照したいので
`FromComponentOn`で指定しています。

```C#
using Ono.MVP.Model;
using Ono.MVP.Presenter;
using Ono.MVP.View;
using UnityEngine;
using Zenject;

namespace Ono.MVP.Installer
{
    /// <summary>
    /// Installer
    /// </summary>
    public class MusicPresenterInstaller : MonoInstaller
    {
        [SerializeField] private GameObject _view, _model;

        public override void InstallBindings()
        {
            Container.Bind<MusicPlayerView>().FromComponentOn(_view).AsSingle();
            Container.Bind<MusicPlayerModel>().FromComponentOn(_model).AsSingle();
            Container.Bind(typeof(MusicPlayerPresenter), typeof(IInitializable))
                .To<MusicPlayerPresenter>().AsSingle();
        }
    }
}
```

ExtenjectのライフサイクルイベントをきっかけにResolveしたいときの書き方を
今回調べて初めて知りました。

【参考リンク】：[【Unity】【Zenject】非MonoBehaviourをDIする際にMonoBehaviourのように振舞わせる](https://light11.hatenadiary.com/entry/2019/02/28/001135)

##おわりに

個人的な学習用のサンプルなのでツッコミ大歓迎です。
下記に上げときます。
【GitHub】: [MVP_Demo](https://github.com/ForJobOk/MVP_Demo)

どんどんアップデートされてる[VContainer](https://github.com/hadashiA/VContainer/releases/tag/1.5.3)も触っていきたいです。

##参考リンク
[Zenjectを使うときに気を付けていること](https://adarapata.hatenablog.com/entry/unity-advent-calendar-2019#%E5%9F%BA%E6%9C%AC%E7%9A%84%E3%81%AB%E3%82%B3%E3%83%B3%E3%82%B9%E3%83%88%E3%83%A9%E3%82%AF%E3%82%BF%E3%82%A4%E3%83%B3%E3%82%B8%E3%82%A7%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3%E3%82%92%E8%A1%8C%E3%81%86)
[[UniRx]購読を停止する](https://qiita.com/yaegaki/items/bea845df0b011d515afb)
[UIのシステムをUniRxで構築する](https://qiita.com/dsgarage/items/621bd3b31b78f33703eb)
[VContainerを組み込んだゲームサンプル](https://qiita.com/kenta-san/items/21bc7e0ebe1ded6e2ac7)
[Unityで経過時間、制限時間を表示する機能を作成する](https://gametukurikata.com/program/time)
[【Unityスクール】カウントダウンタイマーの制作方法](https://win8kat.org/archives/1026)