🔍

Unityにおけるデザインパターンに関して

2023/02/25に公開

こちらの記事はmastアドベントカレンダーの9日目の記事です。
8日目の記事はこちら:エクストリーム・宅通 ~栃木からの宅通奮闘記~
10日目の記事はこちら:coming soon…

こんにちは。mast21のたばこ(@toufuITF21)です。
普段はある会社でゲーム制作に関して学んでいたり、中高生向けにUnityを使用したゲーム制作を教えています。
そんな中でエンジニアとしての設計の大事さを再認識し、一方であまり教育現場でそれが取り上げられていない現状があるな〜と思ったためこの機会に再勉強と記事起こしをしてみました!
ゆくゆくは中高生ないしはそれを教える大学生講師向けに展開していきたいと思っているためなるべく噛み砕いた表現を目指しました!理解が足りていないところも多いと思うので間違った表現などありましたら指摘お待ちしています!

そもそも設計とは?

そもそも設計とはなんでしょう。設計とは言葉の通り、ソフトウェア開発において実際にコードを書き始める前に、「どのような方針でコードを書いていくか」を決めるものです。
例えばシーケンス図と呼ばれるものを書いて処理の流れを可視化したりするステップがあったり、クラスの定義やクラス同士の関係性を洗い出すクラス設計のステップがあったりします。
Unityにおける設計のレベル間や定義などに関しては下記記事が非常に分かりやすいためおすすめです。

Unityにおける「設計レベル」を定義してみた - Qiita

アドカレ_アートボード 1 のコピー.png

なんで設計をするのか?

ここまで読んだみなさんの中に、なんで設計なんてするの?設計なんてしたことないけど、今まで問題なくコードかけてきたから私or僕には必要ないかな〜と思っている方がいるかと思います。
まず第一に設計は自分のためになります。
エラーが出て自身のコードを見直して初めて、そこで処理が複雑に入り組んでしまっていたがために原因の特定にとても時間がかかった、といった経験が誰しも一度はあると思います。
また、機能を追加しようとしたときに、あるクラスがいわゆる神クラスと呼ばれるような、複雑かつ巨大なものになってしまっていて、新たな機能を追加しようとした時に大規模な工事が必要になったりしたこともあるのではないでしょうか?
一般に設計がきちんとなされていないコードは、処理の順序が不明瞭であったり、クラスの役割が統一されていなかったりする状態に陥りやすいです。
一方であらかじめクラスの役割を厳密に定義したり、処理の流れを統一してあげることで、後々不具合が発生したとしても簡単に対応できたり、処理の流れをスムーズに追えるようになったり、新規の機能追加にも柔軟に対応できるようになったりします。
第二に誰に見せてもわかりやすいコードを作れるようになります。
今は1人でプロジェクトを進めていたとしても、もし今後他の人と協力して開発を進めていくことになったらどうでしょう?一緒に作っていくことはなかったとしても、開発を進めていく上でわからないことが生まれ、他人に自分のコードを見せるということは頻繁にあるのではないでしょうか?
そんな時に自身のプロジェクトが統一された一般的な設計ルール(デザインパターン)に基づき作られていると、誰の目にも分かりやすくみやすい状態にすることができます。
そのためにも設計の手法を学んだり、デザインパターンについて知ることが大事なのです。

SOLID原則

そして設計を行っていくにあたり、欠かせないと言われている考え方がSOLID原則と呼ばれるものです。
この原則はオブジェクト指向プログラミングにおける基本的な考え方の一つです。
オブジェクト指向とは簡単にいってしまうと、クラスにデータと処理をまとめていくことでオブジェクトとして取り扱い、オブジェクト同士の組み合わせでプログラム全体を構成していこうという考え方です。
今回の記事ではこの後のデザインパターンに関してもう少し言及していきたいので細かな説明は省きますが。実際のオブジェクト指向言語のメリットや追加型については下記記事がとても分かりやすいです

オブジェクト指向をより理解するために実際に書いて解説する - Qiita

