Unityでシーン遷移時にシンプルかつ安全にパラメーターを渡す方法

## はじめに
[サムザップ Advent Calendar 2021](https://qiita.com/advent-calendar/2021/sumzap) の12/15の記事です。

株式会社サムザップ Unityエンジニアの尾崎です。

## 内容
Unityでシーンを切り替えるときにパラメーター(引数)を渡す方法について紹介します。

紹介するのはパラメーターを遷移先シーンのオブジェクトに直接渡す形です。

パラメーターを渡す方法について検索するとstatic変数やDontDestoryOnLoadを用いた方法が見つかりますが、よりシンプルかつ安全な方法です。
staticやDontDestoryOnLoad、シングルトンはグローバル変数の性質を利用したもので、簡単に使えますがバグを起こしやすかったりシーン間に依存関係が生まれテストがしにくくなったりする問題があります。

またメインで紹介する方法の他にもいくつかの方法を検討しましたのでそれらも合わせて紹介したいと思います。

## 設計
シーンロードを行うSceneLoaderというクラスを作成しました。

次のコードはSceneLoderを使ってシーンを切り替えるサンプルコードです。

```csharp
// シーンBをロードしてコンポーネントを取得
var sceneB = await SceneLoader.Load<SceneB>("SceneBScene");
// 任意のメソッド呼び出し (タイミングはsceneBのAwakeの後、Startの前)
sceneB.SetArguments(123, new List<string> { "abc", "あいうえお" });
```

SceneLoaderはシーンをロードするメソッドを持っており、シーン名と取得したいロード先シーンのコンポーネントを指定します。
シーンロード後にロード先シーンのコンポーネントが取得できる仕組みです。
コンポーネントのメソッドを自由に呼べるのでパラメーターを渡しても良いですし、何か処理を実行することもできます。

内部的にScene構造体の[GetRootGameObjects](https://docs.unity3d.com/ja/2018.4/ScriptReference/SceneManagement.Scene.GetRootGameObjects.html)メソッドを利用しています。

パフォーマンスを考慮し、取得できるコンポーネントはロード先シーンのルート階層に配置されているGameObjectにアタッチされているコンポーネントに限定しています。

またシーンロードは非同期処理になるためasync/awaitでロード処理を実行します。
単純にロードするだけなら同期処理で済みますがロード後に処理を行うには非同期処理が必要になります。
async/awaitのためにライブラリ[UniTask](https://github.com/Cysharp/UniTask)を利用しています。

## 動作環境
Unity2020.3
UniTask 2.2.5

## 実装

### SceneLoader
```csharp:SceneLoader.cs
using UnityEngine.SceneManagement;
using Cysharp.Threading.Tasks;
using UnityEngine;

/// <summary>
/// シーンをロードする
/// ロード先シーンのコンポーネントが取得できるのでパラメーターを渡せる
/// 取得できるコンポーネントはロード先シーンのルート階層のGameObjectに設置されているもの
/// 指定コンポーネントが取得できるのはAwakeの後、Startの前のタイミング
/// </summary>
public static class SceneLoader
{
    /// <summary>
    /// ロードする
    /// </summary>
    /// <param name="sceneName">シーン名</param>
    /// <param name="mode">シーンロードモード</param>
    /// <returns>ロード先シーンのコンポーネント</returns>
    public static UniTask<TComponent> Load<TComponent>(string sceneName, LoadSceneMode mode = LoadSceneMode.Single)
        where TComponent : Component
    {
        var tcs = new UniTaskCompletionSource<TComponent>();
        SceneManager.sceneLoaded += OnSceneLoaded;
        SceneManager.LoadScene(sceneName, mode);
        return tcs.Task;

        void OnSceneLoaded(Scene scene, LoadSceneMode _mode)
        {
            // 一度イベントを受けたら不要なので解除
            SceneManager.sceneLoaded -= OnSceneLoaded;

            // ロードしたシーンのルート階層のGameObjectから指定コンポーネントを1つ取得する
            var target = GetFirstComponent<TComponent>(scene.GetRootGameObjects());

            tcs.TrySetResult(target);
        }
    }

    /// <summary>
    /// ロードする
    /// </summary>
    /// <param name="sceneName">シーン名</param>
    /// <param name="mode">シーンロードモード</param>
    public static void Load(string sceneName, LoadSceneMode mode = LoadSceneMode.Single)
    {
        SceneManager.LoadScene(sceneName, mode);
    }

    /// <summary>
    /// 非同期ロードする
    /// </summary>
    /// <param name="sceneName">シーン名</param>
    /// <param name="mode">シーンロードモード</param>
    /// <returns>ロード先シーンのコンポーネント</returns>
    public static async UniTask<TComponent> LoadAsync<TComponent>(string sceneName, LoadSceneMode mode = LoadSceneMode.Single)
        where TComponent : Component
    {
        await SceneManager.LoadSceneAsync(sceneName, mode);

        Scene scene = SceneManager.GetSceneByName(sceneName);

        return GetFirstComponent<TComponent>(scene.GetRootGameObjects());
    }

    /// <summary>
    /// GameObject配列から指定のコンポーネントを一つ取得する
    /// </summary>
    /// <typeparam name="TComponent">取得対象コンポーネント</typeparam>
    /// <param name="gameObjects">GameObject配列</param>
    /// <returns>対象コンポーネント</returns>
    private static TComponent GetFirstComponent<TComponent>(GameObject[] gameObjects)
        where TComponent : Component
    {
        TComponent target = null;
        foreach (GameObject go in gameObjects)
        {
            target = go.GetComponent<TComponent>();
            if (target != null) break;
        }
        return target;
    }
}
```

※ [[Gist] ozaki-shinya/SceneLoader.cs](https://gist.github.com/ozaki-shinya/7c5946aa0f177fc900d19713a1659362)

### 使い方のサンプルコード

```csharp
// シーンAのクラス
// SceneLoaderクラスを利用して別シーンをロードする
public class SceneA : MonoBehaviour
{
    public async Task Test()
    {
        // シーンBをロードしてコンポーネントを取得
        var sceneB = await SceneLoader.Load<SceneB>("SceneB");

        // 任意のメソッド呼び出し (タイミングはsceneBのAwakeの後、Startの前)
        // SceneAのGameObjectはDestroy済みでnullになるので注意
        sceneB.SetArguments(123, new List<string> { "abc", "あいうえお" });
    }
}
 
// シーンBのクラス
// シーンAからロードされ、メソッドを呼び出される
public class SceneB : MonoBehaviour
{
    public void SetArguments(int param1, List<string> param2)
    {
        Debug.Log($"param1: {param1}, param2: {param2}");
    }
}
```

### マルチシーンでのサンプルコード
ロード先シーンのコンポーネントを取得する仕組みなのでマルチシーンを構成するときにも使えます。

```csharp
// ゲームシーンをAdditiveモードでロード
var gameScene = await SceneLoader.LoadAsync<GameScene>("GameScene", LoadSceneMode.Additive);
// パラメーターを送る
gameScene.Setup(123, "abc");
// イベント処理 (メニューボタンクリックでメニューシーンを開いたり)
gameScene.OnClickMenu = (xxx) => yyy;
// ゲームが終わるまで待機して結果を受け取る
var result = await gameScene.WaitForEnd();
```

## 他の設計との比較
上記のSceneLoaderを作る際にいくつかの設計を検討しました。
それらを紹介します。

### パラメーターを直接渡す
最初に紹介した方法はロード先シーンのコンポーネントが取得できる仕組みでした。
別の方法としてロード先シーンにパラメーターを直接送る方法もあります。

```csharp
// パラメーターを指定してシーンロード
SceneLoader.Load("SceneB", new SceneB.Argument
{
    param1 = 123,
    param2 = new List<string> { "abc", "あいうえお" }
});
```
コンポーネント取得する方法との違いは以下のようなもので、一手間かかる印象です。

- パラメーター用interfaceを実装したパラメータークラスが必要
- パラメーターを受け取るためのコンポーネント(SceneArgumentReceiver)をAddComponentする必要がある
- ロード先シーンに対してできることがパラメーターを送ることのみ
- マルチシーンを構成するには別の仕組みが必要

#### SceneLoaderのコード(直接パラメーターを送るバージョン)</summary>

```csharp:SceneLoader.cs
using UnityEngine;
using UnityEngine.SceneManagement;
using Cysharp.Threading.Tasks;

/// <summary>
/// シーンをロードする (パラメーター直接渡すパターン)
/// Loadメソッドにロード先シーンに渡すパラメーターを指定する
/// パラメーターはISceneArgumentインターフェースを実装する
/// パラメーターはロード先シーンのルート階層のGameObjectに設置されているSceneArgumentReceiverで受け取る
/// パラメーターを受け取れるのはStartメソッドの後 (Awakeではまだ受け渡し前なので受け取れない)
/// </summary>
public static class SceneLoaderWithArgument
{
    /// <summary>
    /// ロードする
    /// </summary>
    /// <param name="sceneName">シーン名</param>
    /// <param name="argument">シーン引数</param>
    /// <param name="mode">シーンロードモード</param>
    /// <returns>ロード先シーンのコンポーネント</returns>
    public static void Load(string sceneName, ISceneArgument argument, LoadSceneMode mode = LoadSceneMode.Single)
    {
        SceneManager.sceneLoaded += OnSceneLoaded;
        SceneManager.LoadScene(sceneName, mode);

        void OnSceneLoaded(Scene scene, LoadSceneMode _mode)
        {
            SceneManager.sceneLoaded -= OnSceneLoaded;

            foreach (GameObject go in scene.GetRootGameObjects())
            {
                // シーンパラメーター受け取り用コンポーネントにパラメーターを渡す
                var argReceiver = go.GetComponent<SceneArgumentReceiver>();
                if (argReceiver != null)
                {
                    argReceiver.Argument = argument;
                    break;
                }
            }
        }
    }

    /// <summary>
    /// 非同期ロードする
    /// </summary>
    /// <param name="sceneName">シーン名</param>
    /// <param name="argument">シーン引数</param>
    /// <param name="mode">シーンロードモード</param>
    /// <returns>ロード先シーンのコンポーネント</returns>
    public static async UniTask LoadAsync(string sceneName, ISceneArgument argument, LoadSceneMode mode = LoadSceneMode.Single)
    {
        await SceneManager.LoadSceneAsync(sceneName, mode);
        Scene scene = SceneManager.GetSceneByName(sceneName);

        foreach (GameObject go in scene.GetRootGameObjects())
        {
            var argReceiver = go.GetComponent<SceneArgumentReceiver>();
            if (argReceiver != null)
            {
                argReceiver.Argument = argument;
                break;
            }
        }
    }
}

/// <summary>
/// シーンパラメーターのinterface
/// 渡すパラメータークラスはこのinterfaceを実装する
/// </summary>
public interface ISceneArgument { }

```


```csharp:SceneArgumentReceiver.cs
using UnityEngine;

/// <summary>
/// シーンパラメーターを受け取るためのコンポーネント
/// </summary>
public class SceneArgumentReceiver : MonoBehaviour
{
    // パラメーター用のinterfaceで扱っているが、型付けやボクシングを気にしないのであればobject型が簡単
    public ISceneArgument Argument;
}
```

#### 使い方のサンプルコード</summary>

ロード先シーンにパラメーターを受け取るためのコンポーネントを設置します。
![スクリーンショット 2021-12-09 午後7.02.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203221/5375a4e7-0c79-db07-5d02-fa99a00d7ada.png)

```csharp
// シーンAのクラス
// SceneLoaderクラスを利用して別シーンをロードする
public class SceneA : MonoBehaviour
{
    public async Task Test()
    {
        // パラメーターを指定してシーンBをロードする
        SceneLoader.Load("SceneB", new SceneB.Argument
        {
            param1 = 123,
            param2 = new List<string> { "abc", "あいうえお" }
        });
    }
}
 
// シーンBのクラス
// シーンAからロードされ、メソッドを呼び出される
public class SceneB : MonoBehaviour
{
    // シーンB用のパラメータークラス
    public class Argument : ISceneArgument
    {
        public int param1;
        public List<string> param2;
    }

    void Start()
    {
        // インスペクタでSceneArgumentReceiverを事前にAddComponentしておく
        var argReceiver = GetComponent<SceneArgumentReceiver>();

        var args = argReceiver.Argument as Argument;

        Debug.Log($"B. param1: {args.param1}, param2: {args.param2}");
    }
}
```

### async/awaitの代わりにコルーチンで実装する
SceneLoaderをasync/awaitではなくコルーチンで実装する方法です。
非同期処理をUnity標準のコルーチンで行うのでUniTaskをプロジェクトに追加する必要がなくなります。

ロード先シーンのコンポーネントが取得できる仕組みをコルーチンで行おうとするとシーンBのロードが終わるとシーンAがDestoryされコルーチンが停止するためシーンBのメソッドを呼ぶことはできません。
(async/awaitはロード後にシーンBが削除された後も処理が継続します)
マルチシーン構成の場合はシーンAが残るのでコルーチンでも同様のことができます。

async/await版との違いは以下のようになります。

- UniTaskが必要ない
- LoadSceneMode.Singleでロードする場合ロード先シーンのコンポーネントを取得できない
- ロード先シーンのコンポーネントを返却するためにSceneLoaderをnewする

```csharp
// SceneLoader作成
var sceneLoader = new SceneLoader<SceneB>();
// シーンロード
yield return sceneLoader.LoadAsync("SceneB", LoadSceneMode.Additive);
// ロード先シーンのコンポーネント取得
SceneB sceneB = sceneLoader.Result;
// パラメーターをセット
sceneB.SetArguments(123, new List<string> { "abc", "あいうえお" });
```

#### SceneLoaderのコード(コルーチンバージョン)

```csharp:SceneLoader.cs
/// <summary>
/// シーンをロードする (コルーチンバージョン)
/// </summary>
/// <typeparam name="TComponent">取得するロード先シーンのコンポーネント</typeparam>
public class SceneLoaderCoroutine<TComponent>
{
    // ロード先シーンのコンポーネント
    public TComponent Result { get; private set; }

    /// <summary>
    /// 非同期ロードする
    /// </summary>
    /// <param name="sceneName">シーン名</param>
    /// <param name="mode">シーンロードモード</param>
    public IEnumerator LoadAsync(string sceneName, LoadSceneMode mode = LoadSceneMode.Single)
    {
        yield return SceneManager.LoadSceneAsync(sceneName, mode);
        Scene s = SceneManager.GetSceneByName(sceneName);

        var target = s.GetRootGameObjects()
            .Select(go => go.GetComponent<TComponent>())
            .FirstOrDefault(x => x != null);

        Result = target;
    }
}
```

## 最後に
Unityによる開発ではどのプロジェクトでも使うであろうシーン遷移パラメーターについて紹介しました。
いくつかの実装パターンを紹介したのでプロジェクトに合わせて選択することができるかなと思います。
お役に立てれば嬉しく思います。

明日は @iida_ryota さんの記事です。