この記事は VR法人HIKKY Advent Calendar 2024 の 2日目の記事です。
これからもいろいろな記事が公開されますので是非購読して下さい。
はじめに
アバターメイカーの開発を担当しています中野です。
今日も元気にUnity開発を行っています。
アバターメイカーはUnityで開発されており、Webブラウザ向けに公開されています。Unity製でありながら様々な画面と遷移と状態を持つアプリケーションとしての開発ノウハウが生かされています。
Unityにおける開発、特に非ゲームのアプリ開発では画面単位でシーンを定義することが多いかと思います。シーンを分割する上で課題となるのはシーンを跨いでのアプリの状態や共有オブジェクトの扱いです。
多くの人がシングルトンや static クラスを用いてシーン間の共有を実現されていると思いますが、シングルトンへの依存が集中してしまい機能追加の過程で様々な思惑が交錯した結果、整理が難しくなることが多い印象です。
この記事ではシーン間のオブジェクト共有をシングルトンや static クラスではなく基底クラスを用いて実装する手法とメリットを紹介します。
画面単位でシーンを定義することが多いかと思います
Unity5の時代ならともかく、現代のUnityでは Prafab でも画面単位のUIを構成できます。しかし、独自に画面遷移機能を実装するよりはUnityの機能であるシーンとして実装することを採用するチームも多いのではないでしょうか。
シングルトンにおける問題点
Unityでシングルトンを実装する際は以下のように実装されることが多いと思います。
シングルトンのよくある実装例
あくまで例なんでChatGPT先生のコピペです(動かなかったらゴメンなさい)
using UnityEngine;
namespace MyApp
{
public class SingletonExample : MonoBehaviour
{
// 何かシーン間で共有したいフィールド/プロパティを持たせるとする
public AppState AppState { set; get; }
public FileSystem FileSystem { set; get; }
private static SingletonExample _instance;
public static SingletonExample Instance
{
get
{
if (_instance == null)
{
_instance = FindObjectOfType<SingletonExample>();
if (_instance == null)
{
GameObject singletonObject = new GameObject(typeof(SingletonExample).Name);
_instance = singletonObject.AddComponent<SingletonExample>();
}
}
return _instance;
}
}
private void Awake()
{
if (_instance == null)
{
_instance = this;
DontDestroyOnLoad(gameObject);
}
else if (_instance != this)
{
Destroy(gameObject);
}
}
}
}
このシングルトンがアプリケーションの状態や共有オブジェクトを持ち、全てのシーンからの参照を可能にします。
// 例えば状態をもったり
SingletonExample.Instance.AppState.PlayerName = "yamada";
// Fileに情報の永続化を行ったり
await SingletonExample.Instance.FileSystem.SaveLastAccessDateAsync(DateTimeOffset.Now, CancellationToken.None);
この手法における問題点は以下の2つと考えます
- プロジェクト上のどのクラスからでもアクセスできてしまう
- 共有オブジェクトへのアクセスのために
SingletonExample.Instance.
を打ち込んでアクセスする必要がある
1の問題は短期的にはメリットにもなり得ますが、シングルトンへの依存が集中する原因となり、シングルトンの特定プロパティのアクセスがタイミング依存となったり、肥大化回避のために別のシングルトンを作った結果シングルトン同士の依存が出来たりと、長い目で見ると開発の複雑さを増大させる要因となり得るデメリットの方が多いと考えます。
2の問題は単にキーボードのタイプ量が増えるというだけなので本質的な問題ではないですが、コードを読む上で短い(不要な文は無い)に越したことなないでしょう。
また、これらの問題はシングルトンでなく static クラスを使った場合も同様に起こり得ます。
基底クラスによるオブジェクト共有
そこで、シーン内のScriptに基底クラスを継承させ共有オブジェクトにアクセスする方法をとりました。ここではシーン内にエントリポイントとして SceneController
ないし SceneManager
的なScriptが一つ置かれていることが前提になっています。
各シーンに配置された SceneController
の基底クラスとして以下の様な SceneControllerBase
を定義します。
namespace MyApp
{
public abstract class SceneControllerBase : MonoBehaviour
{
// シーンが遷移しても内容が保持されるよう内部では static で宣言
static AppState s_appState;
// 派生 SceneController からはprotectedで直接参照できる
protected AppState AppState => s_appState;
static FileSystem s_fileSystem;
protected FileSystem FileSystem => s_fileSystem;
protected virtual void Awake()
{
// ??= がミソ!こうしないと各シーン起動ごとにnewで初期化されてしまう
s_appState ??= new AppState();
s_fileSystem ??= new FileSystem($"{Application.persistentDataPath}/AppInfo");
}
}
// プレイヤー情報をアプリの状態として保持させたいクラス
public class AppState
{
public string PlayerName { get; set; } = string.Empty;
}
// こんな感じに日時をファイルに保存するクラスがあるとする
public class FileSystem
{
readonly string _directory;
public FileSystem(string directory)
{
_directory = directory;
}
public Task SaveLastAccessDateAsync(DateTimeOffset date, CancellationToken cancel)
{
var path = Path.Combine(_directory, "last_access_date.txt");
return File.WriteAllTextAsync(path, date.ToString(), cancel);
}
}
}
SceneControllerBase
は共有したいオブジェクトを static なフィールドで保持しつつ、公開は protected なインスタンスプロパティで公開します。
その際、派生クラスから繰り返し Awake
が呼ばれてもいいよう、??=
を使い最初の1回だけnewが行われるように記述します。
static なフィールドで保持しつつ、公開は protected
Rider君が Property 'AppState' can be made static
と言ってきますが、オレはアクセスを限定したいんだよの気持ちで無視しましょう。
こうすることで TitleSceneController
や GameSceneController
といった派生クラスからは AppState
や FileSystem
に 直接 アクセスできます。
namespace MyApp
{
public class TitleSceneController : SceneControllerBase
{
[SerializeField] InputField _nameInput;
protected override void Awake()
{
base.Awake();
Assert.IsNotNull(_nameInput);
}
protected void Start()
{
// プレイヤー名の入力をStateに保存
_nameInput.onValueChanged.AddListener(x => AppState.PlayerName = x);
}
}
public class GameSceneController : SceneControllerBase
{
[SerializeField] Text _nameText;
protected override void Awake()
{
base.Awake();
Assert.IsNotNull(_nameText);
}
protected void Start()
{
// Stateのプレイヤー名を表示
_nameText.text = AppState.PlayerName;
}
void OnDestroy()
{
// 終了時に時刻を保存
FileSystem.SaveLastAccessDateAsync(DateTimeOffset.Now, CancellationToken.None); //forget
}
}
}
この書き方であれば AppState
や FileSystem
にアクセスできるのは SceneControllerBase
を継承したものだけなのでシングルトンにおける無秩序なアクセスは抑制できます。
また、SceneControllerBase
を継承したシーン全てで Awake
による初期化が行われますので「UnityEditorからはどのシーンからでも実行できるようにしたい」といった用途にもマッチします。
他のクラスで AppState
や FileSystem
を使いたい場合はコンストラクタなりプロパティ経由で渡してください。バケツリレーが嫌ならシングルトンに戻るかDIライブラリなどで良しなに解決してくれる手法を検討ください。
interfaceによる公開
情報の保存先としてファイル操作であることを隠蔽したい場合や、保存の成否によって表示の切り替えを行うようなクラスのユニットテストを実装したい思惑があるなら、FileSystem
のオブジェクトはinterfaceとして公開することも検討して下さい。
public abstract class SceneControllerBase : MonoBehaviour
{
static FileSystem s_fileSystem;
// interfaceとして公開
protected IAppDb AppDb => s_fileSystem;
}
public class FileSystem : IAppDb
{
// 略
}
public interface IAppDb
{
Task SaveLastAccessDateAsync(DateTimeOffset date, CancellationToken cancel);
}
こうすることでアプリ内の実装における依存は FileSystem
でなく IAppDb
の方に集めることができます。
終わりに
やってることはDIですが、DIライブラリを用いない平坦な実装でオブジェクト共有を行える手法だと思ったのでこの方針を採用しました。Unityらしくもありつつ派生クラスからは直接オブジェクトを扱えるのでいい塩梅なのではと思っています。