UE 项目里"需要一个全局管理器"的需求几乎天天有:存档管理、网络会话池、音频总线……老答案是写一个 Get() 单例,新答案是继承一个 USubsystem。这不是同义词替换——SubsystemCollection 把"随谁生、随谁死"这件单例永远做不对的事变成了类型系统的一部分。源码在 Runtime/Engine/Public/Subsystems/ 和 Private/SubsystemCollection.cpp(UE5.3)。
五种 Subsystem 和它们的外层宿主
Subsystem 自己没有独立生命,它的外层宿主(Outer)决定了生命周期:
| Subsystem 类 | 宿主(Outer) | 活多久 | 典型用途 |
|---|---|---|---|
UEngineSubsystem |
UEngine | 进程级,关机才死 | 平台能力封装、全局配置 |
UEditorSubsystem |
UEditorEngine | 编辑器进程 | 编辑器工具、自动化脚本 |
UGameInstanceSubsystem |
UGameInstance | 一次游戏实例,跨关卡 | 存档、会话管理 |
UWorldSubsystem |
UWorld | 一张地图 | 世界内的玩法管理器 |
ULocalPlayerSubsystem |
ULocalPlayer | 一个本地玩家 | 输入增强、玩家 UI 状态 |
五者的差异全在宿主上,机制完全同一套:每类宿主持有一个 FSubsystemCollectionBase,在宿主初始化时批量建出所有声明过的 Subsystem 实例,宿主析构时统一 Deinitialize 并释放。
创建时序:宿主的 Init 就是装配线
以 World 为例,UWorld::InitWorld 里有一行不起眼的 SubsystemCollection.Initialize(this)(World.cpp)。走进去(SubsystemCollection.cpp):
// SubsystemCollection.cpp(UE5.3)
void FSubsystemCollectionBase::Initialize(UObject* NewOuter)
{
Outer = NewOuter;
for (TObjectIterator<UClass> It; It; ++It)
{
UClass* Class = *It;
if (Class->IsChildOf(SubsystemType) &&
Class->HasAnyClassFlags(CLASS_Abstract) == false)
{
USubsystem* Subsystem = NewObject<USubsystem>(Outer, Class);
AddSubsystem(Subsystem, Class);
Subsystem->Initialize(*this); // 虚函数,子类做自己的事
}
}
}
这段遍历有两个过滤口子。其一是 ShouldCreateSubsystem——你的子类可以按条件拒绝创建(比如编辑器专用逻辑在 Game 构建里返回 false)。其二是 Collection 自身的 SubsystemType 绑定:每个宿主类在构造时把自己的基类传进去(FSubsystemCollection<UEngineSubsystem> 之类),所以五种 Subsystem 各归各的集合,互不串味。
这段代码还有个值得注意的细节:NewObject 的 Outer 是宿主本身。这意味着 Subsystem 参与宿主的 GC 归属——GameInstance 销毁时,挂在其下的 Subsystem 自然被 GC 回收,不需要手写释放。生命周期不是约定,是引用关系硬绑出来的。
销毁顺序为什么和设计器预期经常相反
集合在宿主析构时销毁,而宿主的析构发生在 GC 标记流程里。问题在于:游戏逻辑里你经常需要在"世界快没了"的时候收尾——比如把刷出来的临时 actor 清掉。UWorldSubsystem::Deinitialize 被调用时,世界里的 Actor 可能已经开始批量析构,此时再 spawn 或访问 Actor 会拿到半死对象。
所以正确的分层是:Deinitialize 只做无外部依赖的清理(注销回调、释放持有的普通对象),凡是要和其他世界对象交互的收尾,挂到 EndPlay 或宿主的下级通知上。我踩过的坑是在 WorldSubsystem 的 Deinitialize 里调 UGameplayStatics::GetAllActorsOfClass 统计战绩,PIE 退出时直接触发 ensure——世界 actor 列表已经清了。
销毁按初始化的逆序进行。同一集合内多个 Subsystem 之间没有可靠的相互可见顺序(创建按 UClass 遍历结果走,不保证你的子类先建出来),所以不要靠 Initialize 里的运行时 GetSubsystem<另一个Subsystem> 碰运气——对方可能还没建出来,拿到的 null 在打包后才暴露。真需要协同,让上层(比如 GameInstance 的 Init)在两个都 Initialize 完之后再牵线,或者干脆合并成一个 Subsystem。
为什么这套设计比单例好
单例在引擎语境下有四个结构性毛病,Subsystem 逐个有解:
- PIE 隔离。开两个 PIE 窗口调试,两个世界各拿各的
UWorldSubsystem实例,单例则直接互相污染数据。多世界(preview、standalone)同理。 - GC 安全。手写单例持有 UObject 指针需要手动 AddToRoot,忘了就是野引用崩溃;Subsystem 的 Outer 关系让 GC 自动管,而它又能被普通对象安全引用(它本身在 GC 体系内)。
- 测试性。单例的全局态让单元测试互相污染;Subsystem 可以只在测试 GameInstance 下注册一个 mock 版本。
- 开关成本。
ShouldCreateSubsystem让"这个构建要不要这个管理器"变成一行配置,单例只能 if-def 或者运行时 if。
代价也有:每个宿主都会为每个注册的 Subsystem 付一次 NewObject + Initialize,World 级别的宿主在编辑器里数量惊人(每个 preview world、每个 PIE 实例)。插件作者注册 UWorldSubsystem 前应该先问"这东西真的属于每张地图吗",否则编辑器切个 preview 就创建一个,全工程累计下来 Initialize 开销不可忽略。对只该有一个的管理器(存档、会话),UGameInstanceSubsystem 才是对的默认选择。
选用决策
把"这东西的数据对谁有意义"翻译成宿主语言就行:跟着进程走的(热更新器、第三方 SDK 封装)→ Engine;跟着一局游戏走的(存档、成就、联机会话)→ GameInstance;跟着一张地图走的(刷怪总监、环境事件)→ World;跟着一个本地玩家走的(按键方案、镜头偏好)→ LocalPlayer。选错的代价真实存在:把存档管理放 WorldSubsystem,每次切图重建,玩家进度就丢在旧世界的 GC 里。