내 프로젝트의 코드는 GameManager가 Awake 메서드에서 싱글톤 인스턴스를 생성하고, Player가 OnEnable에서 GameManager의 인스턴스에 접근하여 이벤트를 구독하도록 되어 있다.
// Singleton.cs
public abstract class Singleton<T> : MonoBehaviour where T : MonoBehaviour
{
public static T instance { get; private set; }
protected virtual void Awake()
{
if (instance != null)
{
Destroy(gameObject);
Debug.LogError("Instance already created.");
return;
}
if (this is T ins)
{
instance = ins;
DontDestroyOnLoad(gameObject);
}
else
{
Debug.LogError("Formal parameter error.");
}
}
}
// GameManager.cs
public class GameManager : Singleton<GameManager>
{
// ...
public event EventHandler onBeginDrag;
public event EventHandler onDuringDrag;
public event EventHandler onEndDrag;
// ...
}
// Player.cs
public class Player : Entity, IPlayable
{
// ...
private void OnEnable()
{
GameManager instance = GameManager.instance;
instance.onBeginDrag += OnBeginDrag;
instance.onDuringDrag += OnDuringDrag;
instance.onEndDrag += OnEndDrag;
}
// ...
}
그런데 어느순간 잘 되던 코드가 갑자기 NullReferenceException을 뱉어대며 애를 먹어기 시작했다.
싱글톤 인스턴스가 초기화되지 않는 것이었다.
분명 유니티 메뉴얼에 따르면 MonoBehaviour의 라이프 사이클은 Awake - OnEnable - Start의 순서로 초기화되며 시작한다고 하고 나도 그렇게 알고 있었다.
여러 오브젝트들 사이에서도 Awake - OnEnable - Start의 순서가 지켜질거라고 생각한다.
예를 들어 Object1, Object2가 씬에 배치되어 있고 GameObject, Script의 active가 모두 활성화 상태라면 아래의 순서로 실행될 것이다.
Object1.Awake();
Object2.Awake();
Object1.OnEnable();
Object2.OnEnable();
Object1.Start();
Object2.Start();
하지만 실제로는 위 순서대로 호출되지 않는다.
유니티 런타임은 이벤트 메서드를 프레임 단위로 호출한다.
문제는 한 프레임 내에서 오브젝트 호출 순서가 보장되지 않고, 이벤트를 묶어서 처리한다는 것이다.
실제로 Awake, OnEnable, Start는 아래의 순서로 호출된다.
Object1.Awake();
Object1.OnEnable();
Object2.Awake();
Object2.OnEnable();
Object1.Start();
Object2.Start();
Start 메서드는 Awake, OnEnable보다 늦게 호출되는 것이 무조건 보장된다.
하지만 Awake와 OnEnable의 경우 오브젝트별로 묶어서 처리하기 때문에 해당 메서드 내에서 다른 오브젝트에 접근할 경우 아직 초기화가 안되어 에러가 발생할 수 있다.
결과적으로 싱글톤 추상 클래스를 아래와 같이 수정하여 문제를 해결했다.
// Singleton.cs
public abstract class Singleton<T> : MonoBehaviour where T : MonoBehaviour
{
public static T instance
{
get
{
if (_instance == null)
{
GetInstance();
}
return _instance;
}
}
protected static T _instance = null;
protected static bool instantiated = false;
protected bool destroyed = false;
protected virtual void Awake()
{
if (destroyed)
return;
if (_instance == null)
{
SetInstance(this as T);
}
else if (_instance != this)
{
Debug.LogError("Instance already created.");
destroyed = true;
Destroy(gameObject);
return;
}
}
private static void SetInstance(T ins)
{
_instance = ins;
instantiated = true;
(ins as Singleton<T>).destroyed = false;
DontDestroyOnLoad(ins);
}
private static void GetInstance()
{
var objs = FindObjectsOfType<T>();
if (objs.Length == 0)
{
Debug.LogError($"Place the {typeof(T).Name} in the scene.");
return;
}
else if (objs.Length > 1)
{
Debug.LogError($"The scene contains more than one {typeof(T).Name}. Unintended behavior can be detected.");
for (int i = 1; i < objs.Length; i++)
{
(objs[i] as Singleton<T>).destroyed = true;
Destroy(objs[i]);
}
}
SetInstance(objs[0]);
}
}
인스턴스 호출이 먼저인 경우 씬 내에서 해당 타입의 인스턴스를 가져오는 기능을 추가하였다.
또한 인스턴스를 탐색하는 과정에서 중복 검사를 수행하고 중복되는 오브젝트를 제거한다.
유일하게 씬에 오브젝트가 없는 경우에만 위 코드가 오작동을 하는데, 오브젝트가 없는 경우 리소스 폴더에서 프리팹을 불러오는 기능을 추가하면 될 것 같다.
여러 오브젝트 사이에서 OnDisable, OnDestroy의 호출 순서 역시 보장되지 않는다고 한다.
두 메서드 역시 오브젝트당 한 번에 묶여서 호출된다.
참고 자료
Unity Documentation - 이벤트 함수의 실행 순서
Unity Forum - OnEnable before Awake??