技术博客

Subsystem 体系:五种 Subsystem 的生命周期差异

第 24 / 100 篇 · 2025-04-26 · ue5 · GameFrameworkSubsystem源码分析

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 逐个有解:

  1. PIE 隔离。开两个 PIE 窗口调试,两个世界各拿各的 UWorldSubsystem 实例,单例则直接互相污染数据。多世界(preview、standalone)同理。
  2. GC 安全。手写单例持有 UObject 指针需要手动 AddToRoot,忘了就是野引用崩溃;Subsystem 的 Outer 关系让 GC 自动管,而它又能被普通对象安全引用(它本身在 GC 体系内)。
  3. 测试性。单例的全局态让单元测试互相污染;Subsystem 可以只在测试 GameInstance 下注册一个 mock 版本。
  4. 开关成本。ShouldCreateSubsystem 让"这个构建要不要这个管理器"变成一行配置,单例只能 if-def 或者运行时 if。

代价也有:每个宿主都会为每个注册的 Subsystem 付一次 NewObject + Initialize,World 级别的宿主在编辑器里数量惊人(每个 preview world、每个 PIE 实例)。插件作者注册 UWorldSubsystem 前应该先问"这东西真的属于每张地图吗",否则编辑器切个 preview 就创建一个,全工程累计下来 Initialize 开销不可忽略。对只该有一个的管理器(存档、会话),UGameInstanceSubsystem 才是对的默认选择。

五种 Subsystem 的生命周期:随宿主消亡 UEngine UEditorEngine UGameInstance UWorld ULocalPlayer 进程级 编辑器进程 跨整个游戏 一张地图 一名玩家 UEngineSubsystem UEditorSubsystem UGameInstanceSubsystem UWorldSubsystem ULocalPlayerSubsystem 统一的装配与回收(SubsystemCollection.cpp) 宿主 Init → 遍历 UClass 过滤抽象类 → ShouldCreateSubsystem → NewObject(Outer=宿主) 类间无可靠创建顺序,Initialize 不做跨 Subsystem 假设;Deinitialize 随宿主 GC 倒序回收 生命周期 = GC 归属:Outer 引用关系硬绑定,不依赖手写释放

选用决策

把"这东西的数据对谁有意义"翻译成宿主语言就行:跟着进程走的(热更新器、第三方 SDK 封装)→ Engine;跟着一局游戏走的(存档、成就、联机会话)→ GameInstance;跟着一张地图走的(刷怪总监、环境事件)→ World;跟着一个本地玩家走的(按键方案、镜头偏好)→ LocalPlayer。选错的代价真实存在:把存档管理放 WorldSubsystem,每次切图重建,玩家进度就丢在旧世界的 GC 里。