さて話をSOLID原則に戻します。SOLID原則とはソフトウェア開発における守るべき基本ルール5つであり、それぞれの頭文字S,O,L,I,DをとってSOLID原則と呼ばれているものです。こちらに関してもそれぞれ説明すると

  • S(Single responsibility principle):単一責任原則

    1つのクラスの役割は一つであるべき、と言う考え方。

    具体例で説明すると「PlayerManager」と言うクラスがキー入力を受け付けてプレイヤーの座標を変更しアニメーションを再生する、といった状態は非常に良くないです。キー入力を受け付けるクラスや座標変更のクラス、アニメーションを再生するクラスは単独で存在しているべきだし、それらのクラスはそれ以上の役割を請け負うべきではありません。

    これはクラスに役割を負わせすぎることで、少しの機能修正でも必要以上想定以上の箇所に影響が及ぶ可能性があり、処理もまとまりすぎていて非常に見辛いものになってしまうからです。

  • O(Open closed principle):オープンクローズド原則

    機能の追加は簡単にでき、その時に他の関係ない箇所に修正が生じないようにしなければならない、と言う考え方。

    具体例で説明すると、「AreaCalculator」と言うクラスが存在し、その内部の関数「CalculareArea」では引数で渡されてきた図形の種類とパラメータ値(短辺や長辺の値、半径の値など)に応じて面積を計算し返すと言う処理を行ったとします。定義されている図形の数が少なければ「CalculareArea」はそこまで複雑ではないものになりますが、もし追加で20,30の図形に対して対応できるようにしてほしいと言われたとしたら、「CalculareArea」が非常に巨大で見にくいものになってしまうでしょう。

    こうした事態を防ぐためにも具体的な処理は具体的な派生クラスに切り分けて、そこへのアクセスは抽象的なインターフェイスや基底クラスを用いて行うべき、と言う考え方がこのオープンクローズド原則です。

  • L(Liskov substitution principle):リスコフの置換原則

    これは派生クラスが規定クラスの処理やルールを変更するようなことになってしまってはいけないという、考え方。

    例えば「Enemy」と言う規定クラスを定義し、この内部ではlifeの値やそれを減らすAddDamageの関数などを定義したとします。初めの方はこの規定クラスを継承し、「Suraimu」や「Dragon」などの派生クラスが問題なく定義できるでしょう。しかしある時、両腕を破壊しないと本体にダメージがいかないといった性質を有する「Boss」クラスを実装したいとなったとしましょう。そんなときにこのEnemyクラスを継承しようとすると、基底クラスのlifeやAddDamageの関数では実装ができず、追加でbodyLifeやarmLifeといった値を定義する必要がでたり、「Boss」クラスのAddDamage関数ではarmLifeの値に応じて処理を分岐する、といった必要性が生じてしまいます。

    これでは派生クラスで基底クラスの基本ルールに逆らって処理を上書きしてしまっているため、動作の保証がうまくできなくなり、抽象化を通した処理でも意図しない挙動をしてしまう可能性が出てきてしまいます。

  • I(Interface segregation principle):インターフェイス分離の原則

    これは利用しない機能を必要以上に実装しなければならない状況をなるべく避けよう、と言う考え方です。

    例えば全ての敵に共通で存在する機能をまとめた「IEnemy」と言うインターフェイスを定義し、どの敵もこのインターフェイスを継承することでダメージの処理やライフの管理を行っているとしましょう。この時に追加で何回か攻撃することで壊れる木箱を定義する「Box」クラスを実装しようとしたとすると、IEnemyのインターフェイスはライフの概念やダメージの処理を実装しているため一見継承するに適切なように思えます。しかしIEnemyは内部で移動の抽象的なメソッドを定義してしまっているがために、本来動かないはずの木箱の「Box」クラスにおいて移動のメソッドを実装しなければならない状態になってしまうのです。

    こんな時にIEnemyのインターフェイスを適度な細かさに分離、今回の場合だとライフやダメージの概念を持つ「IDamageable」と移動の概念を持つ「IMoveable」に分離することで必要以上な実装を避けられるようになるのです。

  • D(Dependency inversion principle):依存性逆転の法則

    こちらは処理を命令する側の上位クラスが、処理を実装している側の回のクラスに対して依存するようなことがないようにしよう、と言う考え方です。

    例えばボタンを押したときにドアがスライド式に開くといった処理を「Button」クラスから「Door」クラスの「Open」関数を実行する形で実装したとします。この時追加でボタンを押した時に外に開くドアも追加で実装するように言われたとしましょう。「Door」クラスの「Open」メソッドはスライド式のドアにしか対応していないため「OpenSlideDoor」と「OpenOuterDoor」の2つのメソッドを用意し「Button」クラスはドアの種類に応じて実行する関数を変える必要があるでしょう。この用に呼び出し先の下位クラスの変更に応じて、上位クラスにも変更が求められるような状況(上位クラスが下位クラスに依存してしまっている)は良くないです。
    そこで今回のケースでは「IOpenable」と言うインターフェイスを用意してあげて、「SlideDoor」と「OuterDoor」の二つのクラスはIOpenableを継承するようにしましょう。こうしてあげることでButtonクラスは抽象的なIOpenableを通して処理を実行できるようになり、SlideDoorやOuterDoorはIOpenableに依存するといった依存性の逆転ができます。

