XFLAG Tech Noteに学ぶ、Unity MVP設計の方法

MVP 設計 方法 IT・プログラミング

※ 本記事にはプロモーションが含まれています。

こんにちは! ねこです。

最近、エンジニアの仕事で、技術検証を兼ねた Unity で制作しているゲームのアーキテクチャ設計をしています。

私は Unity アプリのフロントエンジニア経験は、もう 4 〜 5 年くらいかなと思います。

しかし、Unity での設計というのは完全に初です。

ですので、最近の Unity 設計について、色々調べるだけ調べてみました。

その時に、参考にさせて頂いた「XFLAG Tech Note」が、私の中でもの凄く参考になったんですよね。

そんな訳で、一旦プロジェクトとは別にサンプルを作って概要を把握しつつ、この設計を丸々、既存の制作中のプロジェクトに適応していきました。

その中で得られた知見を、MVP 設計の導入をお考えのみなさまに、少しでもお役に立てるような情報を書いていこうと思います。

学ぶは、まねぶ

私は、何かを学ぶに一番良い時短方法は、同じ事を先にやっている先人で結果を出している人を完璧に真似することだと思っています。

何故なら、それは、先に先人が大規模開発と運用を実際なら行っていて、それに耐えられてるという実績がすでにあるからです。

また、そこまでの時間と労力をかけた分、何故そうしたのかを含めてエッセンスも感じることができます。

その中で、記事に書かれてないところ、どのように整合性をとって埋めていくかを考えていきました。

それだけだと、ただ先人の真似をした人、ということだけではあります。

しかし、そこまで一生懸命真似しようとすると、今まで見えてこなかったものが見えてきたのは事実です。

先にこれを自由にやらせてもらえたチームに感謝しつつ、その素晴らしいノートを、まずはご紹介させて頂きたいと思います。

XFLAG Tech Note の紹介

XFLAG さんといえば、モンストの開発・運用元で有名な会社です。

その中の方が技術書だけの同人イベント「技術辞典5」で出展していたものが「XFLAG Tech Note」となります。

それが、現在無料公開されておりまして、PDF としてダウンロードできるようになっています。

https://career.xflag.com/report/engineer/xflag-tech-note/

本当に有料級のすばらしい情報ですので、是非みてない方はみてください。

プランナーさん、QAさんなどを含めた全てのゲーム制作者向けの内容になっています。

その中の、第 3 章「とある Unity 開発事例」を参考に書かせていただきます。

参考にするなら、全て真似したほうがいい理由

結論からいうと、XFLAG Tech Note の記事を、参考に設計を考えるのであれば、まずは厳密に真似されることを強く推奨します。

やはり、こちら、本当によく考えられています。

ただ、私もそうですが、真似するのは良いとしても、丸々同じにしようぜ、というのは、さすがに躊躇する方は多いと思います。

いち技術者としては中々身もふたもなく、最悪、エンジニアとしての技量を疑われそうですし。

しかし、これには、ちゃんとした理由があるのです。

それを以下に書いていきます。

悩みを解決するために、増える悩み

まず、この記事を参考されて開発に取り掛かられる方は、同様の悩みを抱えて見られていると思います。

  • 一部が肥大化したコードが出来て、メンテに時間がかかる
  • オリジナリティ溢れるコードが溢れ、修正によるロールバックが多発する

そして、それを解決する為の設計方法を話す上で、よく参考されるのが、MVP アーキテクチャかと思います。

しかし、それを解決したかったが為に導入したアーキテクチャが、開発を進めていくうちにあやふやになり崩壊していくケースは、プロジェクトを進めていくと、よくありますよね。

それは、担当者のスキルレベルの差によるものもありますし、プロジェクトの参画タイミングによっての知見の有無もあります。

しかし、実際の問題点は以下の 3 点にあるかと思います。

問題1 フレームワーク上で如何様にも書けてしまう

しかし、一番の崩壊の原因は、いかに書き方の取り決めを行っても、フレームワーク上で、いかようにも書けてしまうことではないでしょうか。

例えば、通信 API の部分をシングルトンで書くと、どこからでも呼べて便利な反面、極論をいいますと View 部分に通信を書くことが可能になってしまいます。

これは、View に通信を書いたことがダメなのではなく、やはり、指定された方法以外ではアクセスできなくするのが正解だと思います。

