エンジニア

Unityを利用した大規模なゲーム開発にクリーンアーキテクチャを採用した話

投稿日:

こんにちは、タノシムスタジオテックリードの吉谷です。タノシムスタジオでは2011年ごろからクライアントアプリケーションの開発にUnityを利用し始め、いろいろ試行錯誤をつづけた結果、現在ではクリーンアーキテクチャの考え方を取り入れています。

今回は、Unityを利用したゲーム開発にクリーンアーキテクチャを適用した例として、導入理由、具体的な構成、実際に感じたメリット、デメリットなどを紹介したいと思います。

クリーンアーキテクチャとは

クリーンアーキテクチャはRobert C. Martin氏が自身のブログにて発表した、すべてのソフトウェアアーキテクチャが守るべき考え方やアプローチがまとめられた概念です。以下のような同心円状の図で説明されることが多いかと思います。


出典: The Clean Architecture

クリーンアーキテクチャの説明自体は、検索していただければ優れた記事や書籍などが見つかると思いますので、今回は詳しい説明は割愛させていただきます。ものすごくかいつまむと、以下の重要なルールが示されています。

ソースコードの依存性は、上位レベルの方針だけに向かっていなければならない

「上位レベルの方針」というのは図中の円の内側のことで、ソフトウェアのコードの中でとても重要な概念や処理などのことです。

加えて、クリーンアーキテクチャはこの重要なルールを実現するために必要となる、具体的な方法がまとめられています。例えば以下のようなものです。

  • DIP(依存性逆転の法則)を利用した依存とデータフローの制御方法
  • 上位レベルと下位レベルの境界の引き方・データのやり取りの仕方

つまり、クリーンアーキテクチャは、単にコンポーネントの命名やレイヤーの分割方法などを取り決める設計パターンの一種ではく、普遍的な設計に対するアプローチを示すものと捉えるのが良いのだと思います。

なぜクリーンアーキテクチャを採用したか

タノシムスタジオでは、Unityを利用したクライアンアプリケーション開発にて、過去にPresentation, Domain, Dataの三層レイヤードアーキテクチャを採用していましたが、常に課題を抱えている状態でした。それらのは以下のようなものです。

1. メンバー間でレイヤー/コンポーネントの責務の認識がずれることがあった

様々なバックグランドや習熟度のメンバー集まっているチーム開発にて1、リードエンジニアが予期しないレイヤーやコンポーネントに機能が実装されたり、規約や指針が無視されたレイヤーやコンポーネント間のアクセスが発生したりしました(コミュニケーションの問題も大きいですが...)。

また、リードエンジニアが設計や実装のレビューをする際でも、どのコンポーネントに所属させるべきかの説明が、時々微妙にぶれてしまうこともありました。これは、運用しているアーキテクチャや設計原則に対しての理解が浅い部分もあったかもしれませんが、三層という縛りに囚われて、実際にはマッピングするレイヤーや責務の分割が足りなすぎたことにも要因があるように思います。

2. 依存関係の地獄/針の穴を通すような機能改修/テストがしたくてもできない

