🍁

弊社Unityアーキテクチャの凄いところを語る

2024/12/11に公開

この記事はambr, Inc. Advent Calendar 2024の11日目の記事です。


開発お疲れ様です。 私はambrのUnityエンジニアのtanitaka-techと言います。今回はとあるアプリ開発用に作ったアーキテクチャをご紹介します

私は設計が大好きで、今回紹介するアーキテクチャも何度も破壊しながら楽しく育ててきました。この記事で少しでも設計の楽しさが伝わると嬉しいです。

コードの分類がすごい

弊アーキテクチャでは、コードを下図のように分類しています。

コードの最適化を目的とする、共通クラスの分類(EngineeringSide)

名前 責務 クラスの一例
Module 複雑な処理やフローのWrapper API呼び出し、シーン読み込み、ストアURLを開く
State 状態変数の管理 ユーザー名、カメラ座標、選択中のアイテムID
Request フロー処理をSequenceにリクエスト 画面を閉じるRequest、シーン遷移Request

アプリ仕様の実現を目的とする、フロント疎通の分類(PlanningSide)

名前 責務 クラスの一例
Object 入力のハンドリング、状態に応じた挙動変化 トグルボタン、カメラ
Sequence 画面フローの制御 タイトル画面、汎用モーダル画面

大分類の役割

大分類 目的 テスト 職能間コミュニケーション
EngineeringSide コードの最適化 ユニットテスト サーバー
PlanningSide アプリ仕様の実現 E2Eテスト、コンポーネントテスト プランナー、デザイナー

分類によるメリット

こうした分類は、作っておくと下記のメリットがあります。

  • コードを書く際の迷いが減る
  • チームのコミュニケーションが円滑になる
  • コード規約を作りやすい
  • アーキテクチャ運用のロードマップを提示できる

物事を複雑にしているようにも見えますが、意外と役に立つのです。

コードレイヤーの整理がすごい

弊アーキテクチャでは、コードのレイヤーを6つに分割しています。
Installer, Domain, Presenter, View, Behaviour, Sequenceの6つです。
各レイヤーの責務と依存関係を図で表すと下記になります。

具体的なUnity実装

UnityにはAssemblyDefinitionという機能があります。これはコードの依存関係を明示することで不要なコンパイルを削減するための機能です。

このコードの依存関係を明示する特性を活かし、AssemblyDefinitionでレイヤー毎の依存関係を構築するという手法がUnity界隈にはあります。(参考リンク)

ただ、この手法はディレクトリの制約が辛く、情報のまとまりや運用コスト面で難しい問題が発生しました。

AssemblyDefinitionで試行錯誤した構成パターン集

パターン① レイヤーディレクトリに各機能を配置していく

├── Installer/
│   ├── FeatureAInstaller.cs
│   ├── FeatureBInstaller.cs
│   └── Installer.asmdef
├── Presenter/
│   ├── FeatureAPresenter.cs
│   ├── FeatureBPresenter.cs
│   └── Presenter.asmdef
├── View/
│   ├── FeatureAView.cs
│   ├── FeatureBView.cs
│   └── View.asmdef
問題点
  • 機能1つ作るたびにディレクトリを右往左往しなければいけない
  • 構造上、各機能を意味を持ったディレクトリに配置するのが難しい

パターン② 機能毎にレイヤーディレクトリを作って管理

├── FeatureA/
│   ├── Installer/
│   │   ├── FeatureAInstaller.cs
│   │   └── FeatureAInstaller.asmdef
│   ├── Presenter/
│   │   ├── FeatureAPresenter.cs
│   │   └── FeatureAPresenter.asmdef
│   └── View/
│       ├── FeatureAView.cs
│       └── FeatureAView.asmdef
├── FeatureB/
│   ├── Installer/
│   │   ├── FeatureBInstaller.cs
│   │   └── FeatureBInstaller.asmdef
│   ├── Presenter/
│   │   ├── FeatureBPresenter.cs
│   │   └── FeatureBPresenter.asmdef
│   └── View/
│       ├── FeatureBView.cs
│       └── FeatureBView.asmdef
問題点
  • 機能1つ作るたびに各asmdefの依存関係整理が大変

