この記事はUnity Advent Calendar 2021 その2 20日目の記事です
Unityにおいて何かしらのデータの値をSceneを超えて渡したい場合(例えばインゲームシーンでのスコアをアウトゲームシーンで結果発表のために使いたいときとか)にとれるいくつかの手段について実装し、それぞれのメリット・デメリットを考えてみました。
はじめに
Unityではゲームにおけるタイトル画面やインゲーム画面(その中でもステージごととか)をSceneという単位で分割させるのが一般的です。Sceneでの分割には、ある程度の塊でゲームをモジュール化できる、複数人での並行作業をやりやすくするなどのメリットがあります。
ただし、各Sceneではインスタンスが基本的に引き継がれないため、Scene間でデータを受け渡したい場合には何かしらの受け渡し方法を考える必要があります。
この記事では以下の受け渡し方法をそれぞれ実装・比較し、それぞれのメリット・デメリットを考え良さげな方法がどれか考えていきたいと思います。
比較するデータ受け渡し方法
- static class (Singleton class)
- SingletonMonoBehaviour + Don'tDestroyOnLoad
- LoadSceneMode.Additive + MonoBehaviour
- VContainer-EnqueueParent
- VContainer-RootLifetimeScope
- ScriptableObject
調べて無いもの
- Zenject(Extenject) <- メジャーなDIツールなので調べたかったが、自分はあまり触ったことないので未調査。そのうち調べて追記するかも
- PlayerPrefs, ロカールファイル <- 性質的にはアプリ終了時にデータを保存するために使うことが多かったりするので未調査
- サーバーへの保存 <- あんまり手軽な方法でもないので未調査。
- その他良さげなやり方あればコメントで教えてください
他に同じようなこと調べてる人がいないか
まず、このScene間のデータの受け渡しについて、すでに調べている人がいないか検索してみました。
いくつか記事がヒットしましたが、以下の海外のフォーラムのものが一番まとまっていました。
このフォーラムでは以下のものについてメリット・デメリットが書かれていました。
- static class
- DontDestroyOnLoad
- PlayerPrefs
- Saving to a file
- Singleton pattern
- Zenject
このフォーラムの内容でもある程度まとまっていましたが、自分はここに載ってない項目について調べたい、実際に自分でコード書いて理解したいと思ったので、自分でも調査しこの記事にまとめます。
環境
- Unity2020.3.16f1
- VContainer1.9.0
実装したコードは以下のリポジトリにアップしています。(記事中のコードとは異なる部分があります)
前提
実装は↓のgifのような状況を想定しています。比較のため
- "GameScene", "ResultScene"の2シーンがあり、"GameScene" -> "ResultScene"と遷移する
- "GameScene"ではボタンを押してスコアを加算できる
- "ResultScene"は"GameScene"のスコアを引継ぎ、それを表示する
今回はそれぞれの方法のパフォーマンス面については調査していません。手法の手軽さ、使いやすさの面を主に調べています。
また実装は説明に必要な部分だけ抜粋してのせています。
static Class (Singleton)
まずは最も手軽に実装できるstatic classを使った方法です。Singletonも、使うメリット・デメリットとしてはstatic classと大体同じになるので一応かっこで含めてます。
実装は以下のようになります。
// Score受け渡し用
public static class GameScoreStatic
{
public static int Score = 0;
}
// "GameScene"側
public class ScoreChanger
{
public void ScorePlusOne()
{
GameScoreStatic.Score++;
}
}
// "ResultScene"側
public class ScoreGetter
{
public int GetScore()
{
return GameScoreStatic.Score;
}
}
〇メリット
- 使い方が超簡単
- 文法が超簡単
- どこからでも参照できる
〇デメリット
- 素のままだと参照の範囲広くどこからでも値を編集できてしまう。internal使って制限などするとよい。
- コルーチンなど
MonoBehaviour
依存のものは使えない。UniTask使えば同じようなこと(むしろもっと便利なこと)できるのでそこまでデメリットじゃないかも。 - "GameScene", "ResultScene"側どちらのコードも単体テストしにくい
- "ResultScene"がデバッグしにくい
- Interface, Polymorphismが使えない
- Inspectorで調整できない
メリット・デメリットの部分についていくつか補足します。
"GameScene", "ResultScene"側どちらのコードも単体テストしにくい
"GameScene", "ResultScene"側のスクリプトがどちらもstatic classへ依存します。
これらを単体テストをする際には、テストごとにスコア保持部分のインスタンスの状態がリセットされて欲しいです。staticじゃないクラスならインタンスを作り直すだけで良いですが、staticの場合はそれができません。staticの場合でも、リセット用のメソッドを定義しテストごとにそれを呼び出したりするなどできますが、手間がかかるうえ、そのメソッドが公開されている危険性や、呼び忘れのミスなども発生する可能性があります。そのためstatic classへの依存があるとコードが単体テストしにくくなります。
また、より規模が大きい開発ではstatic class同士が依存しだしたりと、よりテストがしにくい状況がうまれやすくなる可能性があることにも注意が必要です。
ちなみに、static class, Singletonへの依存があると単体テストしにくい理由については以下の記事がわかりやすいです。
例えば, Singleton を避ける - Born Too Late
"ResultScene"がデバッグしにくい
"ResultScene"は読み込まれる前に、他のシーンでスコアが設定されていることが前提となっているシーンです。
staticの場合だと初期値0がスコアに入っているだけだしエラーも起きないので特に問題なく見えますが、実は"ResultScene"単体でデバッグがしにくいという問題があります。
例えば、"ResultScene"ではそのスコアによって演出が変わる仕様(例えば、10点以下ならBad, それ以上ならGoodと表示するとか)があるとします。このとき各種演出内容を調整したい場合に、いちいち"GameScene"を起動し任意のスコアにしてから"ResultScene"を読み込む、あるいはstaticクラスのコードを変更し初期値に任意のスコアをいれる必要があります。
前者のやり方では、任意のスコアになるまでボタンを押す、シーンロードを待つといった無駄が生じます。後者のやり方では、コンパイルのため時間がかかる、値を戻し忘れるヒューマンエラーがあるといった問題が生じます。
だいたいのアプリではデザインやゲームロジックの調整が必要なため、このイタレーションの早さが開発効率にダイレクトに響きます。なのでシーンがデバッグしにくいというのは結構大きな問題です。
"Interface, Polymorphismが使えない"
今回の例では受け渡すデータがスコア一つだけでしたが、実際の開発では規模が大きくなるほどこのようなデータが大量に生まれてきます。その際に受け渡し方であったり、受け渡し用のクラスをその中身だけ変えて使いまわせると何かと便利です。そのためには、interfaceやpolymorphism(継承とか)が使えると便利なのですが、static classはそれが使えず欠点となる場合があります。
※継承が使えないのは利点となる場合もあります。あくまで受け渡しにおける上でデメリットということです
"Inspectorで調整できない"
「"ResultScene"がデバッグしにくい」の項目でも書きましたが、開発ではデザインやゲームロジックの調整のために細かく値を変えて動作を確認する、というイタレーションの効率がとても重要です。
そして、各種パラメータの調整がUnityのInspectorViewで行えるととても効率的であるため、それができないことはstatic classのデメリットかなと思います。
Singletonクラスはstaticとだいたいメリット・デメリットが同じなので省略します。Singletonクラス側は継承などが使えます。
// Score受け渡し用
public class GameScoreSingleton
{
private static GameScoreSingleton instance;
public static GameScoreSingleton Instance
{
get
{
if (instance == null)
{
instance = new GameScoreSingleton();
}
return instance;
}
}
public int Score = 0;
// コンストラクタがprivate
private GameScoreSingleton() { }
}
static class (Singleton)パターンについての所感
デメリットは多いが手軽さは抜群なので、開発序盤の一旦コードをがーっと書いちゃうときとかには使う。
それなりのしっかりとしたプロジェクトだと積極的に使わない方が無難、使うとしてもどこか小さ目の機能スコープの中だけでちょこっと使うくらいにしとくと良いかなぁ。
SingletonMonoBehaviour + Don'tDestroyOnLoad
次はMonoBehaviour
をSingleton化したSingletonMonoBehaviourと、シーン遷移の際にGameObject(正確にはUnityEngine.Object
)を破棄しないようするDon'tDestroyOnLoad()
を組み合わせたパターンです。pure C#のクラスではなく、MonoBehaviour
をSingleton化することにより、Inspectorを使えるようにしているのがstaticとの違いかなと思います。
とっつきやすい + このやり方を紹介している記事が多いため、個人的にはUnity界隈で結構メジャーな方法なのかなと思っています。
実装は以下のようになります。
説明しやすさのためSingletonMonoBehaviourの実装が粗め(保守性が低そう)になってますがご容赦ください。
// "GameScene"でGameObjectにアタッチされてる想定
public sealed class GameScoreSingleton : MonoBehaviour
{
private static GameScoreSingleton instance;
public static GameScoreSingleton Instance => instance;
public int Score = 0;
private void Awake()
{
// instanceがすでにあったら自分を消去する。
if (instance && this != instance)
{
Destroy(this.gameObject);
}
instance = this;
// Scene遷移で破棄されなようにする。
DontDestroyOnLoad(this);
}
}
// "GameScene"側
public class ScoreChanger
{
public void ScorePlusOne()
{
GameScoreSingleton.Instance.Score++;
}
}
// "ResultScene"側
public class ScoreGetter
{
public void GetScore()
{
return GameScoreSingleton.Instance.Score;
}
}
GameScoreSingleton
が"GameScene"側にあるGameObjectにアタッチされる使い方を想定しています。
〇メリット
- 使い方が簡単
- 文法が簡単
- どこからでも参照できる
- Inspectorで値を設定できる
- コルーチンなど
MonoBehaviour
依存のものが使える
〇デメリット
- 素のままだと参照の範囲広くどこからでも値を編集できてしまう。internal使って制限などするとよい。
- GameObjectにアタッチしないと動作しない
- "GameScene", "ResultScene"側どちらのコードも単体テストしにくい
- "ResultScene"がデバッグしにくい、"GameScene"から遷移させないとエラー出る。
staticのパターンより少しましになったように感じます。
"GameScene"側ではInspectorから値を設定できるようになっていたり、MonoBehaviour
継承してるのでコルーチン使えたりできます。ただし、依然としてテストやデバッグはしにくいです。
GameObjectアタッチしないと動作しない、"GameScene"から遷移させないとエラー出る、
staticと比較して一番デメリットとなるのはGameObjectがアタッチしないといけないことでしょうか。
先ほどのstaticの例では、"GameScene"を挟まずに直接"ResultScene"をロードした場合にエラーはでませんでした(自動的にintのdefault値の0がスコアになってるから)。しかし、このSingletonMonoBehaviourではGameObjectが見つからないのでNullReferenceException
がでてしまいます。これはデバッグにとても不便です。
もちろん、InstanceにアクセスがあったときにGameObjectを強制的にSceneにつくりだす処理などをいれて回避することもできますが、そのための処理の記述やライフタイムの管理など新たに厄介な問題がでてきてしまいます。
SingletonMonoBehaviour + Don'tDestroyOnLoadパターンについての所感
staticの一部デメリットを克服したもののまだまだ微妙だなぁといった感じ。積極的には使いたくないなぁ。
LoadSceneMode.Additive + MonoBehaviour
次は、LoadSceneMode.Additive
を指定して"ResultScene"を読み込むことにより、Singletonではない通常のMonoBehaviour
を"GameScene"と"ResultScene"で共用するパターンです。
実装は以下になります。
// "GameScene"にGameObjectにアタッチされてる想定
// LoadSceneMode.Additive前提
public sealed class GameScoreMonoBehaviour : MonoBehaviour
{
public int Score;
}
// "GameScene"側
public class ScoreChanger
{
// 同じシーン内にあるので、Findじゃなくて[SerializeField]で参照持っててもいい
public void ScorePlusOne()
{
FindObjectOfType<GameScoreMonoBehaviour>().Score++;
}
}
// "ResultScene"側
public class ScoreGetter
{
// "ResultScene"側から"GameScene"のGameObject(Component)をどう探すかが課題。
public void GetScore()
{
return FindObjectOfType<GameScoreMonoBehaviour>().Score;
}
}
public static class SceneLoader
{
public static void Load()
{
// LoadSceneMode.Additive
SceneManager.LoadScene(ResultSceneName, LoadSceneMode.Additive);
}
}
GameScoreMonoBehaviour
が"GameScene"側にあるGameObjectにアタッチされる使い方を想定しています。
static, SingletonMonoBehaviourのパターンと違い、Scoreへの参照をどうするかに課題がでてきています。上ではとりあえずFindObjectOfType<T>()
でコンポーネントの参照をとってきています。
〇メリット
- 使い方が簡単
- 文法が簡単
- Inspectorで値を設定できる
- コルーチンなど
MonoBehaviour
依存のものが使える
〇デメリット
- 素のままだと参照の範囲広くどこからでも値を編集できてしまう。internal使って制限などするとよい。
- GameObjectにアタッチしないと動作しない
- "GameScene", "ResultScene"側どちらのコードも単体テストしにくい
- "ResultScene"がデバッグしにくい、"GameScene"から遷移させないとエラー出る。
- 参照の解決が必要
- 2つのSceneが重なることによる問題がでてくる
とりあえず実装してみたものの、あまり使うメリットがなさそうなパターンです。SingletonMonoBehaviourパターンのデメリットをほぼ引継ぎつつ、参照の解決という新たな問題を生み出しています。
参照の解決については、特に"ResultScene"側からの解決が面倒くさい or Find系のメソッドを使うことにより低パフォーマンスになりがちです。まぁFind系が重いといってもシーン遷移時だけなら多少FPSが落ちても暗転処理とかでなんとかなる気はしますが。
2つのSceneが重なることによる問題がでてくる
LoadSceneMode.Additive
を使うので当たり前なのですが、2つのSceneが重なることになります。
そもそも自分はこのモードをあまり使わないですが、使うとしたら以下のような状況だけじゃないかなと思います。
- 全シーン共通のUI(例えば、共通メニュー的なものとか?)があり、それプラス各Scene独自のものを表示する決まりになっている。
- 見えるオブジェクトが何もないScene(空のGameObjectにコンポーネントがついてるだけとか)があり、それプラス各Sceneを読み込む決まりになっている。最初のSceneの方に全Scene共通で使いたいManager的なコンポーネントが置いてある。
逆にいえば、今回想定しているようなScene間で柔軟にデータを受け渡したい状況では、2つのSceneが同時に読み込まれるのは不便です。UIや3Dオブジェクトが複数Sceneを跨いで被らないように配置したり、Scene遷移が一方方向では場合の対応など大変になってしまいます。
LoadSceneMode.Additive + MonoBehaviourパターンについての所感
このパターンを使うことは無いと思う。
VContainer-EnqueueParent
割とこっからが本題です。static, SingletonMonoBehaviourなどメジャーだけどあまり理想的じゃない方法に対して、もっと良いものはないか探っていきます。
まずはVContainerを使った方法です。
VContainerとは
もともとUnity用のDIコンテナツールといえばZenject/Extenjectが有名でしたが、最近は割とVContainerも利用者が増えてきている気がします。日本人の方が作者なので公式ドキュメントにも日本語版が用意されていてとっつきやすいです。
READMEの最初にざっくりとした紹介が書かれています。
- Fast Resolve: Basically 5-10x faster than Zenject.
- Minimum GC Allocation: In Resolve, we have zero allocation without spawned instances.
- Small code size: Few internal types and few .callvirt.
- Assisting correct DI way: Provides simple and transparent API, and carefully select features. This prevents the DI declaration from becoming overly complex.
- Immutable Container: Thread safety and robustness.
要はZenejctと比べて、できることをコンパクトに絞ってパフォーマンスをあげつつ、バグが起こりやすいような変な設計がそもそもできないようにするという方向性で開発されているようです。開発思想とかは作者の方が以下のLTで話してるのを聞くとさらによくわかります。
以上VContainerの紹介でした。
VContainerですが、Scene間のデータ受け渡しに使えそうな機能が2つあります。
-
LifetimeScope.EnqueParent()
:Scene読み込み時に、今のSceneが持っているDIコンテナの登録情報を次のSceneに渡す - Project root LifetimeScope:プロジェクト全体に適用されるDIコンテナへ情報を登録し、全Sceneから受け取れるようにする
まずは一つ目のLifetimeScope.EnqueueParent()
を使うパターンを見ていきます。
ちなみにこのパターンは、説明が長い割にあまりおすすめではないので読み飛ばしてもらってもいいかなと思います。
先ほどから、LifetimeScope
という言葉が登場してますが、これが何かわかっていないとこの後が説明しづらいのでまず軽く説明します。
VContainerにおけるLifetimeScope
とは次のようなものです。ちょっと簡略化した説明ですがご容赦ください。
-
MonoBehaviour
を継承したクラス - ユーザーは
LifetimeScope
を継承したクラス(以下、継承クラスといいます)をつくります - 継承クラス内ではメソッド
Configure()
をoverrideでき、その中でDIに必要なクラス情報などを登録します。 - 指定のアトリビュートなどを使うと、自動的にクラス間の依存性が解決された状態のインスタンスが、DIされる側のクラスに注入されます
-
LifetimeScope
単位でDIを適用する範囲を指定できます。 -
LifetimeScope
同士は親子関係をつくれ、登録されているクラス情報を受け継ぐことができます。 - (
LifetimeScope
は登録しているクラスの寿命管理も行ってくれます)
LifetimeScope.EnqueParent()
では、LifetimeScope
同士が親子関係がつくれることを利用して、今動いているScene側にあるLifetimeScope
を次読み込むシーンのLifetimeScope
の親とすることでデータを引き渡せます。
文章の説明だけだとちょっと難しいので、コードを見た方がわかりやすいかもしれません。
実装は以下になります。
// "GameScene"のGameObjectにアタッチされてる想定
// LoadSceneMode.Additive前提
public sealed class ScoreLifetimeScope : LifetimeScope
{
protected override void Configure(IContainerBuilder builder)
{
base.Configure(builder);
builder.Register<ScoreHolder>(Lifetime.Singleton);
}
}
// Scoreの実態。
public class ScoreHolder
{
public int Score;
}
// "ResultScene"のGameObjectにアタッチされてる想定
// "GameScene"側でEnqueueParent() + LoadSceneMode.Additiveが行われて、
// ScoreHolderEnqueueParentの登録情報を引き継いでる想定
public sealed class ScoreHolderEnqueueParentReceiveLifetimeScope : LifetimeScope
{
protected override void Configure(IContainerBuilder builder)
{
base.Configure(builder);
}
}
// "GameScene"側
// ここではLifetimeScopeからの注入を受け取るためMonoBehaviour化。(MonoBehaviourじなくても受け取る方法があるがここでは簡略化のためそうしている。)
public class ScoreChanger : MonoBehaviour
{
private ScoreHolder scoreHolder;
// ScoreLifetimeScopeにより注入される想定。
[Inject]
public void Construct(ScoreHolder holder)
{
scoreHolder = holder;
}
public void ScorePlusOne()
{
scoreHolder.Score++;
}
}
// "ResultScene"側
// ここではLifetimeScopeからの注入を受け取るためMonoBehaviour化。(MonoBehaviourじなくても受け取る方法があるがここでは簡略化のためそうしている。)
public class ScoreGetter : MonoBehaviour
{
private ScoreHolder scoreHolder;
// ScoreHolderEnqueueParentReceiveLifetimeScopeにより注入される想定。
[Inject]
public void Construct(ScoreHolder holder)
{
scoreHolder = holder;
}
public void GetScore()
{
return scoreHolder.Score;
}
}
public static class SceneLoader
{
public void Load()
{
StartCoroutine(LoadSceneAsync());
}
private IEnumerator LoadSceneAsync()
{
// LifetimeScope generated in this block will be parented by `this.lifetimeScope`
using (LifetimeScope.EnqueueParent(scoreLifetimeScope))
{
// If this scene has a LifetimeScope, its parent will be `parent`.
var loading = SceneManager.LoadSceneAsync(ResultSceneName, LoadSceneMode.Additive);
while (!loading.isDone)
{
yield return null;
}
}
}
}
先ほどまでと比べてかなりコード量が増えました。順番に解説します。
-
"GameScene"側では
ScoreLifetimeScope
がGameObjectにアタッチされており、ScoreHolder
のインスタンスを注入する。-
ScoreLifetimeScope.Configure()
の部分の処理が該当。 -
builder.Register<ScoreHolder>(Lifetime.Singleton);
と書いてるうちLifetime.Singleton
としているのは、ScoreHolder
の共通インスタンスをこのLifetimeScope
とのその子LifetimeScope
の中で使いまわすというオプション。 - このとき注入される側の
ScoreChanger
は何等かの方法で受け取る必要があります。↑のコードではMonoBehaviour
化してインスペクタから設定方法(っぽい議事コード)になってます
-
-
異なるシーン間にある
LifetimeScope
同士で親子関係をつくりつつ、"ResultScene"を読み込む。-
SceneLoader.LoadSceneAsync()
の部分の処理が該当。 -
LifetimeScope.EnqueueParent()
を使う。 -
LoadSceneMode.Additive()
を使う。
-
-
"ResultScene"側では
ScoreHolderEnqueueParentReceiveLifetimeScope
がGameObjectにアタッチされており、"GameScene"側のScoreLifetimeScope
が親になっているためそちらに登録されているScoreHolder
の情報を受け取れる。それを"ResultScene"側でScoreGetter
に注入-
ScoreHolderEnqueueParentReceiveLifetimeScope.Configure()
の部分の処理が該当。 - 注入の説明は↑でしたものと同様。
-
〇メリット
- Inspectorで値を設定できる
- コルーチンなど
MonoBehaviour
依存のものが使える - "GameScene", "ResultScene"側どちらのコードも単体テストしやすい
- シーン間のデータ受け渡し以外の通常のDI部分の処理とまとめて書ける
〇デメリット
- "ResultScene"がデバッグしにくい、"GameScene"から遷移させないとエラー出る。
- 2つのSceneが重なることによる問題がでてくる
- "ResultScene"側の全ての
LifetimeScope
の親が設定されてしまう。 -
LifetimeScope
からどう注入するかが悩みどころ
"GameScene", "ResultScene"側どちらのコードも単体テストしやすい
今までのパターンより大きく改善したポイントは、"GameScene", "ResultScene"側どちらのコードも単体テストしやすくなったところです。
private ScoreHolder scoreHolder;
[Inject]
public void Construct(ScoreHolder holder)
{
scoreHolder = holder;
}
Scoreを保持しているクラスを外から注入されるつくりになっているため、モックを差し込みやすく、単体テストが行いやすくなります。このメリットはかなりでかいです。
"ResultScene"側の全てのLifetimeScope
の親が設定されてしまう。
LifetimeScope.EnqueueParent()
では、特に子供にするLifetimeScope
とかを選択できるわけではないです。そのため、複数引き継がれるデータなどがある場合に、不要なデータの参照なども受け取れる状態になってしまいます。
また、一方向的なシーン遷移ではないゲームの場合は問題が生じます。
LifetimeScope
からどう注入するかが悩みどころ
書いたコードでは注入される側のクラスをMonoBehaviour
として、インスペクターからLifetimeScope
のAuto Inject Game Objects
に登録する想定で書いています。ただそもそもVContainerではMonoBehaviour
への注入があまり推奨されていません(詳しくはこのページ読んでください)。
MonoBehaviour
でないクラスに注入する場合は、そのクラスもLifetimeScope.Configure()
の中で登録する必要があり、そうしていくと必然的にほとんどのクラスがVContainer前提になってきます。このあたりプロジェクト開始時からVConatinerを導入しているなどの状況であればいいかもしれませんが、そうでない場合は対応が手間になってきます。
VContainer-EnqueueParentパターンについての所感
単体テストがしやすくなったことなど今までのパターンになかったメリットがありつつ、LoadSceneMode.Additive
のデメリットなども目立ちます。
やっぱりこの機能は依存関係情報受け渡すためのものであって、データ受け渡すには適さないかなぁという気がします。
VContainer-RootLifetimeScope
次はVContainerのRootLifetimeScope機能を使う方法です。
VContainerには、プロジェクト内の全てのLifetimeScope
の親となるRootLifetimeScopeをつくる機能があります。
これを使って、"GameScene", "ResultScene"両方の中に存在しているLifetimeScope
にスコアを保持しているクラスを受け渡します。
実装は以下になります。
// Prefabにアタッチされ、VContainerSettingsのScriptableObjectにインスペクターで設定される想定。
// このLifetimeScopeが全てのシーンのLifetimeScopeの親となる。
public class RootLifetimeScope : LifetimeScope
{
protected override void Configure(IContainerBuilder builder)
{
base.Configure(builder);
// 子のLifetimeScopeに同じScoreHolderRootを引き渡す
builder.Register<ScoreHolderRoot>(Lifetime.Singleton);
}
}
// Scoreの実態。
public class ScoreHolderRoot
{
public int Score;
}
}
// "ResultScene"のGameObjectにアタッチされてる想定
public sealed class ScoreHolderReceiveLifetimeScope : LifetimeScope
{
protected override void Configure(IContainerBuilder builder)
{
base.Configure(builder);
}
}
// "GameScene"側
// ここではLifetimeScopeからの注入を受け取るためMonoBehaviour化。(MonoBehaviourじなくても受け取る方法があるがここでは簡略化のためそうしている。)
public class ScoreChanger : MonoBehaviour
{
private ScoreHolderRoot scoreHolder;
// ScoreLifetimeScopeにより注入される想定。
[Inject]
public void Construct(ScoreHolderRoot holder)
{
scoreHolder = holder;
}
public void ScorePlusOne()
{
scoreHolder.Score++;
}
}
// "ResultScene"側
// ここではLifetimeScopeからの注入を受け取るためMonoBehaviour化。(MonoBehaviourじなくても受け取る方法があるがここでは簡略化のためそうしている。)
public class ScoreGetter : MonoBehaviour
{
private ScoreHolderRoot scoreHolder;
// ScoreHolderEnqueueParentReceiveLifetimeScopeにより注入される想定。
[Inject]
public void Construct(ScoreHolderRoot holder)
{
scoreHolder = holder;
}
public void GetScore()
{
return scoreHolder.Score;
}
}
RootLifetimeScope
が全てのLifetimeScope
の親に自動でなるため、先ほどのパターンよりコードがスッキリしました。
〇メリット
- Inspectorで値を設定できる
- コルーチンなど
MonoBehaviour
依存のものが使える - "GameScene", "ResultScene"側どちらのコードも単体テストしやすい
- シーン間のデータ受け渡し以外の通常のDI部分の処理とまとめて書ける
- "ResultScene"がデバッグしやすい。"GameScene"から遷移させなくてもよい。
〇デメリット
- 全ての
LifetimeScope
の親が設定されてしまう。 - 全ての
LifetimeScope
にデータが受け取れてしまう。
今までのパターンでデメリットとなっていた、"ReusltScene"のデバッグのしにくさが消えました。"GameScene"から遷移させなくてもエラーはでませんし、[SerializeField]などを使えばインスペクターでスコアの初期値の設定などが可能になります。
一方で、データが受け渡されるスコープの広さが少しデメリットとなるかもしれません。もちろんアクセス修飾子の工夫などでアクセスを制限することは可能ですが、それでも全シーンのLifetimeScope
から参照可能になるというのは少し気持ち悪い気もします。ゲーム全体を通して引き継ぐデータなら良いですが、特定のシーン間のみで受け渡したいデータの場合は少し注意が必要そうです。ただ、そこについてもコードやEditor拡張の工夫などで使いやすくできそうかなとも思います。
VContainer-RootLifetimeScopeパターンについての所感
ここまでのパターンで一番良い。VContainerを導入できるプロジェクトなら使っていきたい。
Editor拡張などでもう少し使いやすくできないかは今後探っていきたい。
ScriptableObject
次はScriptableObjectを使ってデータを受け渡すパターンです。
ScriptableObjectといえば、config系の値(プレイヤーのHP初期値とか、ゲームの制限時間)を保存しておくのによく使われますが、シーン間のデータの受け渡しにも使えるようです。詳く知りたい方は以下の記事と動画を見てください。
自分もこの記事と動画を見るまではあまりやったことのない使い方なのですが、筋が良さそうなので調べてみました。
実装は以下となります。
[CreateAssetMenu(fileName = "ScoreScriptableObject", menuName = "ScoreScriptableObject", order = 0)]
public class ScoreScriptableObject : UnityEngine.ScriptableObject, ISerializationCallbackReceiver
{
[SerializeField] private int initScore = default;
[NonSerialized] public int Score;
public void OnAfterDeserialize()
{
// Editor上では再生中に変更したScriptableObject内の値が実行終了時に消えない。
// そのため、初期値と実行時に使う変数は分けておき、初期化する必要がある。
Score = initScore;
}
public void OnBeforeSerialize() { /* do nothing */ }
}
// "GameScene"側
public class ScoreChanger
{
[SerializeField] private ScoreScriptableObject scoreScriptableObject = default;
public void ScorePlusOne()
{
scoreScriptableObject.Score++;
}
}
// "ResultScene"側
public class ScoreGetter
{
[SerializeField] private ScoreScriptableObject scoreScriptableObject = default;
public void GetScore()
{
return scoreScriptableObject.Score;
}
}
スコアを保持するScriptableObjectを一つつくり、それをシーン内のコンポーネントにインスペクターから設定しています。
全体的にコードがすっきりしていて、"GameScene", "ResultScene"側ともに同じ方法でスコアが取得できわかりやすいです。
〇メリット
- Inspectorで値を設定できる
- コルーチンなど
MonoBehaviour
依存のものが使える - "GameScene", "ResultScene"側どちらのコードも単体テストしやすい
- シーン間のデータ受け渡し以外の通常のDI部分の処理とまとめて書ける
- "ResultScene"がデバッグしやすい。"GameScene"から遷移させなくてもよい。
- スコープを分けやすい。
〇デメリット
- 初期値と実行時に使う変数は分けておき初期化する必要がある。
こちらのパターンも今までのデメリットを大きく潰しておりとても良いです。
VContainer-RootLifetimeScopeパターンに勝っている点はスコープの分けやすさでしょうか?今回のやり方のようにインスペクターからScriptableObjectを各コンポーネントに設定しているので、不要な箇所からの参照が起こりにくいと思います。コードも全体的にスッキリしています。
逆に、初期値と実行時に使う変数を分けておくことによりボイラープレートコードが増えてしまうのが負けている点でしょうか。ここについては使いまわせる共通クラスをつくったりなどで対策できる気もするので今後研究していきたいです。
初期値と実行時に使う変数は分けておき初期化する必要がある
実行時に使う変数Score
以外に、インスペクターから初期値を設置するための変数initScore
が定義されています。そして、ISerializationCallbackReceiver.OnAfterDeserialize()
で初期値を代入しています。
これは、Editor上では、実行中にScriptableObjectの値を変えた場合でも、実行終了時に値が元に戻りません(※)。これは多くの場合不都合です。そのため、初期値と実行時に使われる変数を分けておき、初期化をしています。
これにより、ボイラープレートコードが増えてしまうというデメリットがあります。
(※)直接ScriptableObjectの値を書き換えた場合のみです。Instantiate(scriptableObjectClass)
として新しいインスタンスをつくった場合には、元のインスタンスの値は変更されないです。その場合データの受け渡しができないので今回の場合意味がなくなってしまいますが。
ScriptableObjectパターンについての所感
かなり良い。積極的に使いたい。
Editor拡張や、ボイラープレート部分のクラスの共通化などでもう少し使いやすくできないかは今後探っていきたい。
おわりに
今回はUnityにおいて異なるScene間でデータを受け渡す方法について、いくつかのパターンを試し比較してみました。
個人的な所感としては、VContainerのRootLifetimeScopeを使うパターンと、ScriptableObjectを使うパターンが良さげだなと感じました。
記事読まれた方の中には、もっといいやり方ある、Zenjectも調べて欲しい、メリデメがおかしいといった意見を持たれたかたもいると思います。自分も他の人の意見を聞いてみたいので、よければコメント欄で教えてください。