概要
本記事ではClean Architecture定義したレイヤをUnityで実装する際、Assembly Definitionを切るとレイヤ同士の依存関係を明確にできていい感じになるよ、という話をします。
Clean Architecture
CleanArchitectureは例の図の内側にアプリケーションロジックを閉じ込め、変更されがちなフレームワークやデータベースを詳細として捉え、それらをアプリケーションロジックの外側に追いやり中間層を介してアプリケーションロジックから詳細を参照することで、詳細にとらわれないアプリケーション開発を行うアーキテクチャです。
 
Unityでの採用例には CAFU というフレームワークがあるので、ここから調べるのが良いでしょう。
作成者(日本人)の登壇資料も参考になります。
Assembly Definition
AssemblyDefinitionはUnityの機能の1つで、フォルダ内に .asmdef ファイルを配置することでそのフォルダ以下を別dll(=別アセンブリ?)として定義することのできる機能です。
 
.asmdef をフォルダに作成するとその階層以下にあるScriptは同階層以下のScriptしか参照できなくなります。外のScriptを参照したい場合は該当Scriptの属する .asmdef への参照を自分の .asmdef に設定してやる必要があります。
よくテストを実装しようとしてテスト用の .asmdef を切ったは良いが、自プロジェクトや3rdのPluginが .asmdef を切ってなくて仕方なく追加するという人が多い印象を持ってます。
Clean Architecture x Assembly Definitionのメリット
そんな便利だがネガティブな印象を持たれてそうなAssembly Definitionですが、Clean Architectureで定義したレイヤの関係性を制限させたい場合にも効果を発揮します。
(※別にClean Architectureに限る訳ではないですが)
ディレクトリ分けの方針になる
CleanArchitectureではファイルが増えがちです。
- ex. IHogePresenter.cs/HogePresenter.cs, IHogeView.cs/HogeView.cs, IHogeRepository.cs/HogeRepository.cs ...
なんとなくでディレクトリ(フォルダ)を分けていると、開発が進むにつれて不整合が出てきて管理しきれなくなる可能性があります。そこで、 .asmdef を切る方針でいれば dll 分けを考慮したディレクトリ構成になるので、基本的には正しいディレクトリ構成になると期待できます。
レイヤ間(またはモジュール同士)の関係性を制限できる
.asmdef を一切切らない場合、プロジェクト内のScriptはどのScriptにも参照できます。
仮に UseCase からUI系の処理を行う場合は IPresenter を経由して View を操作しようと方針を決めたとします。しかし、開発者の理解不足や凡ミスにより UseCase に直接 View を渡しちゃったというような方針と異なる実装が行われてしまうケースが当然あり得ます。(もしくは都合がいいから View から Repository にアクセスしちゃうケースとか)
レビューで気付けばいいですが、ビルドも動作も想定通りに行われる場合だと方針違いにレビューで気づくのは結構困難でしょう。
そこで、設定のレイヤやモジュールごとに .asmdef を定義し、それらの関係を大元で決めてやれば、想定外のレイヤやモジュール同士の依存関係をシャットアウトすることができます。
レビューでも .asmdef に変更が入ってないかをチェックすればOKなのでお手軽です。
分け方の実例
ソースコードはGitHubにあげていますので、必要あれはご覧ください。
Clean Architecture におけるモジュール
自分の考えるUnityアプリ(ゲームじゃない)を作る上でのClean Architectureの構成は以下のような図になります。
 