色々と研究を重ねた結果、最終的にAssemblyReferenceを使った下記のような構成に落ち着きました。

├── LayerAssemblies/
│   ├── Installer/
│   │   └── Installer.asmdef
│   ├── Presenter/
│   │   └── Presenter.asmdef
│   └── View/
│       └── View.asmdef
├── FeatureA/
│   ├── Installer/
│   │   ├── FeatureAInstaller.cs
│   │   └── Installer.asmref
│   ├── Presenter/
│   │   ├── FeatureAPresenter.cs
│   │   └── Presemter.asmref
│   └── View/
│       ├── FeatureAView.cs
│       └── View.asmref
└── FeatureB/
    ├── Installer/
    │   ├── FeatureBInstaller.cs
    │   └── Installer.asmref
    ├── Presenter/
    │   ├── FeatureBPresenter.cs
    │   └── Presenter.asmref
    └── View/
        ├── FeatureBView.cs
        └── View.asmref

LayerAssembliesに各レイヤーのasmdefを配置し、各機能のディレクトリからasmrefを介して参照しています。 この構成であればディレクトリ制約も依存整理コストも無く、デメリット無しでレイヤー分けの恩恵を受けられます

コード作成コスト対策

ところで、6つもレイヤーがあるとコードの作成も大変です。 実装の度に毎回上にあるようなたくさんのファイルを用意するのはかなりの苦行です。

この対策として、実装の種類毎にテンプレートからファイルを自動生成する仕組みを作っています。


Objectテンプレートからファイルを作る様子

辛い運用は案外そういうものと思って飲み込んでしまう方も多いため、常に気を配っていきたいです。

縦のアーキテクチャがすごい

縦のアーキテクチャが独自の言葉なので説明させてください。
SceneやContextの階層構造や階層間の各コンポーネントの通信設計をどうすれば良いか考える概念を縦のアーキテクチャと呼んでいます。

DIコンテナのContext

Unityはコンポーネント指向です。各コンポーネントは独立して勝手に動くため、コンポーネント間で同じ情報を扱うには工夫が必要になります。

近年この"コンポーネント間で同じ情報を扱う工夫"の1つとして評価が高いのがDIコンテナです。

UnityのDIコンテナ界隈にはContextという概念があります。これはDIコンテナを階層に分割する仕組みのことで、だいたい大まかに3つの階層があります。(細かい仕様はDIコンテナごとに違いがあります)

このような階層構造があると、DIコンテナに割り当てるモジュールの影響範囲を明示できたり、情報のまとまりを作ることができるため、コード運用上非常に有用です。

弊アーキテクチャではこうしたDIコンテナのContextをフル活用しています。

Sceneにコンポーネントをどう配置するか問題

Scene内のGameObjectにどのようにコンポーネントを配置するかも重要です。例題として下記のようなタイトル画面は、どのようにコンポーネントを配置すると良いでしょうか

まず比較として用意したのが、1つだけコンポーネントを配置して、そこでScene内の入力を全てハンドリングする実装です。


public class TitleScene : MonoBehaviour
{
    [SerializeField] private Button _createAccountButton;
    [SerializeField] private Button _loginButton;
    [SerializeField] private Button _termsAndPrivacyButton;
    
    private void Start()
    {
        _createAccountButton.onClick.AddListener(() => SceneManager.LoadScene("CreateAccountScene"));
        _loginButton.onClick.AddListener(() => SceneManager.LoadScene("LoginScene"));
        _termsAndPrivacyButton.onClick.AddListener(() => Application.OpenURL("https://gogh.gg/privacy"));
    }
}

この実装はシンプルで必要十分ですが、チーム開発の場合「今後の保守のしやすさ」もレビュー観点になるため、こうした実装にも積極的にフィードバックがあります。

  • もし、各Buttonに追加実装をするとしたらButtonの修正なのにSceneのコード修正になってしまいそう
  • もし、SceneからButtonが消えたらエラーになってしまいそう
  • もし、このコードを参考にして他の複雑なSceneを実装したら大変なコードになってしまいそう

