はじめに
Unityがプログラミングパターンのサンプルリポジトリを公開していたので、自分の言葉でまとめてみようと思いました。
詳細な説明というよりかはパッと見でなんのことか分かる形でまとめて行きたいと思います。
私の頭の中の解釈がメインなので、間違っている内容があるかもしれませんが、ご了承ください。
※2024/9/30追記
Unity公式から詳細な内容が公開されていました!!
プログラミングパターン
1~5はコードのみのデモになっていて、いわゆるSOLID原則です。
6以降は実行可能なシーン付きのデモが入っています。
1 SingleResponsibility(単一責任の原則)
クラスに様々な機能を実装せず、一つの機能のみの責任をもたせる。
デモでは
デモでは4つのクラスがありました。
クラス名 | 役割 |
---|---|
Player | プレイヤー本体。ここで各機能のクラスを参照する。 |
PlayerAudio | 跳ね返り音を鳴らすためのクラス |
PlayerInput | コントローラーの入力を取得するクラス |
PlayerMovement | プレイヤーの移動をするクラス |
2 OpenClosed(オープン・クローズドの原則)
機能拡張はいくらでもできるようにして、機能そのものの修正はできないようにする。
デモでは
デモでは円や四角に対してそれぞれ面積を求めるための関数を用意せず、それらの基底クラスで面積を求めるための関数を用意しています。
このようにすることで、三角ができたとしても外部からは円や四角と同じ関数を呼び出すことで面積を知ることができます。
つまり、面積を求める機能に修正を加えることなく、それぞれの形ごとに計算方法を決めることができます。
3 LiskovSubstitution(リスコフの置換原則)
オブジェクト指向プログラミングにおいて、サブタイプのオブジェクトはスーパータイプのオブジェクトの仕様に従わなければならない、という原則
(Wikipedia)
スーパータイプ=基底クラス
サブタイプ=派生クラス
捉えどころが難しい説明ですが、
基底クラスのフィールドに、その派生クラスのどれを代入しても動作可能な状態にしておかないという意味だと捉えるのが良いかなと思います。
例えば、キャラクタークラスを継承したエネミークラスとプレイヤークラスがあったとして、キャラクタークラスで用意したフィールドにエネミー・プレイヤーどちらを入れたとしても正常に動く作りにする必要があるということです。
デモでは
乗り物を例にしたデモが入っていました。
乗り物といえば、前にすすめるし、後ろにも進める、また向きも変えれるから基底クラスにそれぞれの関数を用意しておこう。
では、だめです。
乗り物の一種である電車は前と後ろに進むことはできますが、自分で向きを変えることはできません。
なので、IMovable,ITurnableのインターフェースに定義し、レールを走る乗り物、道路を走る乗り物にクラスを分けて、それぞれ適切なインターフェースを実装しようというコードになっていました。
もしなんでもできる乗り物クラスを定義していて、もともと車が格納されていたものを、電車に変えたら右に曲がれないという事になって動作が保証されなくなってしまいます。
4 InterfaceSegregation(インターフェース分離の原則)
インターフェースは細かく分けましょう。
一つのインターフェースに色々詰め込むと、特定のオブジェクトに実装の必要がないものが入ってしまい、実装を約束するインターフェースの役割が果たせなくなってしまいます。
デモでは
以下のインターフェースが用意されていました。
インターフェース名 | 用途 |
---|---|
IDamageable | ダメージを受けるものに使用 |
IExplodable | 爆発するものに使用 |
IMovable | 移動可能なものに使用 |
IUnitStats | かしこさ・耐久値・強さなどのパラメータがあるもの |
上記のインターフェースの仕様例として、2つのオブジェクトの例がありました。
public class EnemyUnit : MonoBehaviour, IDamageable, IMovable, IUnitStats {
// 実装は省略
}
public class ExplodingBarrel : MonoBehaviour, IExplodable, IDamageable {
// 実装は省略
}
5 DependencyInversion(依存性逆転の原則)
ある特定の処理をするためのクラスに、不要である詳細な情報は持たせるべきではないという原則です。
デモでは
スイッチで開くドア・発動するトラップを例に上げていました。
スイッチにドアの参照をもたせるべきではありません。
もし開発が進んで、スイッチで動くものはドアだけじゃないよねとなったら、地獄の分岐が始まってしまいます。
ISwitchableというインターフェースを定義して、ドアとトラップにそれぞれ実装します。
スイッチはドアのような具体的な参照ではなく、ISwitchableの参照だけを持つようにすれば、この先スイッチで開く箱が現れたとしても柔軟に対応していくことができます。
6 Factory
デモに書いてあるように、共通のインターフェースで異なる生成物を生成し、それぞれの生成物は独自の生成ロジックを持ちます。
活用例
ゲームでありそうな活用方法としては、プレイヤーが装備する武器の生成あたりでしょうか。
Factoryクラスに武器IDと経験値などのデータを渡して、適切なオブジェクトを生成させます。
経験値によって武器のパラメータは変わりますし、特有のスキルも持つかもしれません。
利用する側は、初期化処理の詳細を知らなくて良いですし、初期化処理を一箇所で行うことができます。
実際の業務で利用したことがある使い方は以下の2つです。
リクエストファクトリ
クエストの処理はほとんど同じでも、メイン・デイリークエストや、イベントクエストのサーバーAPIがそれぞれ分かれていました。
またレスポンスで返ってくるデータの構造はほとんど一緒で、名前が違っていました。
そこで、共通のデータを取得できるPost関数を持つインターフェースを定義します。
Factoryクラスはクエストのタイプ(Main,Daily,Event)とイベントID(イベント以外は0)を受け取り、共通のインターフェースを返します。
そうすることで、クライアントではAPIの違いを意識せずに共通のリクエストとして扱うことができました。
マスタデータ・サーバーデータの統合
チャレンジやミッションは、上記のリクエストの話と同じようにAPIが、Main・Daily・Eventで分かれていて、
さらに、クライアントは表示のためにマスタも参照しながらコードを書かないと行けない状況でした。
マスタの読み込み用のクラスもそれぞれのタイプによって分かれてしまっています。
それぞれのページでそれぞれ処理をしていたらそのうち収集がつかなくなるのと、タイプが増えるごとに作業が増えてしまいます。
ここでもファクトリの出番です。
マスタデータとサーバーデータを統合したデータクラスを作成し、ファクトリクラスの中でそれを生成します。
また、マスタとサーバーデータはそれぞれ共通の構造が多いため、共通部分は基底クラスを作成しそれを継承させます。
そうすることで、生成処理も共通化することができるので、タイプが増えた時にやることはswitchの分岐を追加することだけになります。
外からは構造の違いを意識しなくても、チャレンジタイプとイベントIDを渡すだけでデータを取得が可能になります。
7 Object Pool
デモで発射されている弾を毎回生成と破棄をしていたらパフォーマンスに影響がでます。
そこで、生成されたオブジェクトをアクティブ・非アクティブにして使いまわします。
ゲームだけではなく、表示アイテム数が多いスクロールリストでも、オブジェクトの使い回しでパフォーマンスを低下させることなく表示させています。
使い所は色々あって、音ゲーのノーツやゲーム中に発生するエフェクトにも使われています。
UnityEngine.Pool
デモに書かれている通り、Unity2021から追加された機能です。
今までメインで使っていたUnityのバージョンの最新は2020だったため、こちらを実際に使ったことはありません。
自前で作成するか、すでにプロジェクトで用意されていてもベストプラクティスではなかったので、汎用的に使えるものではありませんでした。
公式で用意されていると楽になりますね。
あまり、長いコードを載せたくないので、ピックアップして使い方を見ていきます。
private IObjectPool<RevisedProjectile> objectPool;
private void Awake()
{
objectPool = new ObjectPool<RevisedProjectile>(CreateProjectile,
OnGetFromPool, OnReleaseToPool, OnDestroyPooledObject,
collectionCheck, defaultCapacity, maxSize);
}
RevisedProjecttileはMonoBehaviourを継承したデモで飛んでる弾のことです。
ObjectPool生成時に渡している引数は順番に
引数 | 型 | 説明 |
---|---|---|
CreateProjectile | () => RevisedProjectile | オブジェクト生成処理 |
OnGetFromPool | RevisedProjectile => {} | オブジェクト取得時に実行する処理 |
OnReleaseToPool | RevisedProjectile => {} | プールに戻される時に実行する処理 |
OnDestroyPooledObject | RevisedProjectile => {} | maxSizeを超えたときの破棄処理 |
collectionCheck | bool | すでにプーリングされているオブジェクトを戻された時に例外を発生させるかどうか |
defaultCapacity | int | Stackを生成する際に確保する容量 |
maxSize | int | ストックする最大の数(超えた場合は破棄) |
オブジェクトをプールから取り出す
RevisedProjectile bulletObject = objectPool.Get();
オブジェクトをプールに戻す。
こちらはRevisedProjecttile内で行っています。
objectPool.Release(this);
AsyncObjectPool
こちらの記事を書いている時にふと、ObjectPoolで非同期対応(UniTask)すれば使い所がありそうだと思い作ってみました。
上記のObjectPoolと作りは大きく変えていないので、非同期対応したこと以外使い方は同じです。
8 Singleton
詳しく言及する必要がないかもしれませんが、アプリ内にインスタンスが一つであることが約束されていて、どこからでもアクセスできるクラスです。
シングルトンの基底クラスのサンプルがこちらに3パターン用意されています。
シーンマネージャーやダイアログマネージャーなど、シングルトンにすべきものはありますが、多用して良いものではないので、シングルトンにすべきかどうかはしっかり見極める必要があります。
9 Command
ロジックをオブジェクトにすることで、まとめて実行したり、Undo,Redoに対応したり、ロジックをスクリプト化できたり、リプレイやシミュレーションを可能にするパターンです。
コマンドにしておけば何でも対応できるというわけでなく、利用する際はしっかり設計する必要があるパターンだと感じています。
オセロやチェスなどのボードゲームと特に相性が良さそうで、コマンドを保持しておけばリプレイ再生も可能です。
ゲームだけではなく、ツールなどにもこちらのパターンが利用されています。
例えば、Unityのアニメーターでステートとステートをつなぐ操作がありますが、こちらをコマンド化するとUndo,Redoが可能になります。
デモに入っているコマンドのインターフェースは以下のようになっています。
public interface ICommand
{
public void Execute();
public void Undo();
}
場合によってUndoが不要かもしれませんが、インターフェースを提供すれば、実行する側は何の処理か意識せずに実行することができます。
コマンドパターンを利用して、演出とロジックを切り離して実装していれば、ゲームにもよりますが、ロジックだけを回すいわゆるスキップに対応できたり、中断状態から再開する時に、途中の状態までデータを再現するといったことが可能になるので、設計の段階で意識しておきたいパターンです。
10 State
UnityAnimatorのStateMachineでも利用されている、Stateパターンです。
ある状態の処理を一つのクラスにまとめ、状態を切り替えることで各状態の処理を実行します。
基本的にステートの基底クラスでは以下の3つの関数が用意されています。
関数名 | 用途 |
---|---|
OnEnter | ステートが切り替わった時に最初の一回呼ばれる関数 |
OnUpdate | あるステートの時に毎フレーム実行される関数 |
OnExit | 他のステートに切り替わった時最後に一回呼ばれる関数 |
まだ私が、Unityもプログラミングも覚えたてだった時代に、マリオのようなミニゲームを作成する機会がありました。
当時はパターンがあるということも知らずに、マリオの操作を全てUpdate関数の中に処理を書いていました。
実装が進むにつれて、フラグが増えコードがめちゃくちゃになってしまい、追加で手を入れられないようなコードになっていきました。
このような状態になることを防ぐためにあるのがステートパターンです。
状態によって処理が変わるものであれば、真っ先にステートパターンを利用することを考えましょう。
非同期(UniTask)も合わせて利用できるUniTaskStateMachineを実装してみたので、気になる方はぜひチェックしてみてください!
11 Observer
UniRxで実現するパターンです。
イベントを通知する側(Subject)は、イベントを受け取る側が何なのかを意識せずに通知します。
イベントを受け取る側(Observer)は、Subjectを知っているだけでイベントの購読が可能になります。
このパターンを利用する最大のメリットは、書いてあるとおりイベントを通知する側は他のことを何も知らなくて良いことです。
例えば、キャラクター選択画面があったとして、画面にはキャラクターのモデルと、各種パラメーターが表示されていたとします。
Observerパターンを利用すると、キャラクター変更ボタンは選択しているキャラクターが何なのかを知っているだけで良くなります。
ボタン押下時に、キャラクター変更イベントを通知し、キャラクターモデル、パラメータUIはそのイベントを自分で受け取り画面の更新をするだけです。
もし、追加でキャラクター選択時にボイスを鳴らしたい・パッシブスキルアイコンを表示したいとなったとしても、それぞれがイベントを購読するだけで済み、キャラクター変更ボタンに追加項目の参照を追加する必要がありません。
ある操作や行動が、複数のものに影響を及ぼすものであれば積極的に利用していきたいところです。
12 MVP
UnityはMonobehaviourがエントリポイントになるため、何も意識しないとデータと表示とロジックを一つのクラスにまとめてしまうと思います。
MVPパターンでは、データ(Model)と表示(View)を切り分けて、その2つをロジック(Presenter)でつないで実装します。
ModelはViewのことを知りませんし、反対にViewもModelのことを知らなくて済むので、ModelとViewを別々に他の場所で使えるようになるので、再利用性が高くなります。
個人的にVContainerとUniRxを組み合わせてMVPパターンを利用するのが良さそうだと考えています。
VContainerはUnityでDI(DependencyInjection)を実現するためのライブラリです。
UniRxではなくMessagePipeを利用して実現することもできまして、あまり詳細を追えてはいないですが、async/awaitが使えるようになった今ではMessagePipeの利用も考えていったほうが良さそうです。
MVPパターンに関してはまだ私自身、どのように活用できるか探っているところですが、無理に利用しなくても良いかなと思っています。
しかし、ModelとViewを分離して実装することは大切だと思います。
表示処理の中で、表示に必要な処理とデータの更新処理が混ざってしまうとメンテナンスし辛いコードになってしまうのでこれは避けたいところです。
最後に
よく使われるパターンがデモになっていたので、普段から意識はしているものの、見直してみると改めて勉強になるなと感じました。
適切なパターンを適用して、アプリを開発していくことはとても大切ですし、未来の開発にも関わってきます。
開発の初期段階で適切なパターンを検討しておかないと、途中から適用が難しくなってしまうので、最初の設計はかなり重要です。
また、パターンを適用させることを目的にしてしまうと、かえって理解しにくいコードになってしまうので、利点を理解して適切に適用していくという意識も大切です。