前回の記事(概念編)からの続きです。是非こちらもご覧ください。
CleanArchitectureを適用したBMI計算アプリの実装について解説します。
言語はC#、実行環境はUnityです。
依存
プログラムにおける依存とは主に「クラスAがクラスBに依存している」といった表現で使われます。この場合、「クラスBがクラスAの一部になっている」「クラスAはクラスBの中身を知っている」ということを表し、「クラスAはクラスBの上位モジュールである(=クラスBはクラスAの下位モジュールである)」わけです。
上位/下位という言葉を使うと上位は下位より偉いと捉えられますが、一概にそうとは言えません。クラスAがいないとクラスBの存在理由が無いのはそうなのですが、クラスAもクラスB無しでは自分の一部を失うことになります。「代わりはいくらでもいる」とはならないのです。
抽象
「代わりはいくらでもいる」とクラスAがクラスBに対して強く言えるようになる方法に 抽象化 があります。クラスBの機能を整理して、インタフェースBに抽象化します。クラスAはそのインタフェースBに依存するようにすれば、クラスAはクラスBに対して「うちはインタフェースBを実現しているクラスなら誰でも良いんだ。代わりはいくらでもいる。」といえるようになります。
抽象化を実現する方向には interface
と abstract class
を使う2つの方法がありますが、C#ではクラスの多重継承ができないので、interface
を使う方が多いと思います。実装も共通化させたいなどの事情があればabstract class
を使ってください。
さて、CleanArchitecture以前に設計の原則として 依存性逆転の原則 1というものがあります。
抽象化前はクラスAからクラスBの右向きの矢印一本しかなかったですが、抽象化によってクラスBからクラスA(インタフェースB)に向かう左向きの矢印が出来ました。これが依存性逆転です。
今までは一方的にクラスAに使われる側だったクラスBもインタフェースBを 実現してやってる 上位の立場になったわけです。(クラスAに見捨てられても「私はインタフェースBを実現したクラスです」と宣伝すれば別のクラスに使ってもらえるかもしれません。)
境界を超える
CleanArchitectureのルールとして、外側の層が内側の層に依存する というものがあります。
画像左側の3本の並んだ矢印がそれを表しています。
前の記事でUseCaseについて
数字の入力をPresenter経由で受け、BMI計算を呼び出し、結果をPresenterに返す実装をUseCaseで行います。
という説明をしました。これではあたかも
class BMIUseCase {
BMIPresenter presenter = new BMIPresenter();
...
}
の様に BMIUseCase
が BMIPresenter
に依存した作りになっていると想像されたかもしれません。
これは、「外から内に依存する(内から外に依存してはいけない)」という決まりに反しています。しかし、BMIを計算するというUseCaseをBMIUseCase
として実装するためには数値入力のViewと接続したBMIPresenter
が必要になります。この内側から外側への依存を回避するために依存性逆転というトリックを使います。
IBMIPresenter
というinterfaceを定義します。そして、BMIUseCase
からはIBMIPresenter
を参照し、それをBMIPresenter
で実現するようにします。
ここで留意すべきはinterfaceのIBMIPresenter
は緑のInterface Adaptersではなく赤のApplication Business Rulesに属すると言う所です。2
理由はIBMIPresenter
を決定するのはPresenterやViewではなく、UseCaseだからです。BMIの計算と表示を行うUseCaseで使うPresenterの定義がIBMIPresenter
に現れていて、BMIPresenter
はそれを実現したに過ぎないという訳です。
これにより内側は外側を知らず、外側が内側の定義を知っているだけになり、「外から内に依存する(内から外に依存してはいけない)」というルールを守ることができます。
このように、CleanArchitectureで境界を超えるときは抽象化による依存性逆転が便利な場面が多いです。
前記事のDomain/UseCase/Presenter/Viewをすべて表すと以下の図になります。
各層の外側から内側に矢印が向くように作ってあります。Domainにて抽象化を行っていない点が恣意的ですが、この層は変更が行われることが少ないことや、Domainは単にUseCaseから利用される層なのでこのようにしています。必要性を感じるなら抽象化を行ってください。
※ Domain=CleanArchitectureの図で言う所のEntitiesです。本記事ではDomainという言葉を使っています。
黒幕は誰だ?
UseCaseを中心に考えて実装を行うと
class BMIUseCase {
IBMIPresenter presenter;
public BMIUseCase(IBMIPresenter presenter) {
this.presenter = presenter;
}
...
}
interface IBMIPresenter {
...
}
class BMIPresenter : IBMIPresenter {
IBMIView view;
public BMIPresenter(IBMIView view) {
this.view = view;
}
...
}
interface IBMIView {
...
}
class BMIView : IBMIView {
...
}
のような構造になります。
BMIUseCase
はIBMIPresenter
に依存し、コンストラクタで外からIBMIPresenter
を実装したクラスのインスタンスを受け取れるようになっています。
interfaceなどで抽象化した要素に具体的なインスタンスを渡して抽象を解決する処理を**依存性の注入(Dependency injection)**といいます。依存性の注入を行う人物が裏で糸を引いて、各層の実装を引き合わせることでBMIを求めるアプリが完成するのです。正に黒幕です。
実際に依存性の注入を行う場合ですが、プログラムの起動時にMain
でまとめて行う場合もあれば、DIライブラリを使って行う場合などがあります。
// mainで依存性の注入を行う例
int main(string[] args) {
var view = new BMIView();
var presenter = new BMIPresenter(view); // presetnerにはviewが必要
var usecase = new BMIUseCase(presenter); // usecaseにはpresenterが必要
...
}
Unityでの実装
Unityのバージョンは2019.1.14です。
画面イメージ
身長と体重を入力できる InputField
とBMIの結果を表示する Text
を配置します。
名前/年齢/性別の入力は何となく付けました。
View
UIによるユーザへの入出力を行う部分です。Unityの都合による影響をモロに受ける部分でもあります。
using UnityEngine;
using UnityEngine.UI;
namespace BMIApp {
public interface IView { }
public interface IBMIView : IView {
InputField NameInput { get; }
InputField HeightInput { get; }
InputField WeightInput { get; }
InputField AgeInput { get; }
Toggle GenderMaleToggle { get; }
Toggle GenderFemaleToggle { get; }
Text BMIText { get; }
Button SaveButton { get; }
}
public class BMIView : MonoBehaviour, IBMIView {
[SerializeField] InputField nameInput = default;
[SerializeField] InputField heightInput = default;
[SerializeField] InputField weightInput = default;
[SerializeField] InputField ageInput = default;
[SerializeField] Toggle genderMaleToggle = default;
[SerializeField] Toggle genderFemaleToggle = default;
[SerializeField] Text bmiText = default;
public InputField NameInput => nameInput;
public InputField HeightInput => heightInput;
public InputField WeightInput => weightInput;
public InputField AgeInput => ageInput;
public Toggle GenderMaleToggle => genderMaleToggle;
public Toggle GenderFemaleToggle => genderFemaleToggle;
public Text BMIText => bmiText;
}
}
IView
CleanArchitectureに則った構成であることを示すために定義したinterfaceです。別に定義しなくても実装できない訳ではありません。
IBMIView
BMIの入力・出力を行うUIの要素群を定義したinterfaceです。Interface Adapters層の住人になります。
BMIView
IBMIView
を実現した具体的なクラスです。Unityで動かす都合上MonoBehaviour
を継承しします。Frameworks & Drivers層の住人になります。
Presenter
ViewとUseCaseを繋ぐ層です。
- UseCase へ のUIのイベントを通知する
- UseCase から の情報をUIに反映する
という双方向のデータ処理を実現する必要があります。UseCaseへの通知を今回はUniRx
を導入し、IReadOnlyReactiveProperty
を使って実現しました。
using System;
//using UnityEngine.UI; 禁止!!
using UniRx;
namespace BMIApp {
public interface IPresenter { }
public interface IBMIPresenter : IPresenter {
IReadOnlyReactiveProperty<string> NameInput { get; }
IReadOnlyReactiveProperty<string> HeightInput { get; }
IReadOnlyReactiveProperty<string> WeightInput { get; }
IReadOnlyReactiveProperty<string> AgeInput { get; }
IReadOnlyReactiveProperty<bool> GenderMaleSelect { get; }
IReadOnlyReactiveProperty<bool> GenderFemaleSelect { get; }
void Bind();
void SetBMIResult(string result);
}
public class BMIPresenter : IBMIPresenter {
readonly IBMIView view;
public BMIPresenter(IBMIView view) { this.view = view; }
public IReadOnlyReactiveProperty<string> NameInput { private set; get; }
public IReadOnlyReactiveProperty<string> HeightInput { private set; get; }
public IReadOnlyReactiveProperty<string> WeightInput { private set; get; }
public IReadOnlyReactiveProperty<string> AgeInput { private set; get; }
public IReadOnlyReactiveProperty<bool> GenderMaleSelect { private set; get; }
public IReadOnlyReactiveProperty<bool> GenderFemaleSelect { private set; get; }
public void Bind() {
NameInput = view.NameInput.OnEndEditAsObservable().ToReadOnlyReactiveProperty();
HeightInput = view.HeightInput.OnEndEditAsObservable().ToReadOnlyReactiveProperty();
WeightInput = view.WeightInput.OnEndEditAsObservable().ToReadOnlyReactiveProperty();
AgeInput = view.AgeInput.OnEndEditAsObservable().ToReadOnlyReactiveProperty();
GenderMaleSelect = view.GenderMaleToggle.OnValueChangedAsObservable().ToReadOnlyReactiveProperty();
GenderFemaleSelect = view.GenderFemaleToggle.OnValueChangedAsObservable().ToReadOnlyReactiveProperty();
}
public void SetBMIResult(string result) {
view.BMIText.text = result;
}
}
}
IPresenter
CleanArchitectureに則った構成であることを示すために定義したinterfaceです。無駄だと思うなら不要です。
PresenterでViewの要素(Button
とか)を公開するプロパティを持ってしまうとUseCaseがViewの要素にアクセスできてしまいPresenterの層が意味を為しません。ここでのusing UnityEngine.UI
は禁止するのが吉です。
IBMIPresenter
BMI計算アプリUIのin/outを定義したinterfaceです。Application Business Rules層の住人になります。
BMIPresenter
IBMIPresenter
を実現した具体的なクラス。IBMIView
をコンストラクタで受け取り、そいつからのイベントを外部に公開するのと同時に、そいつへのデータの反映も行います。Interface Adapters層の住人です。
Domain
業務ロジックの層です。BMI計算ロジックが含まれます。
namespace BMIApp {
public interface IBMIDomainObject {
float Height { set; get; } // [cm]
float Weight { set; get; } // [m]
}
public class BMIDomain {
public float CalcBMI(IBMIDomainObject domainObject) {
if (domainObject.Height <= 0.0F || domainObject.Weight <= 0.0F) {
return 0.0F;
}
var h = domainObject.Height / 100.0F; // cm -> m
return domainObject.Weight / (h * h);
}
}
}
UseCase
アプリケーションの挙動の実装を行います。Viewを直接参照せずにPresetnerから操作を行ったり、BMI計算処理などはDomainに任せたりするなどして、アプリケーションの挙動に集中させます。
using System;
using UnityEngine;
using UniRx;
namespace BMIApp {
public interface IUseCase {
void Begin();
}
public class BMIUseCase : IUseCase {
readonly IBMIPresenter bmiPresenter;
readonly Component disposableComponent;
readonly BMIDomain bmiDomain;
public BMIUseCase(IBMIPresenter bmiPresenter, Component disposableComponent) {
this.bmiPresenter = bmiPresenter;
this.disposableComponent = disposableComponent;
this.bmiDomain = new BMIDomain();
}
public void Begin() {
var dto = new BMIDataTransferObject();
bmiPresenter.Bind();
bmiPresenter
.NameInput
.Subscribe(x => {
dto.Name = x;
})
.AddTo(disposableComponent);
bmiPresenter
.HeightInput
.Subscribe(x => {
if (float.TryParse(x, out var val)) {
dto.Height = val;
dto.BMI = bmiDomain.CalcBMI(dto);
bmiPresenter.SetBMIResult($"{dto.BMI:F1}");
}
})
.AddTo(disposableComponent);
bmiPresenter
.WeightInput
.Subscribe(x => {
if (float.TryParse(x, out var val)) {
dto.Weight = val;
dto.BMI = bmiDomain.CalcBMI(dto);
bmiPresenter.SetBMIResult($"{dto.BMI:F1}");
}
})
.AddTo(disposableComponent);
bmiPresenter
.AgeInput
.Subscribe(x => {
if (int.TryParse(x, out var val)) {
dto.Age = val;
}
})
.AddTo(disposableComponent);
Observable
.CombineLatest(bmiPresenter.GenderMaleSelect, bmiPresenter.GenderFemaleSelect)
.Subscribe(x => {
if (x[0]) {
dto.Gender = Gender.Male;
} else if (x[1]) {
dto.Gender = Gender.Female;
} else {
dto.Gender = Gender.None;
}
})
.AddTo(disposableComponent);
}
}
public enum Gender { None = 0, Male, Female }
public class BMIDataTransferObject : IBMIDomainObject {
public string Name { set; get; }
public int Age { set; get; }
public Gender Gender { set; get; }
public float BMI { set; get; }
}
}
コンストラクタでIBMIPresenter
を受け取り、Begin()
にてIBMIPresenter
が公開しているIReadOnlyReactiveProperty
をすべて購読(Subscribe()
)します。UIの入力に応じてBMI計算ロジックを呼び出して画面に結果を反映させるなど、アプリの処理がすべてBegin()
に記述されています。UseCaseには難しいことはさせず、子供でもできることだけを記述するのがいいでしょう。例えば入力のバリデーションをしたくなった場合にもBMIUseCase
にべた書きするのではなく、専用のDomainやUseCaseを新たに追加することを考えてください。
Main
今まで出てきたView/Presenter/UseCaseは全てコンストラクタで要素を受け取って依存性の注入ができるようになっています。必要なインスタンスを各自に渡して依存性の注入を行い、アプリの挙動を決定・開始させる役目をMainに任せます。
Unityの場合、MainにはMonoBehaviour
を継承させてシーンに配置することで以下を行えるようにします。
-
[SerializeField]
などでシーン上のUnityのコンポーネント(ここではView)を受け取る -
Awake()
でPresenter/UseCaseを作成 -
Start()
でUseCaseを実行
using UnityEngine;
namespace BMIApp {
public class BMISceneMain : MonoBehaviour {
[SerializeField] BMIView bmiView = default; // from inspector
IUseCase bmiUseCase;
void Awake() {
bmiUseCase = new BMIUseCase(new BMIPresenter(bmiView), this);
}
void Start() {
bmiUseCase.Begin();
}
}
}
Mainに書くのはこれだけです(シンプルですね)。アプリのロジックは全てUseCaseを中心に構成されています。
クラス図
各クラスの関係を表したクラス図です。UseCaseは抽象化されたPresenterしか知らず、Presenterの先にView(MonoBehaviour
などUnityの世界)があることなど知りえないのです。Mainを全てを知る立場に添えて、裏で各要素を繋げてアプリを走らせる役割を任せています。
まとめ
他のCleanArchitectureの記事にある実装例とは合致していないかもしれませんが、私なりに腑に落ちるように噛み砕いてUnityの実装まで落とし込みました。作るScriptのファイルが多くなるのでいざ作ると大変ではありますが、MainやUseCaseに実装する処理が明確でシンプルになった手ごたえを感じています。また、各層が抽象化されているのでUseCaseなどをテストする場合も簡単にできそうです。
GitHub
naninunenoy/UnityViewPatterns/BMIApp
色々(シーン跨ぐ場合や複数UseCaseがある場合など)試したので、今は仮ログインや履歴表示の機能などが盛り込まれてます。
-
Unity開発で使える設計の話+Zenjectの紹介 素晴らしい資料なので是非読んでください ↩
-
umm/cafu_core レイヤー定義 大いに参考にさせてもらいました ↩