VContainer 의 진입점은 PlayerLoop 에 꽂힌다
public class BattleLoop : ITickable
{
public void Tick() { /* 매 프레임 */ }
}
이 클래스는 MonoBehaviour 가 아니고 씬에 붙어 있지도 않은데 매 프레임 호출을 받습니다. 어디서 불리는지 모르면 Update 와의 순서를 틀리고, 자식 스코프에서 같은 진입점이 두 번 도는 것을 못 알아챕니다.
VContainer 는 스코프마다 디스패처가 진입점을 인터페이스별로 모아, 유니티 플레이어 루프에 직접 꽂은 러너에 올립니다. Initialize 만 스코프의 Awake 안에서 곧바로 돌고, 자식 스코프는 부모의 싱글턴 진입점만 걸러 냅니다.
구현한 인터페이스가 불리는 자리를 정한다
| 인터페이스 | 불리는 자리 | 횟수 |
|---|---|---|
IInitializable · IPostInitializable |
컨테이너 빌드 직후, 스코프 Awake 안에서 동기 |
한 번 |
IStartable · IAsyncStartable |
첫 프레임, 모노비헤이비어 Start 앞 |
한 번 |
IPostStartable |
첫 프레임, 모노비헤이비어 Start 뒤 |
한 번 |
IFixedTickable · IPostFixedTickable |
물리 갱신 앞 · 뒤 | 매번 |
ITickable · IPostTickable |
Update 앞 · 뒤 |
매 프레임 |
ILateTickable · IPostLateTickable |
LateUpdate 앞 · 뒤 |
매 프레임 |
Singleton 이나 Scoped 로 등록된 IDisposable 은 스코프가 닫힐 때 불리므로 사실상 종료 진입점입니다.
꼭 RegisterEntryPoint 로 등록하지 않아도 불린다
진입점을 실제로 부르는 것은 스코프마다 하나씩 있는 디스패처입니다. 디스패처는 빌드가 끝나면 컨테이너에 “ITickable 로 꺼낼 수 있는 것 전부” 를 달라고 해서 그 목록을 부릅니다. 어떤 메서드로 등록했는지는 보지 않고, 그 인터페이스로 꺼낼 수 있는지만 봅니다.
그리고 LifetimeScope 는 빌드하기 전에 디스패처를 늘 자동으로 등록해 둡니다. 그래서 LifetimeScope 안이라면 평범한 Register 로 등록해도 인터페이스로 꺼낼 수 있으면 불립니다. 두 가지만 다릅니다.
- 적은 인터페이스만 불린다.
.As<ITickable>()처럼 하나만 적으면ITickable목록에만 들어가서, 같은 클래스가IInitializable을 구현했어도Initialize는 안 불립니다.AsImplementedInterfaces()나RegisterEntryPoint는 구현한 인터페이스 전부로 등록합니다. LifetimeScope없이ContainerBuilder만 쓰면 디스패처가 없다. 이때는RegisterEntryPoint가 디스패처까지 같이 등록하므로 이것을 써야 돕니다.
Initialize 는 Awake 안에서 돌고 나머지는 루프로 간다
디스패처는 빌드 콜백에서 Dispatch() 를 부릅니다. 빌드가 스코프의 Awake 안에서 일어나므로 이것도 Awake 안입니다.
// EntryPointDispatcher.Dispatch (줄임)
var initializables = container.Resolve<ContainerLocal<IReadOnlyList<IInitializable>>>().Value;
for (var i = 0; i < initializables.Count; i++)
initializables[i].Initialize(); // 즉시 동기 실행
var tickables = container.Resolve<ContainerLocal<IReadOnlyList<ITickable>>>().Value;
if (tickables.Count > 0) // 없으면 루프 부담 0
PlayerLoopHelper.Dispatch(PlayerLoopTiming.Update, new TickableLoopItem(tickables, exceptionHandler));
Initialize() 는 플레이어 루프를 타지 않습니다. 다른 게임오브젝트의 Awake 가 아직 안 돌았을 수 있는 시점입니다.
러너는 기존 루프에 끼워 넣어 Update 앞뒤에서 돈다
PlayerLoopHelper.EnsureInitialized() 는 앱 전체에서 한 번(Interlocked.CompareExchange), PlayerLoop.GetCurrentPlayerLoop() 로 받은 루프의 다섯 그룹에 앞뒤로 하나씩, 열 개의 러너를 꽂습니다. 기본 루프가 아니라 현재 루프를 고치므로 다른 라이브러리가 먼저 꽂아 둔 단계를 날리지 않습니다.
Tick 이 Update 보다 먼저인 것은 러너를 ScriptRunBehaviourUpdate 앞에 꽂았기 때문이고, PostTick 이 코루틴보다 나중인 것은 그룹 맨 끝에 꽂았기 때문입니다. 러너는 항목의 MoveNext() 가 거짓을 돌려주면 목록에서 뺍니다. Start 계열은 한 번 돌고 거짓을 돌려 빠지고, Tick 계열은 해제될 때까지 참을 돌려줍니다. 스코프가 닫혀 디스패처가 해제되면 항목은 해제 표시만 되고 다음 프레임의 MoveNext() 에서 빠집니다.
자식 스코프는 부모의 싱글턴 진입점만 걸러 낸다
자식 스코프에도 자기 디스패처가 있어서, 빌드할 때 자기 ITickable 목록을 모아 매 프레임 부릅니다. 문제는 VContainer 에서 IReadOnlyList<ITickable> 같은 목록을 Resolve 하면 자기 스코프뿐 아니라 부모 스코프의 등록까지 모은다는 점입니다. 그대로 두면 루트의 진입점이 루트 디스패처에서 한 번, 자식 디스패처에서 또 한 번 불립니다.
그래서 디스패처는 목록을 그냥 달라고 하지 않고 ContainerLocal<IReadOnlyList<ITickable>> 로 감싸서 달라고 합니다.
ContainerLocal<T> 는 VContainer 내부(VContainer.Internal)의 포장 클래스입니다. 안에 든 것은 값 하나뿐입니다.
public sealed class ContainerLocal<T>
{
public readonly T Value;
public ContainerLocal(T value) => Value = value;
}
아무도 이 타입을 등록하지 않습니다. Resolve<ContainerLocal<X>>() 를 부르면 레지스트리가 등록이 없는 것을 보고 그 자리에서 만들어 줍니다. 이때 X 가 목록이면 부모 스코프의 Singleton 을 뺀 목록을 모아 Value 에 담고, 목록이 아니면 X 를 평소처럼 꺼내 담습니다. 그러니까 ContainerLocal 은 기능이라기보다 “이 스코프 몫만 달라” 는 표시입니다. 부모의 싱글턴은 부모 디스패처가 이미 부르고 있으니 빼는 것입니다.
// EntryPointDispatcher.Dispatch (줄임). 진입점 인터페이스마다 이렇게 받는다
var tickables = container.Resolve<ContainerLocal<IReadOnlyList<ITickable>>>().Value;
목록을 모으는 쪽에서는 이 표시가 localScopeOnly 로 넘어가 부모 등록을 거릅니다.
// CollectionInstanceProvider, 부모 스코프의 등록을 모을 때 (줄임)
if (!localScopeOnly || x.Lifetime != Lifetime.Singleton)
registrationsBuffer.Add(new RegistrationElement(x, scope));
조건이 != Singleton 이라 부모의 싱글턴만 빠지고 Scoped 와 Transient 는 자식에서도 수집됩니다. 이름과 달리 ContainerLocal<T> 은 값을 스코프에 붙잡아 두지도 않습니다. 요청할 때마다 새로 만들어서, Transient 를 감싸 두 번 요청하면 두 번 새로 만들어졌습니다.
주의할 점
1. Initialize() 에서 씬 오브젝트를 건드리지 않는다
안 지키면 생기는 문제. Initialize() 는 스코프의 Awake 안에서 빌드 직후 동기로 불립니다. 다른 게임오브젝트가 Awake 에서 채우는 값을 읽으면 비어 있을 수 있습니다.
이렇게 쓴다. Initialize() 에는 데이터 준비처럼 씬과 무관한 일만 둡니다. 씬 오브젝트가 필요하면 IStartable 로 옮깁니다.
2. 부모 스코프의 진입점은 Singleton 으로 둔다
안 지키면 생기는 문제. 부모에 Scoped 로 둔 진입점은 자식에서도 수집되어 한 프레임에 두 번 돌 수 있고, 자식 스코프가 늘수록 더 겹칩니다.
이렇게 쓴다. RegisterEntryPoint 의 기본값인 Singleton 을 그대로 씁니다. 수명을 바꿔야 하면 자식 스코프가 생기는 구조인지 먼저 봅니다.
3. 진입점은 RegisterEntryPoint 로 등록한다
안 지키면 생기는 문제. 스코프 안에서는 Register 로도 돌지만, 코드를 순수 ContainerBuilder 로 옮기는 순간 등록은 되는데 아무도 부르지 않습니다. .As<ITickable>() 로 하나만 적으면 같은 클래스가 구현한 다른 진입점 인터페이스는 불리지 않습니다. 어느 쪽도 경고가 없습니다.
이렇게 쓴다. RegisterEntryPoint<T>() 로 등록합니다. 디스패처를 챙기고 구현한 인터페이스 전부로 등록합니다.
4. 진입점 예외가 게임을 멈추지 않는다는 것을 알고 쓴다
안 지키면 생기는 문제. 디스패처를 등록할 때 Debug.LogException 을 부르는 기본 예외 핸들러가 늘 함께 등록됩니다. Start 나 Tick 에서 예외가 나도 로그만 남고 다음 프레임이 그대로 돌아, 깨진 상태로 계속 진행합니다.
이렇게 쓴다. 치명적으로 다뤄야 하면 핸들러를 직접 등록해 그 안에서 멈추거나 보고합니다.
builder.RegisterEntryPointExceptionHandler(ex =>
{
Debug.LogException(ex);
// 여기서 게임을 멈추거나 리포트를 보낸다
});
정리
VContainer 의 진입점은 디스패처가 모아 플레이어 루프에 꽂은 러너에서 불립니다.
- 구현한 인터페이스가 자리를 정하고, 스코프 안에서는
Register로도 돌지만RegisterEntryPoint가 안전합니다. Initialize는 스코프Awake안에서 동기로, 나머지는 현재 루프에 끼워 넣은 러너에서Update앞뒤로 돕니다.- 자식 스코프는 부모의 싱글턴 진입점만 거르므로, 부모의
Scoped진입점은 두 번 돌 수 있습니다.