概要
本記事では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
も増えすぎると、可視化や不要な参照を含んでないかのバリデーションツールも欲しくなりそうです。