各モジュールについて説明します。
Domain
UseCase
アプリケーションロジックをここに書きます。
アプリケーションでやりたいことを IPresetner と IRepository を使って実現します。
public class UseCase : IUseCase {
  readonly IPresenter presenter;
  readonly IRepository repository;
  public UseCase(IPresenter presenter,
                 IRepository repository) {
    this.presenter = presenter;
    this.repository = repository;
  }
  void IUseCase.Begin() {
    // presenter経由でボタンが押されたイベントを取得
    // repository経由で回数を保存みたいな
  } 
  void IUseCase.Finish() {
    // 終了処理
  }
}
IPresenter/IRepository
UseCaseを満たすため、UIやDataに対する処理を定義します。UseCase(やりたいこと)があってこいつらが形作られるので、これらはDomainの存在になります。
public interface IPresenter {
  IObservable<Unit> ClickObs {get;}
  void SetText(string text);
}
public interface IRepository {
  void SetCount(int count);
  int GetCount();
}
UIの処理とはいえ UnityEngine.UI への処理はまだ不要です。
Button のイベントを取得するために UniRx を使っています。また、DataのI/Oを非同期で行いたければ UniTask などを導入すると良いでしょう。
Adapter
実装の詳細をアプリケーションロジックをつなぐ中間層です。
Presenter/Repository
こいつらもまだ UnityEngine.UI とかデータの保存先が PlayerPrefs なのか Firebase などの mBaaS なのかも知りません。
//using UnityEngine.UI; 不要ではある
public class Presenter : IPresenter {
  readonly IView view;
  public Presenter(IView view) {
    this.view = view;
  }
  public IObservable<Unit> ClickObs => view.Button.OnClickObservable();
  public void SetText(string text) {
    view.Text.text = text; // しかしTextがここで出てくる
  }
}
public class Repository : IRepository {
  readonly IDataStore datastore;
  public Repository(IDataStore dataStore) {
    this.datastore = dataStore;
  }
  public void SetCount(int count) {
    dataStore.SaceCount(count);
  }
  public int GetCount() {
    return dataStore.LoadCount();
  }
}
IView/IDataStore
中間層と詳細の橋渡しを行う定義です。
public interface IView {
  Button Button {get;}
  Text Text {get;}
}
public interface IDataStore {
  void SaveCount(int count);
  int LoadCount();
}
Detail
詳細の実装を行う箇所です。思う存分フレームワークに依存させてください。
ここが無くなったり更新されたとしても Domain のアプリケーションロジックには一切変更が必要ないというのがミソです。
View/DataStore
public class View : MonoBehaviour, IView {
  [SerializeField] Button button = default;
  [SerializeField] Text text = default;
  public Button Button => button;
  public Text Text => text;
}
public class DataStore : IDataStore {
  public void SaveCount(int count) {
    PlayerPrefs.SetInt("count", count);
  }
  public int LoadCount() {
    return PlayerPrefs.GetInt("count", 0);
  }
}
ここで初めて MonoBehaviour とか PlayerPrefs とかUnityの都合が出てきます。
Main
Main はUnityのシーンに乗せて、UseCase を作成/実行/破棄する役割を持たせます。
UseCase を作成するための材料になる(コンストラクタの引数になる) IPresenter や IRepository は [Zenject.Inject] から受け取ると良いでしょう。
public class Main : MonoBehaviour {
  IUseCase usecase;
  [Inject] 
  Construct(IPresenter presenter, IRepository repository) {
    // usecaseの作成
    usecase = new UseCase(presenter, repository);
  }
  void Awake() {
    // usecaseの実行
    usecase.Begin();
  }
  void OnDestroy() {
    // usecaseの破棄
    usecase.Finish();
  }
}
[Zenject.Inject] でinterfaceの実装を受け取るためには別途DIの設定を記述する必要があります。
Installer
前述のDIの設定を記述します。こいつには Zenject.MonoInstaller を継承させます。
public class Installer : MonoInstaller {
 [SerializeField] View view;
 [SerializeField] DataStore dataStore;
 public override void InstallBindings() {
  Container
   .Bind<IPresenter>()
   .FromInstance(new Presenter(view))
   .AsCached();
  Container
   .Bind<IRepository>()
   .FromInstance(new Repository(dataStore))
   .AsCached();
 }
}
こいつはDIのため詳細のクラス定義を知っている必要があります。
.asmdef 構成
お待たせしました。やっと本題です。
どのモジュールがどのモジュールに参照できるか(すべきか)を考えて .asmdef 分けを行った結果次のようになりました。黄色の四角1つ1つが .asmdef です。
 
なんと1つのプロジェクトで 11個 もの .asmdef を定義することになりました。
(テスト用 .asmdef は含めず)
各 .asmdef がどれにアクセスするかを説明します。
UniRx/UniTask/Zenjectについて
こいつらのライブラリの役割について補足しておきます。
「ライブラリ(=フレームワーク)は詳細に追いやってアプリケーションロジックからは参照させるべきでない」
みたいな説明を先でしましたが、こいつらは別です。各モジュール同士のやりとりにもこの3つは使われているので、アーキテクチャそのものがこの3つのライブラリに依存している形になります。(作るものによっては UnityEngine よりこいつらのが重要かもしれません)
もし3rdライブラリへの依存を避けたいなら、event Action とか System.Threading.Tasks.Task を使うようにしてください。DIはサービスロケータを自作するとかでしょうか。
UseCase
アクセスするのは同じ Domain の中のinterfaceだけです。必要に応じて共通のデータ型定義やロジックのクラスへアクセスしてください。
 
IPresenter/IRepository
こいつらがアクセスする必要があるのは共通データ型くらいでしょう。詳細は知る必要ありません。
 
Presenter/Repository(+IView/IDataStore)
inrefaceを実装したクラスなので、interface定義を知っている必要があります。
 
IView と IDataStore も一緒にしましたが、必要だと思うなら .asmdef を追加しても良いでしょう。
Adapter から Domain に向く矢印が上を向いているのが CleanArchitecture 的なポイントの1つでもあります。
View/DataStore
こいつらもinrefaceを実装したクラスなので、interface定義を知っている必要があります。
 
ここでも Detail からAdapter への矢印が上を向いていることに注目してください。
別のUIフレームワークやmBaaSのライブラリに依存する場合はここの .asmdef のみを編集すれば良く、内側の Adapter や Domain はここの変更による影響を一切受けないということがポイントです。
Main
シーンで実行され UseCase を作成する役割を担います。
 
UseCase とそれの材料になる interface の定義のみを知っていれば良いです。
Installer
Main が UseCase を作成するための材料を事前に準備(DI)します。役割上知る範囲がかなり広大です。
 
Installer は Main を直接知りません。(Main も Installer を知りません。)
Zenject経由で Installer が行ったDIの設定を Main が勝手に受け取っているという表現が正しいです。
まとめ
UnityでClean Architectureを導入しようというなら、Assembly Definitionを使ってこんな感じに分けるといいよという話でした。
感じたメリットとしては
- 自然とディレクトリも分けられる
- 関心の分離にも繋がる
- Intellisenseの候補が絞られる
- 似た名前のファイルが多くなるので結構汚染される
 
- レビュー時には .asmdefにさえ気を配っておけば良い
注意点としては
- asmdefの管理者が必要にある
- AssetBundleに罠がある(らしい)
- 
.csprojが増えて数にビビる- 実害はないですがビックリします
 
でしょうか。
.asmdef も増えすぎると、可視化や不要な参照を含んでないかのバリデーションツールも欲しくなりそうです。