それが、XFLAG Tech Note には、記述の具体例とともに記載されています。

ですので、そのまま導入すれば、必要な要素は、誰が書いても書かれるべきところに書けるはずです。

問題 2 設計には正解がない

さすがに、先ほどの View に通信処理を書いてしまうのは、コードレビューをちゃんと行っていれば解決する問題かと思います。

しかし、もっと判断について曖昧な箇所というのは意外と多く存在します。

例えば、レビュアー A さんなら正解としてしまうが、B さんは不正解としてしまう部分です。

これは、設計には正解がないという、当たり前の問題が根底にあるため、解釈の違いによって起こってしまうことでもあります。

そして、コードレビューする側も人間であるということです。

どれだけ優秀なエンジニアだとしても、レビューするときはどうしても、体調や気分に左右されることがあります。

つまり、昨日は正解と判断したことでも、今日は不正解と判断してしまうこともあるのです。

これらが続くと、チーム内に不和が生じたりします。

そして、こういうことは、担当者に、批判が向きがちです。

その結果、わかりやすい細かな部分は指摘するが、本当に大事な設計の根幹を指摘しないレビューになってしまいかねません。

繰り返しになりますが、問題の本質は各個人ではなく、そもそも設計に正解がないのが問題なのです。

そこも、完全にこの資料に記載されている事を正としまうというルールにすれば、その曖昧なレビューそのものが無くなります。

レビューイもレビュアーも、それだけに注力すればよく、心理的負担も、時間的負担も減ると思います。

問題 3 全ての人が同じ言葉を同じ意味として理解していない

当たり前ですが、アーキテクチャ設計は、その専門職がいるくらいですので、非常に難しいことなのです。

また、XFLAG Tech Note の記事では、Model 部分の概念を細かく分割していますので、さらに輪をかけて難しいはずです。

ですので、中途半端に真似してしまうと、名前だけは一緒の、意図が全く違うものができてしまいがちです。

また、下手したら、設計をする側が間違えて理解しているケースもあり、よりいっそう混乱に拍車をかけてしまう結果になりかねません。

それであれば、全員が、同じ言葉で理解できるようになるまでは、せめて全く同じものを作るほうがいいのではないでしょうか。

そうすることで、未来に起こるであろう誰かへの罪づくりという、不毛な時間を意外と減らしていけるかもしれません。

分からなければ、徹底的に真似しよう

真似は恥ではありません。わからないことを理解できれば、真似は理解するための最速の手法です。

一番まずいのは、中途半端に真似して、その結果、理路整然としてないコードを残すことです。

結果、解決しようとしたつもりが、当初の問題に逆戻りしてしまうのが目にみえてしまうからです。

この話の上で、XFLAG Tech Note の考えを導入する上で必要な MVP の理解を深めていきましょう。

MVP の概要を再定義

ここでちょっと、XFLAG さんも採用している、MVP 設計の概念を再度書いていこうと思います。

XFLAG Tech Note があるのに、なぜ? と思う方もいるかもしれません。

それは、これを書いておくことにより、より XFLAG Tech Note の記載内容を直感的に見やすくなるであろうと思ったからです。

ところで、なぜ、Unity 開発に MVP アーキテクチャが流行り出したのでしょうか。

Unity 開発で、MVP 設計が良い理由

MVP 設計は、Web アプリケーション開発のフレームワークで発展していった経緯のあるものです。

それが、なぜ Unity 開発で応用されるようになっていったのでしょうか。

もちろん、それぞれの事例に対しては、各社の都合もあるかと思います。

しかし、以下のような世間の流れがベースにあると思います。

  • スマホのソーシャルゲームが隆盛を極めたことで、ブラウザベースのアプリ開発しているエンジニアが、かなり流入した。
  • ゲームがモバイル、PC問わず、運用を前提とするビジネスモデルが主流になったため、考えが似ている Web アプリケーションなどと同様の設計を導入した方が都合がいい。

MVP 設計は、Web 開発に置き換えて説明されたら、おそらく非常に理にかなった強力な設計だと理解できると思います。

そのため、Web アプリケーション開発を行ったことのない Unity エンジニアも、ぜひ先に理解しておくと、その後のやりとりがスムーズになると思います。

ですので、まず、Web アプリケーションの簡単な概要から見ていきましょう。

