LoginSignup
39
25

More than 3 years have passed since last update.

CleanArchitectureでasmdefをガチった話(Unity)

Last updated at Posted at 2019-12-10

概要

本記事ではClean Architecture定義したレイヤをUnityで実装する際、Assembly Definitionを切るとレイヤ同士の依存関係を明確にできていい感じになるよ、という話をします。

Clean Architecture

CleanArchitectureは例の図の内側にアプリケーションロジックを閉じ込め、変更されがちなフレームワークやデータベースを詳細として捉え、それらをアプリケーションロジックの外側に追いやり中間層を介してアプリケーションロジックから詳細を参照することで、詳細にとらわれないアプリケーション開発を行うアーキテクチャです。

image.png

Unityでの採用例には CAFU というフレームワークがあるので、ここから調べるのが良いでしょう。
作成者(日本人)の登壇資料も参考になります。

Assembly Definition

AssemblyDefinitionはUnityの機能の1つで、フォルダ内に .asmdef ファイルを配置することでそのフォルダ以下を別dll(=別アセンブリ?)として定義することのできる機能です。

image.png

.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の構成は以下のような図になります。

image.png

各モジュールについて説明します。

Domain

UseCase

アプリケーションロジックをここに書きます。
アプリケーションでやりたいことを IPresetnerIRepository を使って実現します。

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 を作成するための材料になる(コンストラクタの引数になる) IPresenterIRepository[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 です。

image.png

なんと1つのプロジェクトで 11個 もの .asmdef を定義することになりました。
(テスト用 .asmdef は含めず)

.asmdef がどれにアクセスするかを説明します。

UniRx/UniTask/Zenjectについて

こいつらのライブラリの役割について補足しておきます。
「ライブラリ(=フレームワーク)は詳細に追いやってアプリケーションロジックからは参照させるべきでない」
みたいな説明を先でしましたが、こいつらは別です。各モジュール同士のやりとりにもこの3つは使われているので、アーキテクチャそのものがこの3つのライブラリに依存している形になります。(作るものによっては UnityEngine よりこいつらのが重要かもしれません)

もし3rdライブラリへの依存を避けたいなら、event Action とか System.Threading.Tasks.Task を使うようにしてください。DIはサービスロケータを自作するとかでしょうか。

UseCase

アクセスするのは同じ Domain の中のinterfaceだけです。必要に応じて共通のデータ型定義やロジックのクラスへアクセスしてください。

image.png

IPresenter/IRepository

こいつらがアクセスする必要があるのは共通データ型くらいでしょう。詳細は知る必要ありません。

image.png

Presenter/Repository(+IView/IDataStore)

inrefaceを実装したクラスなので、interface定義を知っている必要があります。

image.png

IViewIDataStore も一緒にしましたが、必要だと思うなら .asmdef を追加しても良いでしょう。

Adapter から Domain に向く矢印が上を向いているのが CleanArchitecture 的なポイントの1つでもあります。

View/DataStore

こいつらもinrefaceを実装したクラスなので、interface定義を知っている必要があります。

image.png

ここでも Detail からAdapter への矢印が上を向いていることに注目してください。

別のUIフレームワークやmBaaSのライブラリに依存する場合はここの .asmdef のみを編集すれば良く、内側の AdapterDomain はここの変更による影響を一切受けないということがポイントです。

Main

シーンで実行され UseCase を作成する役割を担います。

image.png

UseCase とそれの材料になる interface の定義のみを知っていれば良いです。

Installer

MainUseCase を作成するための材料を事前に準備(DI)します。役割上知る範囲がかなり広大です。

image.png

InstallerMain を直接知りません。(MainInstaller を知りません。)
Zenject経由で Installer が行ったDIの設定を Main が勝手に受け取っているという表現が正しいです。

まとめ

UnityでClean Architectureを導入しようというなら、Assembly Definitionを使ってこんな感じに分けるといいよという話でした。

感じたメリットとしては

  • 自然とディレクトリも分けられる
  • 関心の分離にも繋がる
  • Intellisenseの候補が絞られる
    • 似た名前のファイルが多くなるので結構汚染される
  • レビュー時には .asmdef にさえ気を配っておけば良い

注意点としては

  • asmdefの管理者が必要にある
  • AssetBundleに罠がある(らしい)
  • .csprojが増えて数にビビる
    • 実害はないですがビックリします

でしょうか。
.asmdef も増えすぎると、可視化や不要な参照を含んでないかのバリデーションツールも欲しくなりそうです。

39
25
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
39
25