Unity 製の AR アプリをクリーンアーキテクチャ/Zenject/UniRxで整理した話

あけましておめでとうございます! スノーボードギアを一新しました。亀山です。またしばらく滑りに行けなくなりそうです。

今年もよろしくお願い致します。

背景

コベリンは、AR によってアクティブラーニングを促進する A-txt というアプリを提供しています。A-txt は Unity で開発しており、当初 ARToolKitX にコンテンツを後からダウンロードする機能を付加しただけのシンプルなアプリでした。そのため、Unity での最も素朴な実装方法となる、Prefab にアタッチされた複数の MonoBehaviour が UnityEvent などでつながって協調して動くという構成になっていました。

しかし、徐々に機能が増えるにつれ、MonoBehaviour が肥大化し、仕様変更が難しい状況になりました。MonoBehaviour のサブクラスなどの UnityEngine に依存したコードは、シーン上に配置されるため、どこで使われているかコードから追いづらくなったり、コンパイルエラーでコードの問題が検出しづらかったり、Unity Editor 上で動作確認をするためにその繰り返しが開発速度を落としたりする問題があったためです。いわゆる Fat View Controller と同じような問題です。

そこで MonoBehaviour にすべてが実装してある構成から、ビジネスロジックや通信等のコードを UnityEngine 非依存のクラスに適切に分割しつつ移行し、これらを最小限の MonoBehaviour から利用する構成への整理を行いました。

Clean Architecture

依存関係の整理のため Clean Architecture の考え方を導入し、命名などの参考にしました。

UI やファイルシステム、ネットワークといった I/O の具体的な実装を含めない形でビジネスロジックを実装した Domain がまず存在し、各 interface を定義します。

それらの interface のなかでファイル・ネットワーク関係を実装した Data、MonoBehaviour を使い UI などを実装した Presentation という層を定義し、必ず Domain が定義するものをこれらが利用・実装し、逆は無いという依存方向を決め、これに従ってコードの整理を行いました。

ディレクトリ構成

Scripts/
├── Data/
│   ├── Entity/
│   ├── Helper/
│   ├── Repository/
│   │   ├── LocalContentRepository.cs
│   │   ├── RemoteContentRepository.cs
│   │   └── Preferences.cs
│   └── Networking/
├── Domain/
│   ├── Repository/
│   │   ├── ILocalContentRepository.cs
│   │   ├── IRemoteContentRepository.cs
│   │   └── IPreferences.cs
│   ├── Entity/
│   ├── Model/
│   │   └── ContentListModel
│   └── View/
├── Helper/
│   └── MainInstaller.cs
└── Presentation/
    └── Presenter/

設計方針

レイヤー

Domain

  • ビジネスロジックを実装する一番偉いレイヤー
    • Data, Presentation は Domain に依存し、逆方向には依存しない
  • MonoBehaviour に依存しない
  • 画面表示や、API のリクエストやファイルの読み込みといった I/O の実装は行わない
    • それらの interface を Domain が定義し、Data, Presentation が実装する
  • Zenject を使わない

Data

  • API のリクエストやファイルの読み込みといった I/O の実装を行う
  • Domain で定義された interface を実装する
  • MonoBehaviour に依存しない
  • Zenject を使わない

Presentation

  • 画面表示や音声の実装
  • MonoBehaviour に依存してよい
  • Domain で定義された interface を実装する
  • Zenject を使って Domain のクラスを取得する

Installer

  • Zenject により DI で Domain のクラスを提供する
  • Domain のクラス生成時に Data で実装したクラスのインスタンスを渡す

コーディング指針

  • StartCoroutine/IEnumerator よりも async/await を使う
  • 各レイヤーのコードに namespace を指定すること
    • namespace ActiveText.Domain
    • namespace ActiveText.Data
    • namespace ActiveText.Presentation
    • 依存方向がおかしくなってないか検出するためだけなのでこれ以上細かい namespace はつけなくていいです
  • 依存方向に注意
    • Domain で using ActiveText.Datausing ActiveText.Presentation を書いてないこと
    • Presentation で using ActiveText.Data を書いてないこと

Zenject

UnityEngine 非依存のコードを作るといっても、必ずどこかではクラスのインスタンスを生成する必要があります。ここでまた空の GameObject に詰め込んだりシングルトンを作ったりすると、せっかく綺麗な依存関係を作ったのに、以前と同じような状態にもどってしまいます。

そこで、Zenject を用いることで、Domain で定義したクラス・インターフェースをシーンに触れることなくインスタンスを生成、MonoBehaviour に注入することができるようになります。また、Zenject の Installer は Domain / Data / Presentation の各層を接続する点の役割となり、明確にレイヤーを分離することができます。

新たに Model や Repository を追加した際は、Installer を修正し、利用先で [Inject] により取得するというワークフローになるため、Unity Editor を触れずに実装をすすめることができます。

ちなみに、Zenject のコンストラクタインジェクションを用いて Model や Repository を生成することで、 Domain / Data 層では Zenject に依存しない形にすることができました。

UniRx

MonoBehaviour が多くの状態を保持していると、それを操作するコードは Presentation に依存することになってしまい、UnityEngine 非依存の部分を増やすのが難しくなります。そのため、Domain で定義される Model の状態が Presentation に反映されるという方向に逆転することが大事です。

そこで、Model の状態を MonoBehaviour 等に反映させるコードが増えていくわけですが、UniRx を導入することで非常に柔軟に、また比較的簡潔にこれを実装することができます。また、UniTask を導入することで Coroutine から async/await への書き換えが非常に簡単になります。

ところで UniRx を使うとき、MonoBehaviour のサブクラスと Model との間に Presenter を間に入れて利用し、MonoBehaviour のサブクラスは Passive View にするとよりコードの整理が進むのですが、今回は UnityEngine 依存側はあくまでコード量の削減 (Domain/Data への移動) に注力し、それ以上の大掛かりな修正は行いませんでした。また、Repository なども Presentation から直接利用しており、依存方向さえ守っていればよいとしました。

結果

これらのコード整理を行った結果、新しい機能の実装がほとんど VisualStudio でのコーディングで済み、Unity Editor 側では少しだけ見た目の調整、MonoBehaviour も追加したオブジェクトの [Serializable] の紐付けと若干の Model 層とのグルーコードを書くだけとなりました。動的に解決される部分が減り、コンパイルエラーが無ければおおよそちゃんと動くという状態は安心です。

一方で、やはりレイヤーを分けることによるコード量の増加や、Prefab を DiContainer を使って Instantiate する修正の抜け漏れの確認の面倒さ、どの程度 Model に状態を移すかといった判断の難しさ、Repository を Domain が定義するデータオブジェクトを interface にするか struct にするか、Serializable なオブジェクトを Domain に入れるかなどの微妙な判断などがありました。色々考えながらコーディングしないといけなくなり、丁寧にチームへの展開が必要になります。本記事はそのために README に書いた設計方針に加筆したものになっています。

弊社は Unity Editor よりもコード主体のアプリ開発に慣れているメンバーが多いため、今までの状態よりはキャッチアップがしやすいコードベースになったのではないでしょうか。

参考資料