なるべく簡潔に分かりやすくまとめようと思ったのですが難しいですね、、
SOLID原則に関しては以下の記事が非常に分かりやすくおすすめです。
下記記事はUnityにおけるの話にはなりますが、SOLID原則に関してはフレームワーク問わず大事な概念だと思います。多分

https://www.slideshare.net/torisoup/unityzenject

デザインパターン

デザインパターンとはそもそもどこから来たものなのでしょうか。大元を辿ると、少し昔にGang of Four(4人組)と呼ばれる4人の人が共同して書いた著書にてデザインパターンは初めて言及されました。この本では簡単に言うとソフトウェア開発をしていくにあたってよくぶつかる壁に対し、一般的に取られている解決策としての設計法をデザインパターンとして23にまとめ紹介しています。
この中でも特にゲーム開発、特にUnityにおけるゲーム開発において非常に頻繁に使用されているものに関していくつか取り上げ簡単に紹介していきたいと思います。
その他のパターンなどに関しては以下記事を参照です!

デザインパターン一覧 [23種類] - Qiita

デザインパターン (ソフトウェア) - Wikipedia

Singleton

シングルトンパターンはUnityである程度のゲーム開発の経験がある人なら誰しも必ず通ることがあるデザインパターンなのではないのでしょうか?

このパターンではSingletonと呼ばれる、インスタンスが唯一であることを保証されたクラスを用意しそこへのアクセスをグローバルなものにすることで誰からも自由に使えるようにすると言う仕組みです。

具体的には下記のような実装になります。

