【Unity】画面遷移におけるビューとロジックの分離手法とUnity Screen Navigatorを使った具体例

画面遷移におけるビューとロジックの分離手法とUnity Screen Navigatorを使った具体例をまとめました。

Unity2019.4.29
Unity Screen Navigator 1.1.2

ビューとロジックを分離することの意義

一般的にゲームのUIは要素と状態が多く、複雑です。
またアニメーションも多用され、細かい調整も必要で、仕様変更による作り直しも多いです。

このような状況下でUIを効率的に構築し、また保守しやすくするためには、
作成したUIをすぐに確認して、修正し、またすぐ確認できるワークフローを整える必要があります。

これをいわゆるアウトゲームの画面遷移という文脈に当てはめると、アプリケーションを最初から再生しなくても、
画面単位でダミーデータを使ってすぐに動作確認できる環境を整えると言うことを意味します。

そのためにはビューをロジックと分離して、ビューにダミーデータを渡せる基盤を整える必要があります。
そこで本記事ではUnity Screen Navigatorを使ってビューとロジックを分離する実装の方法について紹介します。

なおUnity Screen Navigatorの基礎知識については以下のリポジトリや記事を参照してください。

github.com

light11.hatenadiary.com

本記事で作る画面の説明

さて本記事では以下のように、3つの画像が配置された画面を作成します。

左側のFIXEDと書かれた画像は、常にこのページに表示される画像です。
中央のLOCALと書かれた画像は、アプリ内に保存されたLOCAL 01〜LOCAL 03と書かれた画像をランダムに読み込みます。
右側のREMOTEと書かれた画像は、ネットワーク上に置かれたREMOTE 01〜REMOTE 03と書かれた画像をランダムに読み込みます。

f:id:halya_11:20220104210541p:plain
画面の説明

各画像はボタンになっており、それぞれをクリックすると対応するモーダルが開きます。
アニメーションで動作を示すと以下のようになります。

f:id:halya_11:20211212233450g:plain
動作

まずは全部ビューに書く

それでは前節の画面を実装していきます。
まずは全ての処理をビューに書いて実装してみます。

using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.UI;
using UnityScreenNavigator.Runtime.Core.Modal;
using UnityScreenNavigator.Runtime.Core.Sheet;

namespace ViewLogicSeparation.Scripts
{
    public class MainSheet : Sheet
    {
        [SerializeField] private Button _fixedButton;
        [SerializeField] private Button _localButton;
        [SerializeField] private Button _remoteButton;
        [SerializeField] private RawImage _fixedImage;
        [SerializeField] private RawImage _localImage;
        [SerializeField] private RawImage _remoteImage;

        // 初期化処理
        public override IEnumerator Initialize()
        {
            _fixedImage.texture = Resources.Load<Texture>("tex_button_fixed");
            _fixedButton.onClick.AddListener(OnFixedButtonClicked);
            _localButton.onClick.AddListener(OnLocalButtonClicked);
            _remoteButton.onClick.AddListener(OnRemoteButtonClicked);
            yield break;
        }

        // 破棄時の処理
        public override IEnumerator Cleanup()
        {
            _fixedImage.texture = null;
            _fixedButton.onClick.RemoveListener(OnFixedButtonClicked);
            _localButton.onClick.RemoveListener(OnLocalButtonClicked);
            _remoteButton.onClick.RemoveListener(OnRemoteButtonClicked);
            yield break;
        }

        // この画面が表示される前の処理
        public override IEnumerator WillEnter()
        {
            _localImage.texture = Resources.Load<Texture>($"tex_button_local_{Random.Range(1, 4):D2}");

            // テクスチャをダウンロードして取得
            var url = $"http://foo/bar/tex_button_remote_{Random.Range(1, 4):D2}.png";
            var uwr = UnityWebRequestTexture.GetTexture(url);
            yield return uwr.SendWebRequest();
            _remoteImage.texture = ((DownloadHandlerTexture)uwr.downloadHandler).texture;
        }

