【Unity】肥大化したクラスを再設計しながらUnityのクラス設計を考えた
先月くらいから、Unityでモバイルアプリをつくる仕事をしています。
iOSエンジニアとしての経験も一年ちょいで、Unityなんて完全な素人なので、試行錯誤の連続です。
で、試行錯誤の中でクラスが肥大化してしまって、それを分離させたんですが、その作業の中でUnityのクラス設計について考えたので書かせてください。
当初Sceneの切り替えを使っていた
今開発中なのは、よくあるっちゃよくあるARアプリなんですが、スマホのカメラになんかコンテンツ乗っけるってやつです。
まだリリース前なので詳細はボカします。
Aモード/Bモードみたいにモードが切り替えられて、それぞれのAssetを表示する仕様になっています。
全モードに共通するUI部品もあって、それはC(Common)としましょう。
Cの中にuGUIのDropdownがあって、そこでAモード、Bモードが切り替えられる仕組みです。
当初はA, B, Cをそれぞれ別Sceneで開発していました。
CのSceneがベースで、SceneManeger
のLoadScene()
を使っていました。
第二引数を LoadSceneMode.Additive
にすると追加読み込みになるので、SceneAかSceneBをLoadして、モード切り替えのときは破棄して再ロード、でやっていました。
メモリ爆食いのScene切り替え
動作としては問題なかったのでScene切り替えで進めてたのですが、Sceneを切り替えるたびにメモリの使用量が増加するね、という問題はずっと引っかかっていました。
起動時にだいたい500MB程度食うのですが、Scene切り替えのたびにメモリ使用量が増えていって、1GB, 2GBといって、落ちてしまいます。
iPhone 12 Proで30回ぐらいScene切り替えすると必ず落ちるという状況でした。
どうも切り替えのときにUnloadしたSceneのリソースが解放されてないんじゃないかなと思うんですが、Unityのinspectorで見るとちゃんとオブジェクトが消えており、実機でしか再現できませんでした。
LoadSceneMode.Additive
の指定がだめなのかなと思って.Single
を試してみましたが、リソースの消費状況は変わらずでした。
三日ぐらい悩んだんですが、そもそもSceneの切り替えをやめる判断をしました。
コンテナっぽいオブジェクトをつくってSetActive()で切り替える
結局こんな構成にしました。
root
├── (A)
│ └── AのAsset
├── (B)
│ └── BのAsset
└── C
├── Dropdown
└── その他共通部品
Dropdownの中のLoadScene()
をSetActive(true)
に変更しました。
Unityはアクティブ状態をfalseにすると、使用していたメモリも解放されるみたいで、これでメモリ爆食い問題は解消しました。
Dropdownのスクリプトの肥大化
で、これでめでたしめでたしでもよかったのですが、今度はDropdownのスクリプトが肥大化してきました。
ドロップダウンのユーザーアクションだけでなく、モード切り替えのタイミングでさせたい処理がすべて詰めこまれることになり、
もはや「Dropdown」という名前からは想像もできない、アプリのモード切り替えを司る最重要クラスと化してしまいました。
これは良くないですね。
どのぐらい良くないかといえば、焼き鳥屋で焼き鳥焼く料理人として雇ったのに、意外と愛想が良かったので接客もやらせちゃおう、レジもたまにやらせちゃおう、ぐらい良くないですね。
忙しくないときなら回るかもしれませんが、混んでくるとぐちゃぐちゃになるやつです。
Unityようわからんからとりあえず動けばいいでつくっていましたが、これはさすがにヤバい未来しか見えなかったので、きちんとクラス設計することにしました。
本記事はここからが本論です。
(前置きは前置きで重要な発見だったので、ちょっと話題がブレるのそっちのけで書かせていただきました)
そもそもUnityのインスタンスってどうやってつくられるのかよく理解していなかった
Unity経験が年齢に伴っていないので、Unityのインスタンス生成に仕組みがよく理解できていませんでした。
なんとなくC#スクリプトにpublic GameObject xxx
と書いて、Unityエディタ上から紐付けたり、GameObject.Find()
とかGetComponent()
とかでインスタンス取得したりで、テキトーにやっていました。
C#自体はオブジェクト指向言語なので、その構造とUnityプロジェクト自体が持っている構造が頭の中で上手く紐づいていませんでした。
なので、良くわからないけどスクリプトからインスタンス取得するときはpublic GameObject xxx
! とやっていました。
クラス設計をキレイにしよう、と思ったときに、一番の問題は分割したクラスのインスタンスにどうアクセスするかでした。
キレイに責務を分割したと思っても、そのインスタンスへの参照がぐちゃぐちゃになると無意味ですからね。
Unityの基本はGameObjectとComponent
自分が何をわかっていないのかを分析していくと、疑問は下記に集約できるのかと思いました。
C#のスクリプトで書いたclassとUnityのHierarchy Windowで見ているオブジェクトはどう紐づいているのか?
C#の世界とUnityの世界がなんか分離しているように感じていました。
この疑問は、GameObjectとComponentの関係性を理解していないことから来ていました。
C#で書いたclassはComponentとしてGameObjectに紐づいています。
Components (コンポーネント)はオブジェクトやゲームでの振る舞いに関する心臓部です。コンポーネントはすべての GameObject (ゲームオブジェクト)の関数の一部です。もしコンポ―ネントとゲームオブジェクトの関係をまだ理解していないようでしたら、他の項目に移る前に ゲームオブジェクト ページを一読してください。
(Unityマニュアル)
これはUnityの基本にして、超重要概念でした。
UnityにおいてインスタンスはGameObjectに紐づく
どうしてもUnity入門みたいな解説を見ていると、UnityのC#スクリプトは「(書き捨ての)スクリプトを書いている」という意識になるような気がします。
しかしより正確には、「GameObjectのComponentとして書かれたクラスは、GameObject生成時にインスタンス化され、そのまま保持される」という理解が正しいです。
(別に入門でそこまで説明しろ、と言いたいわけではありません。念のため)
つまりUnityの世界においては、すべてのオブジェクトの基本はGameObjectなのです。
GameObjectに紐づかないインスタンス
スクリプトからインスタンスを生成することも当然できます。
GameObjectを生成したいならInstantiate()です。
自前のクラス・構造体・Enumであれば、new
すれば、インスタンスが生成できます。
インスタンスをGameObjectを経由せずに外部から参照するにはstaticにするしかなく厳しい
new
で作成したインスタンスを、外部から参照させたい場合、GameObjectを経由しないでできるでしょうか?
グローバル変数が言語仕様としてないC#でも、一応やる方法はあって、static属性を付与する、という手はあります。
static class CompanyEmployee
{
public static void DoSomething() { /*...*/ }
public static void DoSomethingElse() { /*...*/ }
}
ただクリーンな設計としては推奨されないでしょう。
staticがなぜよろしくないかを書きはじめると長くなるので、もし気になるのでしたら「staticおじさん」とかでググってもらうと過去の議論が追えるかと思います。
異なるオブジェクト間で変数をやりとりするベストプラクティスは何か
ある程度開発が進んでいくと、異なるオブジェクト間で変数をやりとりしたいケースは出てくると思います。
「Unity 変数 共有」でググると、色々方法は出てきますが、結局ベストプラクティクスはよくわかりませんでした。
出てくる方法を列挙すると、
-
public GameObject xxx
で参照を持たせて、GameObjectに対してGetComponent<Target>()
-
GameObject.Find("xxx")
で探して、あとは↑と同様 - staticを使う
- 親クラスをつくって常に継承させる
というところですかね。
static使うのは最終手段です。
親クラスで常に継承は定数を共有させるときに使える手なので、ちょっと例外的ですが。
上二つが良いと思っていて、GameObjectへのアクセスどっちがいいかはケースバイケースですねえ。
あと迷うポイントとしては、public
な変数にするか、private
な変数でアクセスはプロパティ経由にするか。
private int m_HP = 100;
public int HP
{
get
{
return m_HP;
}
}
だいたいのケースはこんな感じでgetterだけにした方が安全だと思いますが。
外部から更新せざるをえない場合は、変数をpublicにするよりもpublicなメソッド叩かせる方がまだいいですかね。。。
UnityはGameObject名で検索する辛さがある
C#のスクリプトからGameObjectへのアクセス方法については、僕の中でまだどっちがいいのか悩んでいます。
UnityのInspectorウィンドウから紐づけるのが一番楽だとは思うんですが、紐づけ忘れたり間違えたりがあるんで、なるべくならコードで取りたいと思ってしまいます。
が、GameObject.Find("xxx")
だと、名前で検索になるので、ここは何とかならんのかなと思っています。
何気なく名前変えると、Find()
が空振りするの嫌なんですよね。
tagつけたりはできるみたいなんですが……
あるいは名前を固定して、変更できないオプションをつける、とかして欲しいですね。
変更しようとすると一操作必要になるので、「あ、何かこの名前見てるスクリプトがあるんだ。じゃあ慎重に名前変更しないと」とわかる、みたいな。
探してみたらHideFlagって機能があるみたいですが、名前だけじゃなくて全部編集不可能になるのか……ちょっと思ってたのと違う……
Inspectorウィンドウの紐づけだと、名前変更は追随してくれるので、そこはすこしメリットがありますね。
現時点で思うベストプラクティクス
色々書いてきましたが、結論として現時点で僕の思うベストプラクティクスを書きます。
まず前提としてGameObjectの構成は下記だとします。
root
├── (A)
│ └── AのAsset
├── (B)
│ └── BのAsset
└── C
├── Dropdown
└── その他共通部品
- 特定のGamaObjectに依存しない共通処理をするクラスは、CのComponentとして配置する
- 僕のつくってるアプリで言うとモード変更処理など
- 共通処理をするインスタンスにアクセスする場合はC経由でアクセスする
- 基本的に
GameObject.Find("/C")
でアクセスする - Cの名前は固定する
- 基本的に
- あと共通処理の種類別にnamespaceは切っておく
- 定数を共有したい場合、structをつくって、必要な場合
new
する - こんな感じ
// 定数を定義する側
namespace AppName.Common
{
public struct Common
{
public const int DefaultHP = 100;
}
}
// 定数を使う側
using AppName.Common;
class Main : MonoBehaviour
{
private int hp;
void Start()
{
Common common = new Common();
hp = common.DefaultHP;
}
}
なぜGameObjectとComponentの関係が混乱したか
以上で本論は終わりなんですが、最後になんで僕がGameObjectとComponentの関係をよく理解できなかったかを考えました。
これはひとえにGameObjectがもうGameObjectでもなんでもないからでしょう。
本来のUnityは3Dゲームをつくるためのエンジンとして設計されたはずで、それがモバイルに対応して、さらにはARだのVRだのに対応しました。
もはやゲームエンジンというよりは、汎用的な開発ツールと化しています。
(事実僕がつくっているのもゲームではないですし)
GameObjectの実体は、2D/3Dオブジェクトだけではなく、UI部品だったり、AR Cameraだったり、AR Sessionだったりします。
GameObjectとCompponentsは親子関係に見えますが、そっちの親子関係に止まらず、GameObject同士がまた親子関係を持っていて、Hierarchy Window上に表示されていて、
それでGameObjectとCompponentの概念が僕の中でぐちゃぐちゃになったように思われます。
C#のコード上で見ると、GameObjectも一つの型でしかなくて、しかも実体は2D/3Dオブジェクト、UI部品、AR Camera、AR Sessionなど多岐にわたり、
挙げ句の果てには空のGameObjectを使ってコンテナ的に使うこともあります。
(僕のアプリでいうとA/B/Cはフォルダ的存在で、実体を持っていません)
しかし、実際はUnityではGameObjectが基本だと思っていいでしょう。
- あるSceneに対して、表示するGameObjectがいくつかある
- GameObjectはComponentの集合体
- GameObjectは親子関係をつけることが可能で、Hierarchy Windowで編集する
- ComponentはInspector Windowで編集する
この基本を理解した上で、クラス設計をすると、それだけでだいぶクリーンにできると感じました。
ベストプラクティクスについてはまだまだ模索中なので、よりベターなやり方を教えていただけたら幸いです!
Discussion
私も、最近iOSの開発からUnityを勉強し始めた者です。
すごくモヤモヤしていた部分をうまく言語化してくださり、ありがとうございます。