💭

Unity ゲーム開発で使うクリーンアーキテクチャの勘所

に公開


https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

クリーンアーキテクチャといえば、この図が有名かと思います。
4層からなるアーキテクチャで、参照の方向性については多くの書籍や技術記事などで説明を目にするかと思います。しかし、抽象的な説明でわかりにくかったり、具体例が Web の話だったりするため、Unity のゲームプロジェクトにどのように落とし込めばよいのか? MVP やその他のアーキテクチャと比較して何がメリットなのか? など具体的なイメージができないままモヤモヤしている人も多いのではないでしょうか? 私もそうでした。

最も重要なこと

私と同じような経験をされている方にとって、最初に理解すべきは4層であることでも参照の方向性でもありません。最も重要なのは図の右下にあるくねくねとした矢印の意味を理解することです。

このくねくねとした矢印は処理の流れを表しています。InputPort や OutputPort も最初の理解を得るためには邪魔な概念なのでいったん忘れてください。すると、処理の流れは次のようになることが分かります。

Controller → Interactor → Presenter

すごくシンプルになったと思いませんか?

では、Controller、Interactor、Presenter これらのクラス種別が何を意味するのか見ていきましょう。

その前に、本記事では4層のレイヤーを以下のように定義します。
内側から Entity 層、UseCase 層、InterfaceAdapter 層、FrameworkAndDriver 層、人によって層のネーミングが違ったりしますが、本記事では原典に従うことにします。「〇〇層」のように必ず層を付けるようにしますので、その場合はレイヤーの事を言っているのだと思ってください。

Interactor

3つのクラスの中で核となるのは Interactor なので、まずは Interactor について説明します。
Interactor という聞きなれない単語に戸惑う方もいるかもしれませんが、Interactor とは UseCase の実装です。そして UseCase とはアクター(操作者、または外部システム)とシステムとの間で、特定の目的を達成するためのやり取りを記述したシナリオとされています。

例えば、ゼルダのようなゲームをイメージしてください。ボタンを押したら「プレイヤーがアイテムを拾う」と思いますが、それが UseCase です。「プレイヤーがアイテムを拾う」という機能を実装したクラスが Interactor というわけです。

public class PickUpItemInteractor
{
    public void Execute()
    {
        // TODO: プレイヤーがアイテムを拾う処理
    }
}

Controller

Controller は、Interactor を呼び出すためのクラスです。
ボタンイベントを受け取った View(MonoBehaviour)が Controller を呼び出し、Controller が Interactor を呼び出します。UseCase における「入力」の役割を担います。

View → Controller → Interactor

普段 Controller は左から右へバケツリレーしているだけの薄い存在のため、View が直接 Interactor を呼び出してはダメなのか?と思われるかもしれません。次のようなパターンです。プロジェクトの規模によってはこれでも良いかもしれません。

public class ControllerView : MonoBehaviour
{
    [SerializeField] private Button button;

    [Inject]
    public void Construct(SomethingInteractor interactor)
    {
        this.button.onClick.AddListener(() => interactor.Execute());
    }
}

View と Controller を分けるメリットとしては、入力データを加工して Interactor に渡す必要があるとき、また呼び出し箇所が複数あるときに、Controller に共通の処理を任せれば何度も同じことを書かずに済みます。モックとの差し替えなども容易になります。将来の拡張性を考えるとこの薄いレイヤーがあることによって柔軟性が高まります。

具体的な入力データの加工としては FPS や TPS などでゲームパッドの入力 Vector2 をカメラの Transform を考慮して計算し、プレイヤーの移動方向 Vector3 に変換する場合などがあります。

public class PlayerController
{
    [Inject] private readonly MovePlayerInteractor movePlayerInteractor;

    public void Move(Vector2 moveInput, float deltaTime)
    {
        // ゲームパッドの入力をプレイヤーの移動方向に変換
        Vector3 direction = this.CalculateDirectionRelativeToCamera(
            moveInput,
            Camera.main.transform);

        // プレイヤー移動の Interactor を実行
        this.movePlayerInteractor.Execute(direction, deltaTime);
    }
}

Presenter

Presenter は Interactor から呼び出されます。
Controller が入力であるのに対し Presenter は出力です。

「プレイヤーがアイテムを拾う」という UseCase を例にすると、りんごを拾ったときに「りんごを拾いました」というメッセージの書かれたダイアログを表示するとします。このとき、ダイアログ(ビュー)へ指示を出すのが Presenter の役割です。

Interactor → Presenter → View

何故 Interactor は Controller に値を返さず、Presenter を呼び出すのか疑問に思った人もいるでしょう。例えば次のような処理にすることも可能なはずです。

public class PlayerController
{
    [Inject] private readonly PickUpItemInteractor pickUpItemInteractor;
    [Inject] private readonly DialogManager dialogManager;

    public async UniTask PickUpItemAsync()
    {
        // アイテムを拾う Interactor を実行
        var item = this.pickUpItemInteractor.Execute();

        // 拾ったアイテムをダイアログに表示
        await this.dialogManager.ShowAsync($"{item}を拾いました");
    }
}

