どんな話?
こんにちは。株式会社 ENDROLL でエンジニアをしている荒木です。
ENDROLL に少しでも関心を持っていただいてるエンジニアさん向けに、
弊社における Unity を使ったアプリの設計の話や、その他の実装プラクティスなどを書いていきます。
弊社の開発業務に関わってくれる方が業務をイメージできるよう全部書くぞ💪という心意気で書いていくので、
多少冗長な部分があるかと思いますが、興味のある方には興味深い内容になると思うので、目を通して楽しんでもらえたら良いなと思います。
アーキテクチャ図
筆者は設計の経験が豊富なわけではありません。2年前(2021年末)に Unity で初めて大きなプロダクト( よひつじの森 )の実装に着手する前に、書籍「Clean Architecture」を読みました。読んだ結果設計ナニモワカランという状態を経てなんとか生み出されたアーキテクチャを、今までちょっとずつ改善していったものです。
Clean Architecture についての説明は今回の趣旨じゃないので、最近見つけたツイート(ポストって言っても伝わらんでしょという意思を込めてツイートと言います)と記事のリンクを貼っておくに留めます。
ツイート
書籍『Clean Architecture』を読んで理解すると「クリーン・アーキテクチャで作りました」という発言は出なくなる。わかる。
記事
世界一わかりやすいClean Architecture
自分の Clean Architecture への理解はこの記事の前半部分くらいまでの理解です。後半の部分は読んでなんとなく言ってることはわかるけど人に説明できるほどの理解には達してないです。
上記ツイートにもあるように、Clean Architecture にしたいね!という気持ちで今の設計になったわけではなく、当時抱えていた課題を解決するためには設計というものを勉強するのが効果的かもしれないぞ、と考えが至り、書籍「Clean Architecture」に書いてあることがなんとなく妥当そうな気がしたので、(なんとなくというのは、Clean Architecture 本が難しくてしっかりとした理解ができなかったことが原因です)そこから着想を得て、必要な機能を整理していって、レイヤー名なんかもなんとなく Clean Architecture っぽい命名にしてみた。みたいな経緯で生み出されています。
当時の課題を思い返すと、こんな感じのことがあったなあと記憶しています。
- そもそも複数名で開発したことがなく、今後メンバーを増やしていくためには何かルールが必要だ
- 一つのクラスでできることをを増やしていった結果、継承関係がだいぶ複雑になっていた
- 開発体験も良くて運用に耐える、ユーザーデータを扱う方法はないものか
アーキテクチャの功罪
功罪って言いたかっただけです。
よかった点
- 実装が早く楽にできる
- 他の人のコードが読みやすい
- UI と機能が分かれていることで UI デザインを待たずに機能を実装できる
- 実装が早く楽にできる
新しいものを実装するときに、既存のルールに当てはめて作っていけばおかしなことにならないので、簡単なことは簡単にできるし、難しいこともその難しさそのものだけに向き合える、という感じがあります。
- 他の人のコードが読みやすい
これは当然だと思うんですが、アーキテクチャが揃っているとコードを追っかけたりどこに何が書いてあるか類推しやすいです。
- UI と機能が分かれていることで UI デザインを待たずに機能を実装できる
レイヤーが分かれていることの恩恵ですね。
特に、UI デザインは作成中だが、機能の方の仕様は固まってる、ということが多いので、そういう時に機能側だけ実装を進められて嬉しいです。
他にもレイヤーが分かれていることで分業ができて助かるという場面はあります。
困った点
設計の存在によって逆に面倒になるケースも存在します。
例えば、どうしても同レイヤーの他クラスを参照したい、とか
間違ってるのはわかっているけれど、これで動くし、時間もないからこのままでいきたい、という状況はあります。
その場合は、設計を保つことだけを正義とするのではなく、
負債を背負ってユーザーに早く価値を届けることとのトレードオフを意識しながら、
メンバーで話し合って対応を決める、という形をとっています。
その経緯を踏んだ上で、
ここはこういう理由でルールを破っています、ということをコメントで明記しておくという対応をしています。
各レイヤーの紹介
以下で各レイヤーについての説明を一つずつしていきながら、そのレイヤーに関する小ネタなどを書き連ねていこうと思います。
Entity
アプリケーションのルールを定義する
具体例
namespace Project.Entity
{
public class Sleep
{
public static bool CanGetUp(DateTime bedtime)
{
bool canGetUp = bedtime.AddHours(1) <= Utility.DateTime.Now;
return canGetUp;
}
}
}
レイヤーの役割
アプリケーションに独自の不変的なルールを記述します。
とは言ったものの、このレイヤーはあまり有効に活用できていないように感じています。
厳密にやろうとすると、UseCase の実装をもう少し Entity に移譲すべきなのであろうが、いちいちやってるとめんどくささが勝るので、明確に「処理」ではなく「ルール」である、もしくは複数の UseCase にまたがって必要なロジックである、という場合にのみ作成されることが多いです。
ここが現状の設計の中で一番自信がない部分ではあるが、実際あまりそれで困っていない気がするので、一旦その程度の認識に留めています。
DataStructure
アプリケーション内のデータの構造を定義する
具体例
using System;
using UnityEngine;
namespace Project.DS
{
[Serializable]
public class UserProfile : Base.SerializableDS
{
public UserProfile() { }
public string name = default;
}
}
レイヤーの役割
永続化が必要なユーザーデータ、アプリ内に組み込むマスターデータの両方を DataStructure として表現します。
自身のメンバーを変数として定義するだけでなく、そのデータを適切に操作/表現するためのメソッドやプロパティを持つことを推奨しています。
Gateway
DataStructure インスタンスと DB レコードの変換を行う
具体例
namespace Project.Gateway
{
public class UserProfileGateway : Base.SingleGateway<DS.UserProfile>{}
}
レイヤーの役割
DS (DataStructure) インスタンスと DB レコードの変換を担います。
このレイヤーは実装のほとんどが Base.Gateway<DS>
に記述されています。
筆者が以前 Web フレームワークの Django を用いた業務をしていたため、Django が提供する ORM のメソッドみたいなイメージで DB レコードへのアクセスを行えるように色々なメソッドが存在します。
以下は実際に BaseGateway に存在するメソッドの一例です。
こんなメソッドが欲しいな、と思ったタイミングで新しいメソッドを作成しています。
public DS First<Tkey>(Func<DS, Tkey> orderKey, Predicate<DS> match = null)
{
match ??= _ => true;
Load();
if (!Exists(match))
{
throw new ProjectException("try First() but found no record.");
}
IOrderedEnumerable<DS> ordered = list.OrderBy(orderKey);
return ordered.First(i => match(i));
}
public bool Exists(Predicate<DS> match = null)
{
match ??= _ => true;
return All().Exists(match);
}
UseCase
アプリケーションの機能を定義する
具体例
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
namespace Project.UseCase
{
public class SleepLog
{
static Gateway.SleepLogGateway gateway = new Gateway.SleepLogGateway();
public static void SaveBedtimeLog(DateTime bedtime)
{
DateTime date = Entity.Sleep.DateTimeToDate(bedtime);
DS.SleepLog log = new DS.SleepLog(data, bedtime);
gateway.Add(log);
}
}
}
レイヤーの役割
UI に依らない、アプリケーションが提供する機能を定義します。
UI を除いた、主な実装箇所になります。
引数を受け取り、必要な値を返す、もしくは新しくデータを保存する、といった操作が多いです。
脳死で書いているとこのレイヤーが肥大化しがちなので、適切に DataStructure, Presenter (時には Entity, Gateway)に必要な処理を移譲するように意識しています。
Presenter
UseCase の機能を UI にとって扱いやすい形で提供する
具体例
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
namespace Project.Presenter
{
public static class CottonFarm
{
static int cottonCountPerFarm = 365;
public static int HowManyFarms()
{
List<DS.SleepLog> allLogs = UseCase.SleepLog.GetAllSleepLogs();
int howMany = (int)Math.Ceiling((float)allLogs.Count / (float)cottonCountPerFarm);
return howMany;
}
}
}
(CottonFarm とは綿花農場ではなく、ユーザーが睡眠記録をつけるたびに生まれる、わたひつじというキャラクターの集う牧場です)
レイヤーの役割
UseCase の機能を UI にとって扱いやすい形で提供するレイヤーです。
大きく2パターンがあり、
一つは UI にとって渡しやすい引数(時には引数なし)で UseCase の機能を提供するパターン、
もう一つは UI が一度に複数の UseCase メソッドを呼ぶタイミングでそれらのメソッドをまとめて一つのメソッドとして提供してあげるパターンです。
以前はこのレイヤーが存在しなかったのですが、その際に、
- UseCase が UI の事情を汲み取りすぎると、UseCase のシンプルさが損なわれるし、そもそも依存関係がおかしい
- UI 側で頑張ろうとすると、UI 側のロジックが大変な感じになる。UI はもっと描画のことだけに集中したい
という課題があり、その解決のために新たに作成されたレイヤーです。
UseCase のメソッドが、そのままの形で UI に取って都合が良い場合は必ずしも Presenter を作成する必要はありません。
View
適切な粒度で UI を定義する
具体例
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UniRx;
using Cysharp.Threading.Tasks;
using Zenject;
namespace Project.UI.CottonFarm
{
using UICottonFarm = Presenter.CottonFarm.UICottonFarm;
public sealed class CottonFormationView : Base.View, UIInterface.IInitializableByController
{
[Inject, SerializeField, ReadOnly] List<CottonFormationUnit> cottons;
public void InitializeByController()
{
InitCottons();
}
void InitCottons()
{
UICottonFarm uiCottonFarm = Presenter.CottonFarm.GetInitialUICottonFarm();
DrawCottons(uiCottonFarm);
}
void DrawCottons(UICottonFarm uiCottonFarm)
{
foreach (CottonFormationUnit cotton in cottons)
{
Presenter.CottonFarm.Cotton cottonData = uiCottonFarm.cottons[cotton.Index];
SetActive(cotton, cottonData);
}
void SetActive(CottonFormationUnit cotton, Presenter.CottonFarm.Cotton cottonData)
{
cotton.SetActive(cottonData is not null);
}
}
}
}
レイヤーの役割
UI の中心となるレイヤーです。
以降のレイヤーは MonoBehaviour を継承しており、シーン上の GameObject にアタッチして利用する Script になります。
メソッド呼び出しに関してはできるだけこちらの制御下におきたいため、Unity が提供する MonoBehaviour.Start()
などのメソッドは極力使わないようにしています。
どの程度の大きさの機能を一つの View とするかの明確な方針はありませんが、変更容易性を重視して、いい感じの大きさで切り分けられるように頑張っています。
Component
Scene 内の個々の GameObject を表現する
具体例
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UniRx;
using Cysharp.Threading.Tasks;
namespace Project.UI.CottonFarm
{
public sealed class CottonFormationUnit : Base.Component
{
public int Index => name.ToIndex();
Renderer _renderer = null;
Renderer Renderer => _renderer ??= GetComponentInChildren<Renderer>();
public void SetMaterial(Material material)
{
Renderer.material = material;
}
}
}
レイヤーの役割
具体的な GameObject を View が扱えるようにするための最小単位のレイヤーです。
Unity における Component を置き換えるような形で使っている影響で、Unity Component が弊社の開発現場で語として登場することがなく、
多少名前がややこしいなとは感じながら、実際あんまり困ることはなく、 Component レイヤーと命名しています。
Component が他の Component にアクセスすることはできず、View が必要なものをまとめて操作をします。(他のすべてのレイヤーも同様に、同レイヤーの他クラスへのアクセスはできません。)
View の仕事を減らせるように、自分自身に関する操作はできるだけ Component 自身が行ったり、同じように扱える Component は共通の Interface を用いて View がまとめて扱えるようにしてあげたりなどを意識しています。
Widget
複数の Component を束ねる
具体例
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UniRx;
using Cysharp.Threading.Tasks;
namespace Project.UI.Shop
{
public sealed class Item : Base.Widget
{
[SerializeField] ItemNameText name;
[SerializeField] ItemPriceText price;
public static void SetText(ItemInfo info)
{
name.text = info.name;
price.text = info.price;
}
}
}
(View 以前の具体例は、よひつじの森の実際のスクリプトに手を加えたものを例としてあげていましたが、Widget は適切なものがなかったのでとても適当に架空のスクリプトを作りました)
レイヤーの役割
複数の Component をまとめて View に提供するためのレイヤーです。
このレイヤーが最も新しく導入されたレイヤーで、特に Prefab として表現されるような、ひとまとまりの UI 部品の親 GameObject にアタッチして、子の GameObject を SerializeField として登録しておく、という使い方をすることで幸せになれることが多いな、という経緯で導入されました。
UIController
View や Scene の制御を行う
具体例
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UniRx;
using Cysharp.Threading.Tasks;
using Zenject;
namespace Project.UI.CottonFarm
{
public sealed class CottonFarmUIController : Base.UIController
{
[Inject, SerializeField, ReadOnly] BgmView bgmView;
[Inject, SerializeField, ReadOnly] NavigationView navigationView;
protected override void AfterAwake()
{
ToOtherScene().Forget();
}
async UniTaskVoid ToOtherScene()
{
SceneName nextScene = await navigationView.OnReadyToChangeScene;
await bgmView.FadeOutBgm();
await SceneController.AddScene(nextScene);
SceneController.UnloadScene(this).Forget();
}
}
}
レイヤーの役割
View スクリプトのメソッド呼び出しや、Scene 遷移に関わる操作を行います。
View の InitializeByController()
はUIInterface.IInitializableByController.InitializeByController()
を実装するメソッドであり、Base.UIController において MonoBehaviour.Awake() のタイミングで呼ばれるようになっています。
UIController は各シーンにつき一つだけ存在します。
必要なタイミングで View のメソッドを呼び出し、
View が公開する IObservable
や UniTask
を用いて、View から必要な情報を受け取ります。
IObservable
と UniTask
は、
複数回発生しうるイベントは UniRx の提供する IObservable を用いる、一度しか発生しないものは
UniTask
を用いる、という形で使い分けるようにしています。
終わりに
長い記事になるぞ、と思って書き始めましたが、
当然のように長い記事になりました。
こんなことを考えながら開発をしています、というところが伝わったり、
読みながら、これは楽しいぞ、と思ってくれた方がいたら嬉しいです。