こういう時に毎回SOLID原則を引用して「保守しやすさ」について話していると大変です。そのため、アーキテクチャでは「保守しやすいパターン」というのを作って、チームでそのパターンを使い倒します

弊アーキテクチャでコンポーネントを配置する際は、基本的に入力1つ1つにコンポーネントを割り当てます

└── TitleScene/
    ├── SceneContext.cs
    ├── TitleSceneInstaller.cs
    ├── CreateAccountButtonObject/
    │   ├── GameObjectContext.cs
    │   ├── CreateAccountButtonObjectInstaller.cs
    │   └── CreateAccountButtonObjectView.cs
    ├── LoginButtonObject/
    │   ├── GameObjectContext.cs
    │   ├── LoginButtonObjectInstaller.cs
    │   └── LoginButtonObjectView.cs
    └── TermsAndPrivacyPolicyTextObject/
        ├── GameObjectContext.cs
        ├── TermsAndPrivacyPolicyTextObjectInstaller.cs
        └── TermsAndPrivacyPolicyTextObjectView.cs

なんとコンポーネントの数は合計で11個になりました。これはよっぽどの理由がないと許されません。そのため、下記のような理由を並べてなんとか許してもらいます。

  • Sceneと各Buttonが疎結合になることで、各コンポーネントの責務が明確になる
  • 今後追加実装があった時に、影響範囲を抑えられるのでエンバグのリスクを減らせる
  • シンプルなSceneも複雑なSceneも、同じパターンで実装されていると分かりやすい

地味にこの時の交渉は大事です。「これが正しいので従ってください!」という言い方だと反感を買うため、リスペクトを忘れないスタンスでのコミュニケーションを意識しています。

画面フローどうするか問題

画面に決まった一連の流れがある時の実装も厄介です。こうした実装は画面全体で協調して処理を進める必要があります。下記はタイトル画面の処理の流れです。

このフローチャートでは、入力待機以外の時はボタンが反応しないことを期待しています。

Unityで特定のタイミングだけボタンを反応させたくない場合、実装としては色んな手法が考えられます。

  • 押されたくない時だけ透明なUIを被せて、ボタンを押せないようにする
  • 各ボタンで入力待機状態に依存して、状態によって入力を分岐させる

これらのやり方は悪くはないですが、いずれも全体の流れを理解していないと、各処理の意図が読み解けないという怖さがあります。もう少しフローチャートに沿ったコード表現にしたいです。

弊アーキテクチャでは、Sequenceと呼ばれる処理の流れ専門のクラスを各Sceneに1つ用意しています。 Sequenceは、各入力コンポーネント(Objectと呼んでいます)からのRequestを待ち受けてフロー制御を行います。

各Objectは今どんな状態だろうがお構いなしにRequestを送るので、Objectはフローに関して全く意識しません。フロー制御はSequenceの責務だからです。

こうすることで、フロー情報がSequenceにまとまるため、Sequenceがフローチャートそのものになるようなコード表現を実現できます。実際のコード例は次の章で紹介します。

横のアーキテクチャがすごい

横のアーキテクチャは、コンポーネントの責務をコード的にどう分割したら分かりやすいかを考える概念です。

Sceneを縦のアーキテクチャで分割したらSequenceとObjectが出てきましたが、これらをさらに横のアーキテクチャで分割していくイメージになります。

論よりコードということでこの章はコード例が多めで、説明が少なめですが、気になるところがあれば気兼ねなく質問していただけると幸いです。

Sequenceの横のレイヤー

Sequenceは下記の3つのクラスに分解されます。

  • Installer
  • Sequence
  • Behaviour(Option)
タイトル画面のコード例

TitleSceneInstaller

/// <summary>
/// TitleSceneのInstaller(SceneContext)
/// </summary>
public class TitleSceneInstaller : MonoInstaller, IInitializable
{
    [SerializeField, NotNull, Header("Appear時の挙動")] private SerializableInterface<IAppearBehaviour> _appearPlayTimelineBehaviour;
    [SerializeField, NotNull, Header("Disappear時の挙動")] private SerializableInterface<IDisappearBehaviour> _disappearTitleSceneBehaviour;