Web アプリケーションの仕組み

Web アプリケーションの仕組みを簡単に説明します。

Web アプリケーションは、端的にいうと、データベース(エクセルのようなリスト)から、データを加工して、画面に出力することで動きます。

Web アプリケーションは、データベースからなんらかの処理を行い、画面に表示されている図

その、「何かしらの加工と出力」を行っている部分を、XFLAG Tech Note の内容にあわせて、より具体的に見ていきましょう。

まず、アプリケーションを動かす場合、データベースから、Rest API を経由してデータを受け取ります。

この時、ページ内の各要素は、すべてのデータが必要ではありませんので、必要なデータを厳選して取る必要があります。

それを各要素の Repository が Entity というデータの入れ物に入れ、管理します。

データベースから Rest API を介して必要なデータを Repository が保持する図

ページ内にビジュアルとして表示される項目を、各ブロックの Presenter が、Repository から得たデータを取得して View に表示します。

Webアプリケーションの MVPでデータベースから表示されるまでの図

もし、View 側で、データの変更があったら、その変更を監視してる Presenter が、ビジネスロジックが書かれてある Usecase をつど呼び出し、repository 経由で、データ層にアクセスします。

そして、データ層の値が変更されたら、それを監視している、それぞれの Repository が、値を受け取り、また同じように View まで進むのです。

MVP 設計でブラウザの View の操作でデータを変更する図

これをブロック分けすると、つぎのようになります。

  • データ層 … データベース(Rest API)
  • アプリケーション層 … Repository, UseCase(Entity)
  • プレゼンテーション層 … Presenter, View

これを見ると、各要素は、それぞれ同士が繋がりなく、データ層を経由して各々が好き勝手(?)に振る舞っているのがよく分かると思います。

簡略化すると、こんな感じです。

MVP 簡略図

XFLAG さんが Unity への設計の際に行ったこと

では、実際に XFRAG のエンジニアさんが Unity に設計の移植を行う際、どのようにしたかを見ていきましょう。

ねこ
ねこ
ここ以降の内容は、XFLAG Tech Note で省略されてた部分を、憶測で埋めてる部分もありますので、解釈の間違えがある可能性があります。

それを踏まえたうえで、開発の参考になればと思います(間違えは、ご指摘頂けると嬉しいです)。

まず、モバイルのソーシャルゲームは、ユーザーデータなどはもちろんサーバの情報が正になりますので、頻繁にサーバ側とのやり取りが発生します。

しかし、Web サービスのように、必ずしもずっと通信させる訳ではありません。

例えば、キャラクターなどのアセットは、必要になる前に、先にダウンロードさせて、ローカルにキャッシュさせるのが通常です。

つまり、データの取得と、使用のタイミングが別々になるケースが多々あります。

ですので、通信するタイミングを出来るだけ纏めて行う必要があり、その途中のデータはモバイル内のローカルキャッシュでまかなう必要があります。

そこを XFLAG さんは、ILoader インターフェースを継承した Loader クラスを作り、纏めてデータを先読みしているようです。

そして、先読みデータを持っている Loader クラスを使って Repository を初期化しています。

Unity MVPでデータベースから表示されるまでの図

つまり、Repository が本来持っている API 通信まで行うという機能はなく、そのかわり、キャッシュしている Entity などのデータは、お互いに参照できるようになっています。

では、データの変更をどのようにして行うかというと、UseCase を介して、APICliant クラス経由で値の変更などを行なっているのです。

これが、想定される図になります。

MVP 設計で Unity の View の操作でデータを変更する図

資料になかった部分の記載例

ここでは、上記を踏まえた上で、記載になかった部分をどのように記述したかを書いていこうと思います。

実装した内容を大幅に簡略化して記載していますので、ご了承ください。

また、現在は私は絶賛リファクタリング中だったりします 笑。

各 Presenter クラスの初期化

ページ(シーン)の上位クラスから、各 Presener を初期化します。

using UnityEngine;
using UniRx.Async;

/// <summary>
/// サンプルページ
/// </summary>
public class Main : MonoBehaviour
{
    /// <summary>
    /// Presenter サンプル 01
    /// </summary>
    [SerializeField]
    private Sample01Presenter sample01Presenter = default;

    /// <summary>
    /// Presenter サンプル 02
    /// </summary>
    [SerializeField]
    private Sample02Presenter sample02Presenter = default;