ひとつの機能を修正するにしろ、とにかく影響範囲が大きくデグレしやすい状態でした。慎重に調査を行いつつ改修しても、微妙な影響を読みきれずバグがリリースされてしまうこともありました。正直かなりの地獄でした(汗。

また、品質担保のために自動テストをしようと思っても、事実上できない状態でした。これは、一つのクラスをユニットテストをする際でも、複数のクラスに依存していたり、シングルトン、MonoBehaviourなどのコンポーネントが芋づる式に依存してしまい、数多くのオブジェクトを一式用意する必要が出てしまうためです。つまり、モックを注入可能にするなどのリファクタリングをわざわざしない限りは、とてもではないですがテストは現実的ではありませんでした。

3. 様々なゲームの要件で、設計のパターンが統一できない

新しいアーキテクチャのパターンや、フレームワークなどの調査や研究などを行いましたが、特定パターンを効率化するものや、MVC、MVP、MVVMベースの限定的なフレームワークでは、全体を統一的な方針で説明するのがかなり難しいという問題がありました。

ゲームアプリケーションは様々な機能を実現しなければなりません。例えば保持しているアイテムの一覧のデータ表示・更新などのシステマチックな機能から、キャラクター操作のアクション性が高いもの、パズルロジックなどです。

特定パターンに特化したアーキテクチャではシステマチックな機能においては高い効果を生むかもしれませんが、アクション性が高い部分や、はたまた将来おとずれるかもしれないとんでもない企画や要件で、それらのフレームからはみ出たり、複雑性をコントロールできないリスクがありました。

そしてクリーンアーキテクチャの導入

今思うと、アーキテクチャの問題というよりは設計原則への理解不足やコミュニケーションの問題も大きかった気もしますが、何かチームで共通認識を打ち立てつつ軸を保てるブレイクスルーが必要でした。

そんなとき、クリーンアーキテクチャの書籍や適用事例が世に出始め、調査・検討をしたところ、様々なアプリケーションに適用できるルールやアイディアをベースとした考え方がマッチしました。

実際に小規模なプロジェクトで試してみたところ、依存性のコントロールやレイヤーやコンポーネント分割の提示例が、上記の問題を割とうまく解決できそうなことが分かり、チーム全体で推奨していこうという流れになったのです。

具体的な構成

タノシムスタジオでのクライアントアーキテクチャをざっと図示すると以下のような感じになってます。図の色合いは冒頭の同心円状のクリーンアーキテクチャの概要図とそれとなく対応させています。一部端折っていたり、実際の実装箇所によっては他にもインターフェースが定義されていたりしますが、大枠はこのような形です。各レイヤーやコンポーネントの役割・ポイントを簡単に説明します。

Domainレイヤー

Domainレイヤーはアプリケーションの中で、ビジネスロジックが実装される特に重要なレイヤーです。他レイヤーの依存の矢印は全てこのレイヤーに向いています。

このレイヤーには、ビジネスロジックの「実装」の他に様々な抽象化された方針、つまりインターフェースが定義されています。これは図に記述してあるIPresenterやIRepositoryなどもそうですが、ゲームには直接関係ないもの(たとえば通知機能など)まで様々です。Domainレイヤーはこのインターフェースを自由に呼び出して使いますが、実装は他の外側のレイヤーで頑張って行います。物によっては外部のSDKの機能で実現されるものもあります。

UseCaseは後述するEntityレイヤーとインタラクションして、一連の処理を実行した後、その結果をDomainResponseModelとしてPresentationレイヤーに送信します。

Entityレイヤー

EntityレイヤーはDomainレイヤーのさらに内側に存在する最も重要なレイヤーです。Entityレイヤーに所属するオブジェクトはUserやCharacterなどのいわゆるModelのような立ち位置のオブジェクトや、単なる計算式や関数を表すオブジェクト、場合によっては複数のオブジェクトや計算式を扱って処理を完結させる物など様々です。

ここは、プロジェクトにおいて最も重要で純粋なビジネスロジックを実装する場所と定義してますが、抽象的で説明や解釈が割と難しいところです。以下のように説明する場合もあります。

  • 他のゲームと区別できるような特徴的な概念やロジック
  • 仕様書で定義されている重要な計算式
  • サーバーサイドと共通している概念

Presentationレイヤー

Presentationレイヤーは情報や画像などを画面に表示する処理と、ユーザーからのインタラクションを受け付ける処理を担当します。ゲームアプリケーションでは特に見た目に関する処理などが多いので、かなり大きいレイヤーです。メニューやボタンのUI、キャラクターや背景など、見た目として現れるすべての部分を司ります。

PresenterはDomainレイヤーで定義されているIPreseterインターフェースを実装しており、UseCaseからの命令を受け付け、出力されたDomainResponseModelをViewが利用できるViewModelに変換します。この際、その変換が複数のPresenterで利用される場合は、設計時に適宜Translatorと呼ばれるクラスを定義して処理を委譲したりします。

Viewは全体のアーキテクチャの中でMonoBehaviourを継承する数少ないコンポーネントで、uGUIのオブジェクトやSpriteRendererなどとやりとりを行います。具体的にはPresenterからViewModelを受け取り、そこに記述された情報通りの情報を表示するといった具合です2。なお、タノシムスタジオではUI的なビューの実現には、uGUI機能群を扱いやすくした独自のUI Frameworkを利用しています。また、後にも触れますがUniRxのReactivePropertyにより、ViewModelの内部のパラメータがダイナミックに変わるような場合もあります。

さらに、PresenterはIViewDelegateを実装することでViewからのイベントを受け取ります。なぜ、わざわざIViewDelegateのインターフェースを定義しているかというと、プレハブなどのアセットデータを保持し煩雑になりやすいViewをうまく使い回せるようにするためです。似通った機能の場合、View自体は同じでも、別のPresenterとUseCaseの実態がバインドされ、別の画面を実現するといったケースがあります。

Dataレイヤー

Dataレイヤーは、Domainレイヤーがデータにアクセするための機能を提供します。具体的にはサーバーやローカルストレージと通信し、データの取得や更新などを行います。

RepositoryはDomainレイヤーで定義されたIRepositoryインターフェースを実装し、Domainレイヤーが特に何も考なくてもEntity内の永続化(もしくは横断的に保持)すべきオブジェクトを利用できるようにします。つまり、UseCaseはIRepsitoryにアクセスすれば、面倒な通信やデシリアライズ処理、キャッシング処理、インスタンス化やマッピング処理などを気にせずに、ビジネスロジック実行に必要なオブジェクト(Entity)を取得したり更新できるということです。

さらに、Repositoryはデータアクセスのためのインターフェースが定義されたIDataStoreを利用することで、通信などの処理を別のコンポーネントに委譲します。IDataStoreの実装は、こちらもPresentationレイヤーと同様に独自のNetworkingフレームワークを利用したり、ローカルストレージにアクセスするクラスであったり、PlayerPrefsを利用するものなどがあります。また、Dataレイヤーでは通信や永続化などで用いるシリアライズのためのデータ構造をDataと定義し、この一時的なオブジェクトからEntityレイヤーのオブジェクトへの変換はRepository、もしくは必要に応じて専用のTranslatorが担当します。

AppMainレイヤー

AppMainレイヤーは、アプリケーションもしくはシーンなどの一定の機能群が起動する際のいわゆるエントリーポイントであり、各レイヤーのコンポーネントの依存注入や準備を行います。これらは後述するDIフレームワークの一部(Installer)であったり、システム全体やライブラリの設定情報(Env/Config)などが記述されています(それらのインターフェースの定義はDomainレイヤーであることもあります)。

すべてのレイヤーへ参照が可能で、最も詳細を知っているコンポーネントであり、下手をすると色々な実装がなされてしまう部分です。心を強くもって設計ルールの責務通り各レイヤーにそれらの実装を委任するようにしています。また、都合に応じてUnityからのイベント(OnApplicationPauseなど)やSDKからのイベントを受け取って処理をトリガーしたりなどもしています。

その他/画面遷移

その他、全体の画面遷移などを司るレイヤーや機能群があるのですが、説明や図が複雑化してしまうので今回は割愛させてもらいました。

その他のポイントや実践していること

概念上のクラス図などは大体上記で説明した通りなのですが、実際に業務レベルのプロジェクトにクリーンアーキテクチャを導入する上で、他にも考えたことや実践したことがたくさんあります。それらをいくつか紹介します。

DIフレームワーク(Extenject)の利用

クリーンアーキテクチャを適用する上で、比較的多くのコンポーネント分割や実態の依存注入を行う必要があるため、UnityのDIフレームワークであるExtenjectを導入しています。これは、開発効率の向上につながっていると思います(反面、メンバーの学習コストが一番高かったところな気がします...)。

ただし、コンテキストやInstallerの分割などもかなり慎重に行い、一定のレギュレーションを設けています。あくまで、具象クラスのインスタンス化や依存注入を自動化しているだけということを念頭に、たとえDIフレームを利用しないとしても破綻ができるだけしないように用法・用量に気をつけています。

Reactive Extensions(UniRx)の利用

サーバーとの通信を行う上で非同期を管理する仕組みはかなり重要なポイントですが、これにはReactive Extensions(UniRx)を利用しています。ただしこちらも、利用場所や利用方法には特定のレギュレーションを設けており、複雑化しないように気を配っています。

ここで特記すべきは、今回紹介しているアーキテクチャでは、実はDomainレイヤーからの表示処理のイベントを受け取る方法が2パターンあるということです。1つは上記のクラス図のようにUseCaseがIPresenterをメソッドコールするパターンです。もうひとつは、UseCaseがIObservableを返すパターンです。

UseCaseがIPresenterをメソッドコールするパターン

こちらは、主にアクション性が高いコアゲームのような画面で採用されています。このような画面では、あるひとつのインプットがDomainレイヤーに入力された結果、画面のいろいろな部分の見た目の更新を行う必要があるため、この方針の方がシンプルに実装でき都合が良いのです。また、パフォーマンス的に優位な側面もあります。

UseCaseがIObservableを返すパターン

こちらは、主にショップやキャラクター強化などの、システマチックな画面での通信を挟むケースの画面で採用されています。こちらのような画面では通信中にローディング画面を挟んだり、結果によってはエラー表示をしたり、戻るボタンを押したときに処理を中断するなどをする必要があるためUniRxと相性がとてもよいのです。しかも、Rxの利点でもあるようにかなり簡潔にかけます。

各レイヤーごとにAssembly Definitionを配置

Domain、Presentation、Data、AppMainごとにAssembly Definitionを作成し、上記の概要通り各Assemblyに参照の制限を設けています。チームに対してアーキテクチャの説明やコードレビューなどは行っていますが、人間心が弱かったり、認識というものはズレて当たり前なので、機械的に制限をかけてしまうことはとても効果が大きかったです。

本来もう少し細かくアクセス制御をすべき、ということもあるかもしれませんが、今回はこのぐらいのレイヤーごとの制限がコスパがよかったように思います。特にレイヤー間の境界の部分は、プロジェクトのアーキテクチャの原則として、かなり厳密に守りたかったこともあります。

クリーンアーキテクチャの輪読会を実施

クリーンアーキテクチャを本格的にチームとプロジェクトに導入する上で、メンバー全員で輪読形式の勉強会を行いました。これは、クリーンアーキテクチャの本質を知れたということもありますが、なによりもSOLID原則などの設計の基本を学び直したり、今までの実例と照らしながら議論を行い、チーム全体でアーキテクチャや設計というものに向き合ったことがとても良かったです。「設計やアーキテクチャを重視することは、とてもメリットがあることだ」という認識をチームで共有できたように思います。

感じたメリット

次に、クリーンアーキテクチャを実践してみて、実際に感じたメリットや良かったと思う点を紹介します。

突拍子もない設計・実装が減った

とにかく、コードレビューしたときに「なんだこれ!?」と思うことが減りました。例えばビューの処理を担当するコンポーネントに重要なビジネスロジックが実装されていたり、めちゃくちゃ責務を持ちまくってるGodクラスの誕生などがほぼほぼ起きませんでした。

これは、予め基本となるコンポーネントがそれなりに細かく分割されていたことと、ルールや責務の説明が明確にできたことがメンバー間の認識の齟齬を埋めたように思います。また、基本的には依存性のルールを守っていれば、予め示したレイヤーやコンポーネントを厳密に守る必要はなく、サブのレイヤーを作ったり機能を委譲する選択肢を柔軟にとれたことも大きいです。

針の穴を通す改修やリファクタリングが減った

依存関係が本当にシンプルになりました。色々な場面でバウンダリーを意識してインターフェースの定義とDIPがなされ、循環参照も一つもない状態です。以前までは、一つの機能を改修しようと思うと、影響範囲をよく調査して慎重にコードの変更を行っていましたが、その苦痛がかなり減ったように思います。

自動テストが割としやすくなった

重要なDomainレイヤー付近のテストはかなりしやすくなりました。これは、依存関係が制御されたことでモックやドライバーなどのテストするための準備が相当楽になったからです。全ての機能に関しての自動テストはコストの関係でできていませんが、重要なドメイン付近の機能は自動テストを書ける状態になったのは大きいです。

詳細の決定を先送りできた

クリーンアーキテクチャを提唱しているMartin氏の主張でもある「いいアーキテクチャとは重要な決定を遅らせてくれる」というように、決定を遅らせてコストを節約することができたケースがあります。

具体的には、データをサーバーからやり取りするときのフォーマットや処理についてです。この付近の機能は開発初期のころにシンプルな汎用性のあるフォーマットで実装をさっと済ませましたが、過去の経験からパフォーマンスがボトルネックになることが予想されました。ただ、機能が出揃っていない開発初期段階では、そのリスクは不確定でした。

しかし、クリーンアーキテクチャの教え通り、その部分のインターフェースは上位レベル(円の内側)に定義し、詳細の実装がそれに依存する形になっていたので、「問題が起きたときに、下位の実装を何かに変更すればよい」という気持ちで開発を進めました。

結果として、その部分の性能は、初期の実装方針で十分であることがわかり、他の重要な課題に意識を割くことができました。

感じたデメリットや課題

反対にクリーンアーキテクチャを導入することに対するデメリット周りのトピックや、現状自分が感じている課題点について紹介します。主にクリーンアーキテクチャそのものの問題というよりは、自分やチームの解釈・適用方法などからくるものが多いように思います。

ファイルやクラス数が多くなった?

一般的にクリーンアーキテクチャは各レイヤーの境界を明確に意識したり、責務をより厳密に分割していくことが多いと思うのでクラス数やファイル数が多くなる傾向にあると思います。弊社プロジェクトでも、世の中の一般的な例にもれず、一つの機能を作るために用意するコンポーネントは圧倒的に多くなったと思います。これは、ちょっとした画面を新規に作る際に、ものすごく面倒で冗長に感じ、そのような場合は開発速度は今までの方法より下がっていると思います。

ただし、習熟度やメンバーによっては、「お作法」に従って設計・実装すればよいので、逆に早くなったという意見もあります。また、複数人で実装する際は、見事にレイヤーごとに作業分担して、今までと比較してかなりの速度で作り上げてしまうケースもありました。さらに、新しく作る際には面倒と思っても、後々改修する際に特定のレイヤーだけを修正すればよい、などの後から利いてくるメリットもあるように思います。

ファイルやクラス数が多くなった -> 冗長な構造や変換がある

正直いうと、ファイルやクラス数が増えてしまう事自体は問題ないと思っています(ファイル検索性の低下、コンパイル時間/バイナリサイズが増えるなどは多少あるかもしれませんが)。いくつかのファイルは自動生成やテンプレートからの生成などのサポートを行っていますし、DIも行っているので、多少なりとも実装の負荷は下がっているからです。

一番問題に感じているのは、場所によっては、冗長なコンポーネントが生まれてしまったことです。これは、割とアーキテクチャのクラス図や「お作法」のようなものが、強くチームに浸透してしまったせいかもしれませんが、同じデータ構造を持つオブジェクト同士に変換する処理などが散見されています。例を上げると、Domainレイヤーから出力されたデータ構造をViewModelに変換する部分がそれにあたります。

ここは、もう少し、方針をゆるくしたり(ドメインで定義した構造などをある程度利用するなど)、マッピング処理を自動化したり、アーキテクチャ全体を少しシンプルにしても良かったように思います。また、「この処理やクラスはなぜ必要なのか」、「意味のない処理を書いているかも」のような違和感を、チーム全体で共有できればもっとよくできたかもしれません。

Entityのレイヤーがスカスカぎみになってしまった

クリーンアーキテクチャにおいて「Entity」の定義は「エンタープライズビジネスルール」とされており、様々な重要なルールや構造が定義されていくべきですが、なぜか割とスカスカになってしまいました。例えば「Entityレイヤーにモデルのような構造が記述されるが単なるプロパティを持っているだけのことがある」といった具合です。

何が起こったかというと、DomainレイヤーのUseCaseに割と重要な処理が記述されることが散見されました。これにより、複数のUseCaseクラスに重複した処理が書かれることが多くなってしまっています。プレゼンテーション側に処理が漏れ出ることは少なくなったので、だいぶマシなのは確かなのですが、、、

クリーンアーキテクチャを実践する上で一番むずかしいものは、Entityレイヤーの解釈や設計、または意識の統一のように思いました。Entity内の構造や実例はあまり紹介されていないように感じますし、そもそも、ゲームアプリケーションにおいて、どの部分がアプリケーションに依存しないルールであり、どの部分がアプリケーション固有なのかはっきりしない部分があるように思います。このあたりは、もう少しEntity内のコンポーネントの設計や、チームの共通認識を揃えることに注意を払うべきでした。

明確な画面要件が先行するため、ドメインのレイヤーが概念的にはViewに依存してしまう傾向がある

ゲームアプリケーションの特性などもあるかもしれませんが、画面要素を意識しながらDomainレイヤーの処理を実装することが多くあり、DomainレイヤーがPresentationレイヤーに引っ張られているように感じられることが多くあるような気がしました。クラス図的には依存は内側に向いているが、概念的にはドメインオブジェクトが表示の都合などを割と考慮してくれているような感覚です。

タノシムスタジオにて機能を一つ作りあげる際のワークフローは以下で進むことが多いです。

  1. 要件、仕様、画面要件決定(プランナー)
  2. 画面仕様、デザイン、レイアウト決定(デザイナー)
  3. コンポーネント設計・機能実装(エンジニア)

そのため、エンジニアの思考がView側から始まり、ビジネスルールをどのように定義するかということより、どのように表示物のデータを用意するかということに意識が向きやすいからだと思います。これは上記の「Entityのレイヤーがスカスカぎみになってしまった」という問題に関連しているかもしれません。

ただし、これはこれでうまく回っていますし、Presentationレイヤーのコンテキストが大きくが変わる(GUI=>CUI, HTML=>PDF、Mobile=>Standalone, uGUI=>???, Unity=>Unreal Engineのような)ことは想定していないので、そこまで問題はないのかもしれません。反面、このあたりのクリーンアーキテクチャに対する厳密ぎみな取り組みは全体の構造を複雑化させ「冗長な構造や変換がある」の問題を引き起こしている可能性はあります。

チームの学習コストやオンボーディングにコストはかかる?

チームに新しい概念を取り入れるときに一番気になるところは、学習コストが高いのではないかというところですが、クリーンアーキテクチャに関してはめちゃくちゃ大きいリスクやデメリットには感じませんでした。たまたま、弊社ではチーム内で設計に対するモチベーションが高まっていたり、タイミングよく勉強会を行えたり、小さめのプロジェクトで実験などのができたのは幸運でしたが、、、

大きめのプロジェクトをうまく作ろうと思うと、何かしらのアーキテクチャの戦略は必要です。これはどのようなプロジェクトでもメンバーにアーキテクチャの方針を理解してもらう必要があるということを意味します。新しくJoinするメンバーに対しても説明が必要なことは同じです。そのときに、理にかなったアーキテクチャのほうが説明がしやすく、チーム内でのドキュメントも整備されやすいかと思います。また、書籍なども紹介することもできます。なにより、コード自体が整理されていれば、「とりあえずここと同じようにつくってほしい」と頼むだけで、少しずつ学習できることが多いように感じています。

まとめ

今回は弊社スタジオのUnityプロジェクトにクリーンアーキテクチャを採用した事例を紹介しました。クリーンアーキテクチャが示すいくつかの普遍的なルールは、これまでチームで課題を感じていた設計やソフトウェア構造に強い軸を作り、関心事の分離や、依存と複雑性のコントロールをよりうまく達成したように思います。これはコストやデメリットを上回るメリットがあったと感じています。

ただし、上記で述べたように、現在の解釈や適用方法には、まだ課題も感じており、ベストな解を模索し続けたり、チーム内で認識を合わせていく必要があるように思います。これには、設計ルール・理論の正しさもあるのですが、実用性を考慮した割り切りなど、原理主義的になりすぎないこともポイントになりそうです。

今回の事例は、あくまで弊社スタジオ・チームでの解釈や適用例であり、クリーンアーキテクチャとして間違っている箇所や見当違いのようなものがあるかもしれませんが、同じような課題や、チーム全体で設計やアーキテクチャに向き合う際に、なにかしらの参考にしていただけたら幸いです。


  1. クライアントのエンジニアチーム規模は5〜10人くらいの規模です。 

  2. 紹介しているクラス図ではPresenterがViewを保持しており、上位レベルの方向のみ依存させるというルールに沿っていない点が指摘事項として挙げられます。今回のViewとPresenterは密接に連携するケースが多く、課題感のセクションで述べる通り画面要件策定フローの存在感が大きいことと、UIフレームワークと密になっても良いという判断から簡略化のためこのような構造になっています。ただ、IViewのようなInterfaceを一応切っておくか(Presenterのテストがやりやすくなる)、うまい具合にViewModelとバインドできるような仕組みの方がよかったのかもしれませんが微妙なところです。 

採用情報

ワンダープラネットでは、一緒に働く仲間を幅広い職種で募集しております。

-エンジニア
-, ,

© WonderPlanet Inc.