    // ProjectContextへの依存
    [Inject] private ILoginSceneLoader _loginSceneLoader;
    [Inject] private IBeginningAvatarCustomSceneLoader _beginningAvatarCustomSceneLoader;

    private TitleSceneSequence _sequence;

    public override void InstallBindings()
    {
        // Requests
        var moveToCreateAvatarSceneRequestHandler = new RequestHandler<MoveToCreateAccountRequest>();
        Container.BindInstance<IRequestPusher<MoveToCreateAccountRequest>>(moveToCreateAvatarSceneRequestHandler);
        var openPrivacyPolicyRequestHandler = new RequestHandler<MoveToPrivacyPolicyRequest>();
        Container.BindInstance<IRequestPusher<MoveToPrivacyPolicyRequest>>(openPrivacyPolicyRequestHandler);
        var moveToLoginRequestHandler = new RequestHandler<MoveToLoginRequest>();
        Container.BindInstance<IRequestPusher<MoveToLoginRequest>>(moveToLoginRequestHandler);

        _sequence = new TitleSceneSequence(
            loginSceneLoader: _loginSceneLoader,
            appearBehaviour: _appearPlayTimelineBehaviour.Value,
            disappearBehaviour: _disappearTitleSceneBehaviour.Value,
            beginningAvatarCustomSceneLoader: _beginningAvatarCustomSceneLoader,
            moveToCreateAvatarSceneRequestConsumer: moveToCreateAvatarSceneRequestHandler,
            openPrivacyPolicyRequestConsumer: openPrivacyPolicyRequestHandler,
            moveToLoginRequestConsumer: moveToLoginRequestHandler
        );
        Container.BindInstance<IInitializable>(this);
    }
    
    void IInitializable.Initialize()
    {
        _sequence.StartSequence(this.GetCancellationTokenOnDestroy()).Forget();
    }
}

TitleSceneSequence

/// <summary>
/// TitleSceneの一連の手続きをまとめたクラス
/// </summary>
public class TitleSceneSequence
{
    private ILoginSceneLoader LoginSceneLoader { get; }
    private IAppearBehaviour AppearBehaviour { get; }
    private IDisappearBehaviour DisappearBehaviour { get; }
    private IBeginningAvatarCustomSceneLoader BeginningAvatarCustomSceneLoader { get; }
    private IRequestConsumer<MoveToCreateAccountRequest> MoveToCreateAvatarSceneRequestConsumer { get; }
    private IRequestConsumer<MoveToPrivacyPolicyRequest> OpenPrivacyPolicyRequestConsumer { get; }
    private IRequestConsumer<MoveToLoginRequest> MoveToLoginRequestConsumer { get; }

    public TitleSceneSequence(ILoginSceneLoader loginSceneLoader, IAppearBehaviour appearBehaviour, IDisappearBehaviour disappearBehaviour, IBeginningAvatarCustomSceneLoader beginningAvatarCustomSceneLoader, IRequestConsumer<MoveToCreateAccountRequest> moveToCreateAvatarSceneRequestConsumer, IRequestConsumer<MoveToPrivacyPolicyRequest> openPrivacyPolicyRequestConsumer, IRequestConsumer<MoveToLoginRequest> moveToLoginRequestConsumer)
    {
        LoginSceneLoader = loginSceneLoader;
        AppearBehaviour = appearBehaviour;
        DisappearBehaviour = disappearBehaviour;
        BeginningAvatarCustomSceneLoader = beginningAvatarCustomSceneLoader;
        MoveToCreateAvatarSceneRequestConsumer = moveToCreateAvatarSceneRequestConsumer;
        OpenPrivacyPolicyRequestConsumer = openPrivacyPolicyRequestConsumer;
        MoveToLoginRequestConsumer = moveToLoginRequestConsumer;
    }
    