    void Start()
    {
        InitializeAsync().Forget(Debug.LogException);
    }

    /// <summary>
    /// 初期化
    /// </summary>
    /// <returns></returns>
    private async UniTask InitializeAsync()
    {
        // データ送信とアクセスに必要な API クラスの初期化
        SampleApiCliant sampleApiCliant = new SampleApiCliant();

        // 必要なデータの取得
        SampleDataLoader sampleDataLoader = new SampleDataLoader();
        bool isLoaded = await sampleDataLoader.GetAllObjectsAsync();

        if (isLoaded)
        {
            // ロード完了したら 各 Presenter を初期化
            Sample01Repository sample01Repository = new Sample01Repository(sampleDataLoader);
            sample01Presenter.Initialize(sampleApiCliant, sample01Repository);

            Sample02Repository sample01Repository = new Sample02Repository(sampleDataLoader);
            sample02Presenter.Initialize(sampleApiCliant, sample02Repository);
        }
        else
        {
            // TODO: 何度か試行するロジックを入れる
        }
    }
}

Entity クラス

各 Entity は取得後、ほとんどの場合は、XFLAG Tech Note P31 にあるようなCharacterDetail, CharacterListContent のようなクラスに Entity を内包して使用します。

その時に、そのシーンで使用するパラメータやステート、アセットなどのデータも必要であれば追加します。

以下、記述例です。

using UnityEngine;

public class Xxxx01Entity
{
    public int param_01;
    public float param_02;
    public string param_03;
}

public class XxxxEntityForSample
{
    private Xxxx01Entity entity;

    public XxxxEntityForSample(Xxxx01Entity entity)
    {
        this.entity = entity;
    }

    public int Param01 => entity.param_01;

    public string Param03
    {
        get { return entity.param_03 }
    }

    public Vector3 param_04 = Vector3.zero;
}

Loader クラス

ILoader インタフェースを持つ、LoaderBase クラスを作り、そこに共通の通信クラスを記述します。

各画面には、それをオーバーライドした個別の Loader クラスを制作し、初期化時に、必要なデータを取得するようにしています。

using System.Collections.Generic;
using UniRx.Async;

public interface ILoader
{
    /// <summary>
    /// この Loader で使用する全てのオブジェクトを取得
    /// </summary>
    /// <returns>取得処理は問題なく完了したか?</returns>
    UniTask<bool> GetAllObjectsAsync();

    /// <summary>
    /// 取得したデータをロードする(Loader キャッシュから、オブジェクトの受け取り)
    /// </summary>
    /// <typeparam name="T">オブジェクト(Entityを内包するデータを想定)</typeparam>
    /// <returns>ロードしたデータ</returns>
    T Load<T>();
}


public class LoaderBase : ILoader
{
    private List<object> objects = new List<object>();

    /// <summary>
    /// 取得したデータをロードする(Loader キャッシュから、オブジェクトの受け取り)
    /// </summary>
    /// <typeparam name="T">オブジェクト(Entity, EntityCache を想定)</typeparam>
    /// <returns>ロードしたデータ</returns>
    public T Load<T>()
    {
        T targetObject = (T)objects.FirstOrDefault(@object => typeof(T) == @object.GetType());
        return targetObject;
    }

    /// <summary>
    /// この Loader で使用する全てのオブジェクトを取得
    /// </summary>
    /// <returns>取得処理は問題なく完了したか?</returns>
    public async virtual UniTask<bool> GetAllObjectsAsync()
    {
        Debug.LogError("このメソッドはオーバーライドしてお使いください");
        return false;
    }

    /// <summary>
    /// Loader キャッシュに、オブジェクトを追加する
    /// </summary>
    /// <param name="targetObject">追加したいオブジェクト</param>
    protected void AddObject(object targetObject)
    {
        objects.Add(targetObject);
    }

    //〜〜〜〜以下、API 通信によるデータ取得、ローカルキャッシュの取得などの処理
}


