2019/10/23(水) 追記
この記事の内容は今だとZenjectを使うといい感じに解決できるので、Zenjectを使いましょう。
追記ここまで
はじめに
こちらの記事で書かれている
シーン自体はAwake前にロード出来てるみたいですが、ロードしたシーンに含まれるオブジェクトに対して、Awake時点でのアクセスは出来ないようです……。
を解決する方法。
Unityで全体の初期化は
- 初期化用のシーン(例:Initialize)を用意
- シングルトンでDontDestroyな◯◯Managerを作る
- 初期化用シーンにGameObject(プレハブ)を置いて◯◯ManagerをAddComponentする
- 初期化用シーンが最初に読み込まれるようにする
という設計にすると何かと便利です。
が、開発中は修正中のシーンを開いてプレイ&エラーを繰り返したほうが早く、いちいち本来の起動シーケンスで確認したくない。
つまり、「どんなシーンを実行しようが全体の初期化が終わっている状態で始まる」のが理想です(今だとマルチシーンエディティングで初期化用シーンをHierarchyに置いておけばいいので若干楽ですが、シーンのアクティブの設定によっては問題が起きます)。
Awakeより前に初期化を終わらせたい
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
を使うと最初のシーンのロード前に処理を実行できるので、このタイミングで初期化シーンをロードすることができます。
ただ、普通にやるとアクティブなシーンのロード(すべてのMonoBehaviourのAwakeとOnEnable呼び出し)が完了してから初期化シーンのロードが走ってしまうので、アクティブなシーンのAwake内で全体の初期化に依存した処理を行うことができません。
どうするか?
MonoBehaviourのAwakeはAddComponentされているGameObjectが非アクティブの場合は呼び出されないので、アクティブなシーンにあるGameObjectを非アクティブにし、初期化シーンのロードが済んだらアクティブにします。
コード
using System.Linq;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
public class InitializeSceneLoader
{
private const string INITIALIZE_SCENE_NAME = "Initialize";
private static bool initialized = false;
private static IList<GameObject> gameObjects;
private static Scene initializeScene {
get { return SceneManager.GetSceneByName(INITIALIZE_SCENE_NAME); }
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
public static void LoadInitializeScene()
{
if (initialized) {
Debug.LogWarning("already initialized.");
return;
}
initialized = true;
// NOTE: アクティブなシーンのGameObjectを非アクティブにする(Awakeを抑制)
SetActiveScene(false);
SceneManager.sceneLoaded += (scene, mode) => {
if (scene.path == initializeScene.path) {
Debug.LogWarning(string.Format("{0} was loaded.", INITIALIZE_SCENE_NAME));
// NOTE: アクティブなシーンのGameObjectをアクティブにする(Awake発火)
SetActiveScene(true);
}
};
// NOTE: buildIndexはエディタのHierarchy上でロードされているかLoadSceneが呼ばれた後はBuildSettingsに表示されている値を返す
if (initializeScene.buildIndex < 0) {
SceneManager.LoadScene(INITIALIZE_SCENE_NAME, LoadSceneMode.Additive);
}
}
private static void SetActiveScene(bool value)
{
if (gameObjects == null) {
// NOTE: アクティブなシーン内しか検索できない(Resources.FindObjectsOfTypeAllでも同様)
gameObjects = Object.FindObjectsOfType<Transform>()
.Select(t => t.root.gameObject)
.Distinct()
.Where(go => go.activeInHierarchy)
.ToList();
}
foreach (var go in gameObjects) {
Debug.Log(string.Format("{0} {1}->{2}", go.name, go.activeInHierarchy, value), go);
go.SetActive(value);
}
}
}
initializeScene.buildIndex < 0
とかはUnityの仕様?バグ?を使った判定なのでもうちょいちゃんと判定すべきかも。
実行環境
OS | Unity |
---|---|
macOS Sierra 10.12.3 | 5.5.2f1 Personal |
(モバイル端末で検証しなければ・・・)
問題点
上記の実装だと問題点が一つあって、複数のシーンを読み込んでいる場合は初期化シーンを最初にロードすることができません(すでにHierarchy上でロードされているシーンのAwakeが優先的に呼ばれる)。
これはどう頑張ってもどうにもならなかった。
やってみたこと
-
Scene#GetRootGameObjects
でアクティブでないシーンのGameObjectを取得する -> ロード完了前だとエラーになる - 初期化用シーン以外をUnloadする -> 0フレーム目で完結する必要がありできない
-
Resources.FindObjectsOfTypeAll
を使う -> アクティブなシーンしか走査しない - アクティブなシーンを切り替えて他のシーンのGameObjectを操作する -> 同一フレーム内ではシーンのアクティブ切り替えが完了しない
- BuildSettingsでシーンのindex変更 -> 特に変わらず
メモ
- 基本的に「シーンのロード完了」というのは「そのシーン内のすべてのAwake呼び出しの完了」を指す
- アクティブなシーンはAwakeを抑制してあっても初期化用シーンより前にロード完了扱いになる(
SceneManager.sceneLoaded
が呼ばれる) - シーンのロードはフレームの最後にまとめて行われるらしく、
SceneManager.LoadScene
を呼ぶと内部的にはロードするシーンとしてスタックされるだけで、同一フレーム内のLoadScene
呼び出しをキャンセルできないっぽい(UnloadScene
、SceneManager.SetActiveScene
も同様)
感想
- 本来はAwakeとかのUnityが用意しているコールバックに頼らずに明示的に初期化を呼び出していく設計のほうがいいのかも(そっちのほうが可読性が高く大規模開発に向く)。
- でもやっぱり「よくわからんけど便利な感じで動作する」という状況のほうがUnityぽいし理想的。
- Awakeの挙動を調べてたらどうやらUnity的には「他のコンポーネントやGameObjectに依存しない初期化を行うべき」という考えぽい。本来は他に依存している処理は
Start
内で行うべき。 - いろいろ試すまで「アクティブなシーン」という概念を知らなかった
参考資料
- [全てのシーンに存在し、かつ、一つしか存在してはいけないマネージャー的存在の実装方法【Unity】]
(http://kan-kikuchi.hatenablog.com/entry/ManagerSceneAutoLoader) - 【Unity】5.3からのマルチシーン編集を前提として使用する上で注意すべき7つの項目
- SceneManager(公式リファレンス)
- イベント関数の実行順(公式リファレンス)
- Transform.root(公式のリファレンス、いつから実装されてたのだろう)
- Bug: GetRootGameObjects() is not working in Awake
- Resources.FindObjectsOfTypeAll(公式リファレンス)
- Unityの関数AwakeとStartの違いと現状のプログラミングの問題点