    public async UniTaskVoid StartSequence(CancellationToken cancellationToken)
    {
        // 画面に入った時の演出
        await AppearBehaviour.AppearAsync(cancellationToken);

        // 入力待機ループ
        await ConcurrentProcess.Create(

            // アカウント作成待機
            Process.Create(
                waitTask: async ct => await MoveToCreateAvatarSceneRequestConsumer.WaitRequestAndConsumeAsync(ct),
                onPassedTask: async ct =>
                {
                    await BeginningAvatarCustomSceneLoader.LoadSceneAsync(new BeginningAvatarCustomSceneConfig(), ct);
                    return ProcessContinueType.Break;
                }
            ),

            // ログイン待機
            Process.Create(
                waitTask: async ct => await MoveToLoginRequestConsumer.WaitRequestAndConsumeAsync(ct),
                onPassedTask: async ct =>
                {
                    await LoginSceneLoader.LoadSceneAsync(
                        config: LoginSceneConfig.CreateDefault(),
                        cancellationToken: ct);
                    return ProcessContinueType.Break;
                }
            ),

            // プライバシーポリシー待機
            Process.Create(
                waitTask: async ct => await OpenPrivacyPolicyRequestConsumer.WaitRequestAndConsumeAsync(ct),
                onPassedTask: async ct =>
                {
                    Application.OpenURL("https://gogh.gg/privacy")
                    return ProcessContinueType.Continue;
                }
            )
        ).LoopProcessAsync(cancellationToken: cancellationToken);

        // 終了演出 & シーンの破棄
        await DisappearBehaviour.DisappearAsync(cancellationToken);
    }
}

Objectの横のレイヤー

Objectは下記のクラスに分解されます。

  • Installer
  • Presenter
  • ViewModel
  • View
  • Behaviour(Option)
タイトル画面の"はじめる"ボタンのコード例

CreateAccountButtonObjectInstaller

/// <summary>
/// CreateAccountButtonObjectのInstaller(GameObjectContext)
/// </summary>
public class CreateAccountButtonObjectInstaller : MonoInstaller, IInitializable
{
    [SerializeField, NotNull] private CreateAccountButtonObjectView _createAccountButtonObjectView;
    [Inject] private IRequestPusher<MoveToCreateAccountRequest> _moveToCreateAvatarSceneRequestPusher;

    private CreateAccountButtonObjectPresenter _presenter;

    public override void InstallBindings()
    {
        _presenter = new CreateAccountButtonObjectPresenter(
            view: _createAccountButtonObjectView,
            moveToCreateAvatarSceneRequestPusher: _moveToCreateAvatarSceneRequestPusher
        );
        Container.BindInstance<IInitializable>(this);
    }

    void IInitializable.Initialize()
    {
        _presenter.StartLifecycle();
    }
}

CreateAccountButtonObjectPresenter

/// <summary>
/// CreateAccountButtonObjectのPresenter(責務はViewModelを作成してViewを初期化することと、入力のハンドリング)
/// </summary>
public class CreateAccountButtonObjectPresenter
{
    private CreateAccountButtonObjectView View { get; }
    private IRequestPusher<MoveToCreateAccountRequest> MoveToCreateAvatarSceneRequestPusher { get; }

    public CreateAccountButtonObjectPresenter(CreateAccountButtonObjectView view, IRequestPusher<MoveToCreateAccountRequest> moveToCreateAvatarSceneRequestPusher)
    {
        View = view;
        MoveToCreateAvatarSceneRequestPusher = moveToCreateAvatarSceneRequestPusher;
    }

    public void StartLifecycle()
    {
        View.InitView(new CreateAccountButtonObjectViewModel());
        View.OnCreateAccountButtonClickedObservable
            .Subscribe(_ => MoveToCreateAvatarSceneRequestPusher.PushRequest(new MoveToCreateAccountRequest()))
            .AddTo(View.GetCancellationTokenOnDestroy());
    }
}

CreateAccountButtonObjectViewModel