public class SampleDataLoader : LoaderBase
{
    /// <summary>
    /// この Loader で使用する全てのオブジェクトを取得
    /// </summary>
    /// <returns>取得処理は問題なく完了したか?</returns>
    public async override UniTask<bool> GetAllObjectsAsync()
    {
        // 必要なデータを Entity クラスに読み込み
        Xxxx01Entity xxxx01Entity = await base.GetServerData<Xxxx01Entity>(api, param); // TODO: こんな感じの処理を追加

        // 必要なデータを内包したクラスの生成
        XxxxEntityForSample xxxxEntityForSample = new XxxxEntityForSample(xxxx01Entity);

        //TODO: ここで、必要であれば、画像アセットなども
        // xxxxEntityForSample.param_04 = await LoadTextureDataAsync(xxxxEntityForSample.param_03);

        // Loader に追加
        base.AddObject(xxxxEntityForSample);

        // 〜〜〜〜以下、必要な数分のロードを行う
    }
}

ApiCliant クラス

基本的には、Loader クラスと同じような構成ですが、こちらは、初期化時に、インターフェースではなく、実際に使用するクラスを引数で渡しています。

Tech Note のサンプルだと、インターフェースを渡してるようですが、メソッド名から汎用的な使用法を想定できませんでした。。

Repository クラス

初期化時に、必要である Entity を内包したデータを取得します。

他のページがメインで利用する Entity を内包したデータでも、何か作用させたければ、初期化時に一緒に取得しておきます。

using UniRx;
using UniRx.Operators;
using System;

public class Sample01Repository
{
    private XxxxEntityForSample01 xxxxEntityForSample01;
    private XxxxEntityForSample02 xxxxEntityForSample02;


    public MainUIRepository(ILoader loader)
    {
        // 必要な分のデータを取得
        xxxxEntityForSample01 = loader.Load<XxxxEntityForSample01>();
        xxxxEntityForSample02 = loader.Load<XxxxEntityForSample02>();
    }

    //〜〜〜〜以下、必要なパラメータを外部から読めるように。値変更を監視したい場合は UniRx を使う

    public int Param01 => xxxxEntityForSample01.Param01;

    public float Param08 => xxxxEntityForSample02.Param08;

    public IObservable<int> OnChangedParam01AsObservable
    {
        get { return this.ObserveEveryValueChanged(_ => _.Param01); }
    }
}

Presenter クラス

ApiCliant については、実際のクラスを引数でもらっています。

View については、必要な表示要素を、必要な分保持します。

using UnityEngine;
using UniRx.Async;
using UniRx;
using UnityEngine.UI;

    /// <summary>
    /// カットイン演出を管理する Presenter
    /// </summary>
    public class CutinPresenter : MonoBehaviour
    {
        [SerializeField]
        private Text text = default;

        [SerializeField]
        private ViewSample01 viewSample01 = default;

        [SerializeField]
        private ViewSample01 viewSample02 = default;


        private CutinRepository repository;
        private BattleApiCliant apiCliant;

        /// <summary>
        /// 初期化
        /// </summary>
        /// <param name="apiCliant">使用する ApiCliant</param>
        /// <param name="repository">使用する Repository</param>
        public void Initialize(BattleApiCliant apiCliant, CutinRepository repository)
        {
            this.repository = repository;
            this.apiCliant = apiCliant;

            // イベントハンドリングの追加
            AddEventHandling();
        }

        /// <summary>
        /// イベントハンドリングの追加
        /// </summary>
        private void AddEventHandling()
        {
            // viewSample01 の変更を検知して、UseCase 経由でデータを更新するサンプル
            viewSample01.OnChangeParamAsObservable
                .Subscribe(async param =>
                {
                    var useCase = new ChangeParamUseCase(apiCliant, repository);
                    if (await useCase.Run(characterId, newName).Forget(Debug.LogException))
                    {
                        viewSample01.SetParam(param);
                    }
                    else
                    {
                        // 失敗ダイアログなど
                    }
                })
                .AddTo(gameObject);
        }
    }

最後に

こういうのって、考えるのは楽しいですよね。

よければ、感想をコメント、Twitter などにいただけると嬉しいです。

重ねて、すばらしいドキュメントを公開してくださった XFLAG さんと、該当部分を執筆した いもさん(Twitter: @adarapata)に感謝しつつ、終わりたいと思います。

長文お読みいただき、ありがとうございました。

テスト駆動開発について

今回書ききれなかった、テスト駆動開発についての内容をまとめました。

こちらも合わせて、ご参考ください。

[sitecard subtitle=関連記事 url=/about-tdd-at-xflag-tech-note/ target=]