        // この画面が非表示になる前の処理
        public override void DidExit()
        {
            _localImage.texture = null;
            _remoteImage.texture = null;
        }

        private void OnFixedButtonClicked()
        {
            // Fixedボタンがクリックされたらモーダル01を開く
           ModalContainer.Find("Main").Push("Modal01", true);
        }

        private void OnLocalButtonClicked()
        {
            // Localボタンがクリックされたらモーダル02を開く
           ModalContainer.Find("Main").Push("Modal02", true);
        }

        private void OnRemoteButtonClicked()
        {
            // Remoteボタンがクリックされたらモーダル03を開く
            ModalContainer.Find("Main").Push("Modal03", true);
        }
    }
}

ビューやModalContainerは適当に作成し、[SerializeField]に参照をアサインします。
あとはSheetContainer.Show()を使ってこのシートを表示すれば、正常に動作することが確認できます。

f:id:halya_11:20211212233450g:plain
動作

上記の実装の問題点

さてここで、前節で作成した画面が大きなアプリケーションに含まれる多数の画面の一部であり、
何回も画面遷移をしてからやっと到達できる画面であると仮定します。

f:id:halya_11:20211221231313p:plain:w500
何回も遷移してやっと到達できる

この画面の動作確認をするときに、アプリケーションの最初の画面から毎回遷移していたのでは時間がかかり過ぎてしまいます。
また例えばログイン画面がうまく動作していなかったら、目的の画面に到達することすらできません。

f:id:halya_11:20211221231336p:plain:w500
ログインが壊れたら確認できない

これはワークフローや開発プロセスを考慮できていない設計上の問題であり、
開発規模が大きくなるにつれて大きな技術的負債となることが想像できます。
仮にロジックの単体テストを完璧に書いていたとしても、ビューがこれでは片手落ちです。

このような事態を防ぐために、上記のMainScreenをアタッチしたPrefabを単独でシーンに配置して再生できる環境を整えます。

さて単独で動作確認する際に、ここまでの実装ではMainScreenには以下の課題があります。

  • LOCAL 01-03の画像が全て存在しないと動作確認できない
  • 遷移先のモーダルが壊れていたり未実装だったら動作確認できない
  • URLが示す画像 (REMOTE 01-03)が全て存在しなかったら動作確認できない

これでは結局、この画面の動作確認が周辺の環境によりできなくなってしまう可能性があります。
そこで次節ではビューとロジックを分離して、より画面単独での動作確認がしやすい実装を行います。

ビューとロジックを分離する

それではまずビューから動作確認を妨げるロジックを分離していきます。
上記の問題点を解決するために、画像のパスやURLは外から与え、クリック時にはイベントだけを外側に通知するようにします。

using System.Collections;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Networking;
using UnityEngine.UI;
using UnityScreenNavigator.Runtime.Core.Sheet;

namespace ViewLogicSeparation.Scripts
{
    public class MainSheet : Sheet
    {
        [SerializeField] private Button _fixedButton;
        [SerializeField] private Button _localButton;
        [SerializeField] private Button _remoteButton;
        [SerializeField] private RawImage _fixedImage;
        [SerializeField] private RawImage _localImage;
        [SerializeField] private RawImage _remoteImage;

        public event UnityAction FixedButtonClicked;
        public event UnityAction LocalButtonClicked;
        public event UnityAction RemoteButtonClicked;

        private string _localImagePath;
        private string _remoteImageUrl;

        // LOCAL画像のパスとREMOTE画像のURLは外側から設定する
        public void Setup(string localImagePath, string remoteImageUrl)
        {
            _localImagePath = localImagePath;
            _remoteImageUrl = remoteImageUrl;
        }

        public override IEnumerator WillEnter()
        {
            _localImage.texture = Resources.Load<Texture>(_localImagePath);
            
            // テクスチャをダウンロードして取得
            var uwr = UnityWebRequestTexture.GetTexture(_remoteImageUrl);
            yield return uwr.SendWebRequest();
            _remoteImage.texture = ((DownloadHandlerTexture)uwr.downloadHandler).texture;
        }