/// <summary>
/// CreateAccountButtonObjectのViewModel(View側の表示更新に必要なイベントを保持)
/// </summary>
public readonly struct CreateAccountButtonObjectViewModel
{
    // 今回は特に定義する事がないですが、本来は下記のようなObservableを定義します(View側で購読して表示更新が行われる)
    // public Observable<bool> IsVisibleChanged { get; }
}

CreateAccountButtonObjectView

/// <summary>
/// CreateAccountButtonObjectのView(責務はViewModel経由での表示更新と、入力イベントの伝搬)
/// </summary>
public class CreateAccountButtonObjectView : MonoBehaviour
{
    // CommonButtonBehaviourはボタンの共通の挙動を扱います(押下時に音を鳴らしたり)
    [SerializeField, NotNull] private CommonButtonBehaviour _createAccountButtonBehaviour;

    public Observable<Unit> OnCreateAccountButtonClickedObservable =>
        _createAccountButtonBehaviour.OnClickButtonObservable;
    
    public void InitView(CreateAccountButtonObjectViewModel viewModel)
    {
        _createAccountButtonBehaviour.InitBehaviour();   
    }
}

横のレイヤーが必要な理由

横のレイヤーは例えるなら、コードの片付けルールです。おもちゃはおもちゃ箱に、洋服はクローゼットにしまうように、コードも決まった場所に整理整頓しておかないと、後から使う人が困ってしまいます。そのため、レイヤーというコードの置き場所を作ることで整理整頓をルールにしているのです。

一方で、片付け場所が多すぎても負担になるため厳選も必要です。横のアーキテクチャはチームの反応や実際の手触りを見ながら地道に調整をしていきます。

フォルダ管理がすごい

フォルダ構造はProjectの最初にある程度決めておくことが多いと思いますが、アーキテクチャが育ってきたらアーキテクチャに即したフォルダ構造に変えていくのがおすすめです。

弊アーキテクチャには階層構造としてContextの階層があるので、これを素直にフォルダに反映していきます。

縦のアーキテクチャをフォルダ構造に落とし込む

TitleSceneからCreateAccountButtonObjectまでのフォルダ構造は下記のようになります。

└── Project/
    ├── _Domain
    ├── _Installer
    ├── _Sequence
    ├── _Behaviour
    └── TitleScene/
        ├── _Domain
        ├── _Installer
        ├── _Sequence
        ├── _Behaviour
        ├── CreateAccountButtonObject/
        │   ├── _Domain
        │   ├── _Installer
        │   ├── _Presenter
        │   ├── _View
        │   ├── _Behaviour
        │   └── P_CreateAccountButtonObject.prefab
        └── S_TitleScene.unity

Context階層を踏襲しつつ、少しアレンジが加わっています。 ポイントは下記になります。

  • コードのレイヤー用フォルダはアンダーバープレフィックスをつけることでContext階層と区別する
  • PrefabやSceneに独自プレフィックスを付けて検索性を上げる

このように実際のプロダクト構造をそのままフォルダ構造に落とし込むことで、ファイルの置き場所を明確にする事ができます。

階層構造の限界

このフォルダ構造だと1つ困ることが、Contextを跨いだ共通ファイルの扱いです。例えば、SceneAとSceneBで共通のPrefabを使う場合、どちらのフォルダに置くべきかが定まりません

SceneA/CommonButton.prefab
SceneB/CommonButton.prefab

こういう時のみContext階層とは別のCommmonというフォルダに、共通ファイルを配置するようにしています。

Common/CommonButton.prefab

終わりに(蛇足)

ここまで読んでいただきありがとうございます。
最後にとっておきのアプリを紹介させてください。
goghというアプリです。

開発仲間のTokoroさんから頂いたアバターちゃん

goghはアバターがとんでもなく可愛いだけでなく、高品質なLo-Fi音楽や環境音まで搭載、読書に疲れたあなたの脳をリフレッシュさせてくれること間違いなしです

今回紹介したgoghのアーキテクチャはまだまだ発展させていきます。機会があればいずれまたご紹介させていただければと思います。

この記事が少しでもあなたのアーキテクチャの一助になれば幸いです。

ambr Tech Blog

Discussion