public class GameManager : MonoBehaviour{
	private static GameManager instance;
	public static GameManager Instance
	{
		get 
		{
			if (instance == null)
			{
				SetupInstance();
			}
			return instance;
		}
	}
	private void Awake()
	{
		if (instance == null)
		{
			instance = this;
			DontDestroyOnLoad(this.gameObject);
		}
		else
		{
			Destroy(gameObject);
		}
	}
	private static void SetupInstance(){
		instance = FindObjectOfType<GameManager>();
		if (instance == null){
			GameObject gameObj = new GameObject()
			gameObj.name = "GameManager";
			instance = gameObj.AddComponent<GameManager>();
			DontDestroyOnLoad(gameObj);
		}
	}

シングルトンパターンは大変に便利かつ危険なものです。どこからでもグローバルなアクセスを許可する一方でそれは反面どこにこのシングルトンクラスが影響を与えているのかを非常に追いづらくなってしまいます。
それゆえにシングルトンパターンはアンチパターン(なるべく使わない方がいいもの)とされることが多いです。
しかし同様にシングルトンパターンは非常にわかりやすくシンプルで初学者向けであり、継続的な機能維持や拡張を求められるような規模のプロジェクトでもない限りは、非常に便利なものになります。その効果を最大限活用するためにデメリットを深く理解し、制御できる範囲で扱えるようにしましょう。

Observer

Observerパターンでは、何かが起こったことをイベントとして通知するsubjectというオブジェクトに対してそれを受信し、何らかの処理を実行するobserverが複数存在します。これは例えるならラジオ番組の放送局がsubjectであり、それを聴く視聴者がobserverである状態です。

Observerパターンの最大のメリットとしてはその非依存性にあります。subjectは何らかの出来事を通知しますが、それを誰がどのように受け取ってどのような処理をするのかに関しては全く無知であり、これはobserverに対して非常に非依存的であると言えます。同時にobserverも、subjectに対しては依存的であっても(subjectによってobserverの状態は変化していくため)observer同士はお互いを知ることはなく、非依存的でいられます。

さらにはC#のビルドイン機能としてSystem.Actionが存在しています。そのため、必ずしもMonobehaviourを継承せずともsubjectの機能を簡単に実装することができるのです。
また他にも、ObservableCollectionと呼ばれる、要素の追加、削除、更新などを監視できる動的なデータ管理方法があらかじめ実装されていたりしており、ここらへんの仕組みを再構築せずとも使用できるのは強みです。

欠点として挙げられるのは、observerのsubjectに対する参照は依然として強く残ったままになってしまうことです。ただしこれに対してはシングルトンのEventManagerのようなクラスを作成してあげることで全てのobserverの参照をこちらに集約することでsubjectとobserverの依存関係を完全に無くしてあげることもできます。

MVP(Model View Presenter)

MVP(Model View Presenter)パターンはMVCパターンという名前でWebフレームワークでのデザインパターンとして広く取り入れられているデザインパターンとよく似ています。今回はMVPパターンの紹介なのでこちらについての説明は省きますが、それぞれいろいろ異なる点共通している点があるので気になる方はこちらの記事などを参照してみてください。

MVCモデルについて

MVPパターンとはその名が示す通り、コードを三つのレイヤー層に分離します。それぞれについて軽く説明すると、

  • Model:データを管理する。データや状態の変化のイベントを発行する。
  • View:成形されたデータをUIなどを通してユーザの見える形で描画する。ユーザからの入力を受け取りイベントを発行する。
  • Presenter:ロジックを持つ。ModelやView、他クラスからのイベント通知を受け取って、データ整形を行い、ViewやModelに渡す。

となります。MVPパターンの最大のメリットとしては、データ(lifeの値や、Playerの座標など)とロジック(敵の攻撃を受けた時にlifeを減らす、という処理や上下左右キーが押されたときに座標を更新する、という処理)とインターフェイス(実際に現在のlifeをハートのアイコンなどに変換してUI上に表示したり、更新された現在地をPlayerのTransformに反映したり)を明確に分離しすることができることです。そのことによってそれぞれの単体でのテストを容易にしたり、機能の拡張などが行いやすくなります。
メリットが多いように見えるMVPパターンですが、Unityのようにデータとロジックが結合したコンポーネントなどを扱うフレームワーク内では必ずしも全ての場合でこのパターンが使えるわけではないため、使い所は慎重に見極めなければなりません。

最後に

デザインパターンはあくまでも考え方の一つなのでこれを学んでおけばつよつよエンジニアまっしぐら!と言うわけでは絶対にありません。
同時にあるパターンだけを覚えておいて、どんなプロジェクトのどんな箇所にもこのデザインパターンを適用すれば良いというものではありません。
それぞれのパターンに利点・欠点はあるため適材適所で柔軟に使いこなせるようになることが大事です。
さらにエンジニアとして働いていくにあたってはおそらく全員が通るような基礎の部分の知識であり、これを自身の引き出しに入れて置けるだけでコードの読解力や構成力が上がるはずです。多分
今回の記事を作成するにあたり、以下のUnity公式のあげているデザインパターンの資料、プロジェクトを参考にしました。

ゲームプログラミングパターンでコードをレベルアップさせよう | Unity Blog
https://github.com/Unity-Technologies/game-programming-patterns-demo

というより良い資料が公式から上がったのでなるたけ分かりやすく噛み砕いて還元したいな〜と思い記事に起こした形です。
あまり纏めることができず長々としてしまいましたが、今後なるたけ分かりやすくまとめていきたいな〜と思っています。

GitHubで編集を提案

Discussion