入力に対して出力が常に1対1の関係であるならこれで問題ないかもしれません。

しかし、ゼルダなどで「プレイヤーがアイテムを拾う」を行った時、そのアイテムが初めて入手したものであればゲーム内の時間の流れを止めて画面中央にアイテムの詳細情報が書かれたダイアログが表示されます。アイテムが初めて入手したものでないときは画面端にトーストでログが表示されるだけで、ゲーム内の時間は止まりません。このような場合はどうすれば良いでしょうか?

実は最初に紹介したクリーンアーキテクチャの図には載っていませんが、Interactor は Presenter を何個でも持つことができます。コードにすると次のような感じになるかと思います。

public class PickUpItemInteractor
{
    [Inject] private readonly DialogPresenter dialogPresenter;
    [Inject] private readonly ToastPresenter toastPresenter;

    public async UniTask ExecuteAsync()
    {
        ... // アイテム獲得処理

        // 初めて入手したものかどうかで分岐
        if (isFirstTime)
        {
            // 時間を止めてダイアログを表示
            this.PauseGameTime();
            await this.dialogPresenter.HandleAsync(item);
            this.ResumeGameTime();
        }
        else
        {
            // トーストを表示
            this.toastPresenter.Handle(item);
        }
    }
}

「プレイヤーがアイテムを拾う」という処理の全体像を Interactor を見るだけで把握できます。

責務を明確にする

もし Interactor が Presenter を呼び出すのではなく、Controller に戻り値を返すとしたら、 isFirstTime も戻り値に含めて Controller で分岐させる必要が出てきます。ゲームに仕様変更が発生したら、Interactor と Controller の両方の流れを把握したうえで変更を加える必要があるため大変です。

開発していると、どこまでがひとつの UseCase なのか?といった粒度に悩むこともありますが、「プレイヤーがアイテムを拾う」に関しては、初めて入手したものだったら時間を停止してダイアログを表示する、という一連の処理をまとめてひとつのシナリオとした UseCase で問題ないでしょう。isFirstTime による分岐を InterfaceAdapter 層(Controller, Presenter)に委ねるのは明らかに責務を超えています。

UseCase 層と Entity 層をアプリケーションの内側とするなら、FrameworkAndDriver 層はゲームエンジンなどによる具体的な表現を伴う外側の世界です。InterfaceAdapter 層は内側と外側を分離するための薄い膜のようなものです。なんとなくイメージできたでしょうか?

層のことはいったん忘れてもらい、処理の流れを説明してきましたが、結局は層の話になってしまいました。

InputPort と OutputPort

最初に「InputPort や OutputPort も最初の理解を得るためには邪魔な概念なのでいったん忘れてください。」と書きましたが、ここまで来れば思い出してもらっても大丈夫かと思います。

これまで説明のコードは直接 Interactor や Presenter などのクラスを使っていましたが、これではテストやモックへの切り替えが困難になります。そこで IInputPort, IOutputPort として、入出力をインターフェースにします。

IInputPort: Interactor が継承して実装する
IOutputPort: Presenter が継承して実装する
public class PickUpItemInteractor : IPickUpItemInputPort
{
    [Inject] private readonly IDialogOutputPort dialogOutputPort;
    [Inject] private readonly IToastOutputPort toastOutputPort;
    ...
}

また、Presenter は Interactor よりも外側の層に位置するので、IOutputPort は IoC(Inversion of Control / 制御の反転)を実現するためでもあります。

補足

ゼルダの場合、実際には「アイテムを拾う」以外に「話しかける」や「調べる」などの機能も含まれており、近くにあるものによって挙動が変わります。説明を簡単にするために「アイテムを拾う」だけに注目しましたが、本当は「アイテムを拾う・話しかける・調べる」これらをひとまとめにした UseCase になり、実装はもっと複雑なものになるかと思います。

宣伝

記事のサンプルコードに [Inject] というアトリビュートがあったと思いますが、これは一般的な DI コンテナによくあるアトリビュートで [SerializeField] のようにクラス参照を注入できる仕組みです。

クリーンアーキテクチャのような設計を導入しようとした場合、DI コンテナは必須になるかと思います。そうでなくともピュア C# で書かれたクラスを Inject できる仕組みは public static なシングルトンインスタンスを使うよりも柔軟性が増します。

ここで紹介はしませんが、無料で高機能・高性能な DI コンテナは多数あります。
そんな中、拙作の DI コンテナである Simpleton をついにリリース致しました。
Unity Asset Store にてなんと有料で販売中です😅
メリットとしては DI のためのコード量を減らし、学習コストを抑えられることが特徴です。
https://assetstore.unity.com/packages/tools/utilities/simpleton-326289

詳しくはこちらのユーザーガイドをご覧ください。
https://zenn.dev/fig_codefactory/books/ec12aa6d573c3b

参考にしたもの

https://izumisy.work/entry/2019/12/12/000521

Discussion