        // 初期化処理
        public override IEnumerator Initialize()
        {
            _fixedImage.texture = Resources.Load<Texture>("tex_button_fixed");
            // クリックイベントは通知するだけ
            _fixedButton.onClick.AddListener(FixedButtonClicked);
            _localButton.onClick.AddListener(LocalButtonClicked);
            _remoteButton.onClick.AddListener(RemoteButtonClicked);
            yield break;
        }

        // 破棄時の処理
        public override IEnumerator Cleanup()
        {
            _fixedImage.texture = null;
            _fixedButton.onClick.RemoveListener(FixedButtonClicked);
            _localButton.onClick.RemoveListener(LocalButtonClicked);
            _remoteButton.onClick.RemoveListener(RemoteButtonClicked);
            yield break;
        }

        // この画面が非表示になる前の処理
        public override void DidExit()
        {
            _localImage.texture = null;
            _remoteImage.texture = null;
        }
    }
}

次にこのビューにパスやURLを渡したり、ビューのイベントを受け取ったりするPresenterを作ります。

画面遷移においては、各画面のライフサイクルイベントはビューからのイベントの一種と見なすことができます。
Presenterはこの画面のライフサイクルイベントをフックする形で、各タイミングに適した処理を行います。

UnityScreen Navigatorでは以下のように、ISheetLifecycleEventを各画面にAddLifecycleEvent()することでこれを実現します。

using System;
using System.Collections;
using UnityScreenNavigator.Runtime.Core.Modal;
using UnityScreenNavigator.Runtime.Core.Sheet;
using Random = UnityEngine.Random;

namespace ViewLogicSeparation.Scripts
{
    public class MainSheetPresenter : ISheetLifecycleEvent, IDisposable
    {
        private readonly MainSheet _sheet;

        public MainSheetPresenter(MainSheet sheet)
        {
            _sheet = sheet;
            // このPresenterのライフサイクルイベントを画面に追加している
            // 第二引数に0未満の値を与えると、画面のライフサイクルイベントより先に実行される
            // 第二引数に1以上の値を与えると、画面のライフサイクルイベントより後に実行される
            _sheet.AddLifecycleEvent(this, -1);
            _sheet.FixedButtonClicked += OnFixedButtonClicked;
            _sheet.LocalButtonClicked += OnLocalButtonClicked;
            _sheet.RemoteButtonClicked += OnRemoteButtonClicked;
        }

        public void Dispose()
        {
            _sheet.RemoveLifecycleEvent(this);
            _sheet.FixedButtonClicked -= OnFixedButtonClicked;
            _sheet.LocalButtonClicked -= OnLocalButtonClicked;
            _sheet.RemoteButtonClicked -= OnRemoteButtonClicked;
        }

        public IEnumerator Initialize()
        {
            yield break;
        }

        public IEnumerator WillEnter()
        {
            // Sheetで使う画像のパスやURLを設定する
            var localImagePath = $"tex_button_local_{Random.Range(1, 4):D2}";
            var remoteImageUrl = $"http://foo/bar/tex_button_remote_{Random.Range(1, 4):D2}.png";
            _sheet.Setup(localImagePath, remoteImageUrl);
            yield break;
        }

        public void DidEnter()
        {
        }

        public IEnumerator WillExit()
        {
            yield break;
        }

        public void DidExit()
        {
        }

        public IEnumerator Cleanup()
        {
            yield break;
        }

        private void OnFixedButtonClicked()
        {
            // Fixedボタンがクリックされたらモーダル01を開く
            ModalContainer.Find("Main").Push("Modal01", true);
        }

        private void OnLocalButtonClicked()
        {
            // Localボタンがクリックされたらモーダル02を開く
            ModalContainer.Find("Main").Push("Modal02", true);
        }

        private void OnRemoteButtonClicked()
        {
            // Remoteボタンがクリックされたらモーダル03を開く
            ModalContainer.Find("Main").Push("Modal03", true);
        }
    }
}

あとはこのPresenterにMainSheetを与えつつインスタンス化すれば、正常に動作することが確認できます。

