はじめに
Unityを使用したソシャゲ開発・運用に関わって4年目になります。
そこで感じたプログラミングをする上で大切だと思ったことをピックアップしてまとめていきます。
あくまで私が感じたことなので、一般的に正しいとは限らないのでご了承ください。
大事だと思ったこと
DRY(Don't repeat yourself)
同じ処理を何度も繰り繰り返し書かずに、共通化するということです。
とても当たり前のことですが、こちらを守るのは意外と難しいことだと感じています。
DRY守るのが難しい理由
他人がどこを共通化しているのか把握しきれない
自分が書いたコードでしたら、共通化した部分を覚えているのでそこまで難しいことはないかもしれません。
しかし、実際の開発で一人だけで書いていくことは少ないので、他人が共通化した部分を利用していく必要があります。
チーム開発ではDon't repeat yourselfはもちろん守らなければなりませんが、Dont't repeat ourselfである必要があります。
仕様が変わるため、何でも共通にすればよいわけではない
共通化!これもあれも共通化!と血走ってもいけないと私個人的には感じています。
例えばインゲームのリザルトを共通処理で作ったとします。
イベントが始まり、
リザルト途中で獲得報酬のポップアップ出したい!
リザルトページにイベントポイント関連の内容をはさみたい!
リザルトからの遷移先を変えたい!!
と要望がきたら共通処理の中にイベント用の分岐を入れる必要が出てしまい、影響範囲も広くよくわからないコードに変化していってしまいます。
こちらの動画がとても分かりみ深いです。
DRYを守るために
チームで共通化の認識を作る
こちらは私もあまり実現できていない内容なので、どちらかというと理想になります。
マスタデータ,リソース読み込み,通信などは共通認識がほぼほぼあるはずなので、共通基盤を使わずに独自の実装に走る人は殆どいないと思います。
文字列操作や日付周りの共通処理などから共通認識が怪しくなってくると感じています。
こちらはUtilityなどで処理をまとめ、あちこちに定義している場所が散らからないようにして、チームにしっかり共有する。そして、コードレビューを徹底する必要があります。
共通化はエンジニアだけではなくて、UI・仕様の段階で画像やテキスト、ボタンなどの作りを決め、更にそれらを利用するヘッダー・ポップアップ・アイテムアイコン・カードユニットなどの作りを共通で決めて使っていくのが理想的です。
ServiceLocator・ストラテジーの導入を考える
様々な箇所に同じような分岐を入れていくのはよくありません。
処理のまとまりを一つの場所で管理して、それぞれの場所で分岐を書かないようにするために、DIやストラテジーパターンを導入を検討します。
導入の例
良い例がなかなか思いつかないですが、オセロを例に上げてみましょう。
通常モード時は
・緑色の盤面
・制限時間なし
・アシスト表示あり
ハードモード時は
・赤色の盤面
・制限時間あり
・アシスト表示なし
対応する箇所で分岐を書いて愚直に対応して行くことも可能ですが、それぞれのモードで何が変わるかコード上で把握するのが難しくなってきます。
さらにモードが増えていくと、分岐が肥大化して収集がつかなくなりそうですね。
ServiceLocatorパターンを利用すると、インターフェースに実態を登録できるので、それぞれの処理では分岐を書かず、インターフェースを通して処理を実行するだけで済みます。
実態を登録するときだけ分岐を書けば良くなるので、最小限の分岐を最初に書いて終わりです。
DI(Dependency Injection)を導入する
オブジェクト同士が参照を密に持たないようにするためのパターンですが、私自身はまだ実用化に向けて勉強している途中です。
個人的にはUnityでDIを導入するときには、VContainerが良いかなと思っています。
UnityにDIを導入するメリットを大きく掴むことができたら、使っていきたいと感じているところです。
設計が複雑になるところが導入するデメリットだと感じていて、チームで開発するときにボトルネックになってしまわないか懸念があります。
継承をする際基底クラスは慎重に設計する
共通の処理を基底クラスにまとめられるので、最初に紹介したDRYを守るためには継承は非常に便利な概念だと思います。
私は基底クラスは実装の約束や方向性を作る場所であって、処理の共通化をする場所ではないと考えています。
例えば、キャラクターという基底クラスがあって、自キャラとエネミーがそれを継承しているとします。
これがRPGゲームだとしたら、HPという概念は共通でどちらも持っているので基底クラスにデータを持ちます。
HPの初期化とダメージや回復した時の処理の基底クラスですべてやってしまって大丈夫でしょうか??
初期化に必要なデータが異なるケースがあるので、共通で初期化するべきではないですし、ダメージの受け方や回復の仕方も仕様によって変わっていくことも十分考えられます。。。が、同じ可能性もあります。
この場合はHPという概念を基底クラスにして、それぞれの継承先で実態を決めてもらったほうが柔軟性がありそうです。
基底クラスに長い処理を書いてしまい、継承先のクラスのために分岐処理を書く必要が出てきてしまったら最悪です。
また、仮想関数(virtual)をオーバーライドするときに基底クラスのもともとの処理を呼んだり呼ばなかったりするケースが多いのも良くないです。
基底クラスの設計は慎重に行いましょう!
いろんなところにデータを置かない
関数を呼ぶときに、一時的にデータを渡すのは良いですが、処理と一緒にデータを保持しなければ行けないケースはデータの渡し方を慎重に決めたほうが良いです。
intやfloat、構造体などの値型は実体がコピーされますし、オブジェクト型は参照を切り替えても他のオブジェクトに渡したものは前の参照を残したままになるので、いんろなところにデータがあると、一部更新が漏れて不具合になってしまうことがあります。
上流のオブジェクト(例えばシーンやダイアログ)で使うデータを一つのデータクラスにまとめて、個々のクラスにそのデータクラスを保持させたほうがデータの反映が追いやすくなります。
他にはUniRxを使用してデータの変更を監視する方法も有効です。
データではなく、UniRxのIObservable(Subscribeができるインターフェース)を渡すことでデータを保持させることなく監視が可能になります。
必ずデータを一つの場所にまとめる必要があるというわけではなく、値の変更が頻繁に起こる場所では積極的にまとめていったほうが良いということです。場合によっては単独でデータを保持させたほうがいいケースもあると思います。
非同期処理を正しく扱う
コルーチンよりもUniTaskがおすすめです。UniTaskの記事もあるので詳細はこちらを確認していただければと思います。
コルーチンを使うにしても、UniTaskを使うにしても非同期処理で気をつけないといけないことは、待ち処理とキャンセル処理です。
待ち処理
初期化処理やダウンロード・通信処理などを行うときはしっかり待って、待ち途中に行ってはいけない処理を挟まないようにしないといけません。
気をつけるべきことをそれぞれまとめていきます。
初期化処理
シーンやダイアログなど初期化が完了していない状態で表示させてはいけません。
初期化が終わるまで表示が見えないようにしておく必要があります。
async/awaitを使用していて、戻り値がvoidの関数が初期化で使われていたりすると、その関数が終了する前に初期化が完了してしまうケースが発生します。
なので、戻り値は基本的にUniTaskにして、本当にawaitする必要がないものには明示的にForgetするようにしましょう。
ダウンロード処理
初期化処理でダウンロード処理がある場合は、気をつけることは初期化処理と変わらないです。
要素をリサイクルして表示するスクロールビューの要素の画像など、ユーザーの操作によってダウンロードが挟まる場合は注意が必要です。
仮にダウンロード中はユーザーの操作をブロックするという処理があった場合、スクロールなどの操作をブロックしてしまうとユーザビリティが悪くなってしまいます。
また、ダウンロードが終了するまでは前回使用していた画像を表示していたりすると表示に不整合が起きてしまうため、画像をローディングアイコンに切り替える必要があります。
通信処理
ボタンを押したときにAPI通信が走る場合、ボタンをしっかり押せない状態にしないと重複して通信が走ってしまうので、しっかりブロックする必要があります。
キャンセル処理
割と忘れられがちな処理ですが、大事な処理です。
ユーザーの操作によって非同期処理をキャンセルする必要があるケースがあるのでしっかり記述する必要があります。
例えばシーン遷移時などでオブジェクトが破棄された場合、非同期処理が回り続けているとNullReferenceExceptionが発生してしまいます。
処理によって、エラーを吐かず無限に回り続けるものもあるので、メモリリークの原因にもなってしまいます。
Unity標準のコルーチンは、Destroy時に処理も破棄されますが、シングルトンクラスで回している場合は注意が必要です。
UniTaskの場合はCancellationTokenを渡して、OperationCanceledExceptionを発生させて処理を停止させます。
(try-catchでOperationCanceledExceptionをハンドリングするか、Forgetで例外を握り潰す必要はあります)
重い処理
そもそも重い処理は書かないほうが良いのですが、そうするしかない場合にメインスレッドを停止させてしまうのはユーザビリティ的によくありません。
別のスレッドで処理させるか、ループの間にフレーム待ちを挟んで、メインスレッドをできるだけ止めないようにしましょう。
しかし、UnityのAPIはメインスレッドでしか呼べないものがほとんどなのでこのあたりは気を付ける必要があります。
コールバックするときは気をつける
FuncやActionで処理を複数のクラスにまたがって渡し合ったり、コールバックの中でコールバックしてネストが深くなるのはよくありません。
いろんなところにデータを置かないと同じ話になりますが、コールバックの参照を色々なところで持っていても処理が追いにくくなりますので、できるだけ一箇所にまとめましょう。
ネストが深くなってしまう場合は、UniTaskを検討するのが良いです。
また、FuncやActionは処理を一つしか登録できないので、UniRxやイベントを使用したほうが後々良いケースがあります。
UniRxはイベントの上位互換なので、UniRxを使用できる環境であればUniRxを積極的に使っていきましょう。
さいごに
以上私がプログラムを書く上で大切だなと思ったことになります。
大切にすべきことは、経験によって増えたり変わってくるものと感じています。
入社前に達人プログラマーという本を渡されたのですが、当時は経験が浅かったので理解できないことがありました。
今読んでみると、違った捉え方ができると思うので再度読んでみたいと思います。