GOODROIDでリードエンジニアをさせて頂いています及川です。
今回は技術的負債を設計で返済した経験をもとに、
負債化した原因から全シーン単一起動可能な設計によって解決に経った経緯を書きました。
負債の顕著化
技術的負債は、ネストの深いシーンのデバッグ工数が爆増という形で顕著化しました。
シーン起動に必要なデータがシーン依存になっており、インゲームのたった一行の変更ですら、特定手順のシーン起動によって必要な情報を構築しなければ確認ができない状態でした。
負債化した原因
負債化した原因は、肥大化しやすい設計です。
旧設計でシーン遷移を行うためには、Use Case経由でServiceを叩いて任意のStorageに状態を持たせる必要がありました。
次のシーンに必要なデータの保存フォーマットがシーン毎に異なるため、接続するシーン同士でユニークな実装が求められていたためです。
実装としては、汎用的なGamaManagerとシーン/データなどの実装者依存のServiceが、独自の形式でデータのやり取りを行っていました。
少人数/短期間/運用を行わない小規模開発ならこの方法でも問題ありませんでした。
しかし、規模の巨大化に伴い汎用的なGamaManagerに責任が集中し神クラス化し、スマートUIを許容していこともありシーンとデータが紐づいてしまいました。
これにより、シーンの順実行による前提データの構築を余儀なくされました。
目指すべき姿「全シーン単一起動」
負債を返却する方法として、全シーン単一起動可能なシーン設計での返済を試みました。
単体駆動により劇的なデバッグ工数の削減が可能だと唱えられていたのと、Unity Test RunnerのPlayModeで単体起動効率を体験していたのが決め手になりました。
単体駆動自体はマルチシーン導入前より唱えられていますが、マルチシーンでシーンの責任が細分化した今でも、単体で責任を果たせられるシーンがデバッグの工数削減に繋がるのは変わりません。
マイクロシーンアーキテクチャ
新しいシーン設計として「マイクロシーンアーキテクチャ」を用意しました。
これは、シーン起動に必要な条件を解決する責任を専用のクラスが担うことで、シーンの単体起動を担保する設計です。
シーンに必要な条件は二つあります。
1つは、アプリケーション起動に必要な情報/状態が満たされていることです。
マスタがダウンロードされている/グローバルなシステムの初期化が完了しているなどが充当します。
処理をストリーム化しAsyncSubjectとして公開したりすることで条件を満たします。
もう1つは、シーン起動に必要な情報/状態が満たされていることです。
Playerがアウトゲームで指定したスキン情報/InGameのスコア情報など、
直前のシーンで生成されたデータがContextとして共有されている必要があります。
データ共有がなされていない場合、シーン直起動と判断しデバッグ用のダミーデータを作成・使用することで条件を満たします。
これらの条件達成をStarterが責任を担うことで、どのシーンも起動条件を満たすことが出来る設計になっています。
実装例
プロダクトでは、App起動条件を満たすAppStarterと、Scene起動条件をContextを用いて満たすSceneStarterに分離する形を取りました。
SceneのエントリポイントとなるSceneStarterがAppStarterとContextを使用して起動条件を満たしたのち、Use Caseを実行します。
実際のコードは下記のようになります。
[DefaultExecutionOrder(-int.MaxValue)]
public class InGameSceneStarter : SceneStarter<InGameContext>
{
[SerializeField] private InGamePresenter _presenter;
[SerializeField] private DebugInGameConfig _debugInGameConfig;
private void Start()
{
var appStarter = ApplicationStarter<ApplicationStartBehavior> .Instance;
appStarter
.Initialize()
.Subscribe(
_ => { },
ex => Debug.LogException(ex),
OnStart
);
}
private async void OnStart()
{
try
{
if(HasContext)
{
// シーン遷移挟んでるのでContextを使用して環境構築
// マスタからIngameパラメータ構築
}
else
{
// 直起動なのでdebug環境を構築
// DebugInGameConfigを使用してInGameパラメータ構築とPhotonのマッチングセットアップ
}
// 環境構築完了したのでInGameエントリポイントのInGamePresenterを叩く
_presenter.StartInGame(Context);
}
catch (Exception e)
{
// 例外処理
}
}
}
ApplicationStarter
が公開しているアプリケーション初期化処理(AsyncSubject)の完了を待って、InGameSceneStarter
がContextの有無を考慮してシーン起動条件を満たします。
また、デバッグ環境としてインゲームパラメータをScriptableObject化して直起動の際に使用することでデバッグ効率を上げています。
旧設計の問題解決
以下の方法で解決しています。
- スマートUIによる遷移導線のシーンバインド
シーン起動の条件解決に必要なデータをContextという形で公開し、呼び出し元に要求することで各シーンの起動データの受け渡しを標準化。
シーン起動の条件解決にviewの介在する余地をなくすことで解決。
- 汎用的GameManagerの肥大化
様々な責任が混ざっていたのを単体責任に分離、GameManagerを廃止することで解決。
Appの起動責任は専用のStarterに移譲。
新しいアーキテクチャで得られた利点
「マイクロシーンアーキテクチャ」の導入により2つのメリットを得ることが出来ました。
1つは、デバッグの高速イテレーションです。
シーン単体起動により、いままで多くのシーンを介さないと開始できなかったシーンが直起動で可能になりました。
最小限のシーンでデバッグが可能になり、イテレーションが高速化しました。
2つ目は、テストデータを使用したデバッグが容易になったことです。
SceneStarterにより、シーンの起動時にContextとその反映状態をコントロールできるようになりました。
Contextを埋める際、ScriptableObjectなどを使用することでテストデータが容易に反映可能になりました。
これらのメリットをどんなシーンでも受けられる様になりました。
マイクロシーンアーキテクトのデメリット
シーン単体起動を可能にしたこの設計ですが、デメリットも存在します。
旧設計に比べ、シーン当たりの実装コストが高い点です。
実機想定のシーンフローでデータを構築していく旧設計の場合、1フローでデータ構築が可能です。
その点、新設計は実機想定のフローに加え、各シーン毎に起動フローの実装が必要になります。
マルチシーンの細分化が進んだ場合、起動フロー部分の工数や保守の面で問題が発生する可能性もあります。
今回採用したタイトルはPhotonのマッチングがあったため、コストよりリワードの方が多かったのですが、全てのタイトルで適応できるとは限りません。
まとめ
今回、顕著化した負債を設計によって返済することが出来ました。
昨今のタイトルは基本的に運用前提なので、旧設計のようなスケールしない設計を採用している事はほぼないと思います。
かなり稀なケースだとは思いますが、設計による解決例の一つとしてどこかで役に立てれば幸いです。