f:id:halya_11:20211212233450g:plain
動作

なお今回はビューとの分離が目的なのでこれ以上難しく考えず、Presenterに全部処理を書いてます。

ダミーデータと共に実行する

さてではビューとロジックを分離して画面を単独で実行確認できるようになったところでダミーデータを使って実行確認してみます。
今回は適当なダミー画像のパスとURLを固定で与え、各ボタンのクリック時には適当なダミーモーダルを出しておきます。

以下のようなダミー用のPresenterを作りました。

using System;
using System.Collections;
using UnityScreenNavigator.Runtime.Core.Modal;
using UnityScreenNavigator.Runtime.Core.Sheet;

namespace ViewLogicSeparation.Scripts
{
    public class FakeMainSheetPresenter : ISheetLifecycleEvent, IDisposable
    {
        private readonly MainSheet _sheet;

        public FakeMainSheetPresenter(MainSheet sheet)
        {
            _sheet = sheet;
            _sheet.AddLifecycleEvent(this, -1);
            _sheet.FixedButtonClicked += OnFixedButtonClicked;
            _sheet.LocalButtonClicked += OnLocalButtonClicked;
            _sheet.RemoteButtonClicked += OnRemoteButtonClicked;
        }

        public void Dispose()
        {
            _sheet.RemoveLifecycleEvent(this);
            _sheet.FixedButtonClicked -= OnFixedButtonClicked;
            _sheet.LocalButtonClicked -= OnLocalButtonClicked;
            _sheet.RemoteButtonClicked -= OnRemoteButtonClicked;
        }

        public IEnumerator Initialize()
        {
            yield break;
        }

        public IEnumerator WillEnter()
        {
            // Sheetで使う画像のパスやURLを設定する
            var localImagePath = "tex_button_dummy";
            var remoteImageUrl = $"http://foo/bar/tex_button_dummy.png";
            _sheet.Setup(localImagePath, remoteImageUrl);
            yield break;
        }

        public void DidEnter()
        {
        }

        public IEnumerator WillExit()
        {
            yield break;
        }

        public void DidExit()
        {
        }

        public IEnumerator Cleanup()
        {
            yield break;
        }

        private void OnFixedButtonClicked()
        {
            OpenFakeModal();
        }

        private void OnLocalButtonClicked()
        {
            OpenFakeModal();
        }

        private void OnRemoteButtonClicked()
        {
            OpenFakeModal();
        }

        private void OpenFakeModal()
        {
            ModalContainer.Find("Main").Push("FakeModal", true);
        }
    }
}

このPresenterにビューとなるMainSheetを渡して初期化すると、実行結果は以下のようになります。

f:id:halya_11:20211221225035g:plain
実行結果

このように、ローカルやリモートの画像がまだ用意されていなかったり、
モーダルがまだ出来上がっていなかったりしても、ダミーデータを使って画面の動作確認ができるようになりました。

最後に

以上、Unity Sceen Navigatorを使ってビューとロジックを分離する方法についてまとめました。

この記事の例はあくまで一例であり、必ずしもこれが正解ではないことに注意してください。

例えば今回の例ではダイアログを別のビューとして切り出しましたが、
仕様次第では画面の中のパーツの一部としてビューに組み込んでしまう方がいいかもしれません。
画面ごとではなく、いくつかの画面をまとめた単位で動作確認できる環境を整えてもいいでしょう。

また、実際のアプリはもっと複雑で、ダミーデータを使った動作環境を整えるのにも多くの工数がかかります。
環境構築よりも目の前の実装を優先するべきだという主張もあるかもしれません。
しかしながら、「環境構築にかかる工数」<「それによって削減できる工数」であれば当然やるべきであり、
それを判断することこそが重要な仕事なのではないかと思います。

アーキテクチャの理論に振り回されて原理主義に陥らず、将来を見据えて自分の頭で総合的に判断し、
その上での道具としてUnity Screen Navigatorを活用していただけたら幸いです。

関連

github.com

light11.hatenadiary.com