生命周期归属:Subsystem 可全局访问不等于它活得够久

自动关联目录:生命周期归属:Subsystem 可全局访问不等于它活得够久

核心判断:选择 UE Subsystem 不是选择一种方便的单例,而是在声明状态由谁创建、能跨过哪些销毁边界、必须在什么时候失效。

一、业务冲突

编辑 World、PIE ClientTravel、新 World、LocalPlayer 和 GameInstance 的创建顺序并不对齐。状态放得太低,换 World 后丢失;放得太高,又会把旧 World 的引用带进新 World。最危险的情况不是空指针,而是对象仍活着,却代表上一轮 Session 或上一张地图。

二、实际实现

工程曾把客户端运行角色挂在 ULocalPlayerSubsystem。LocalPlayer 在编辑 World 中第一次判断“不应创建”后,ClientTravel 进入网络 World 不会重新补建,导致整个客户端运行宿主缺失。后续改为 UWorldSubsystem,让每个真实游戏 World 独立拥有角色状态。

相反,跨 World 的到达数据和加载呈现使用 UGameInstanceSubsystem:前者必须在目标 VM 第一帧即可读取,后者必须覆盖旧 World 销毁到新 World Ready 的空窗。物理注册、场景对象和网络时钟仍归 World,因为它们不能越过当前模拟域。

三、值得保留

当前实践已经认识到“全局可访问”和“跨 World 存活”不是一回事。把网络角色、物理和场景放回 World,把到达快照放到 GameInstance,是按业务寿命选择 UE 宿主,而不是按调用方便选类。

四、结构性缺陷

UE 只提供 Engine、GameInstance、World、LocalPlayer 等粗粒度容器,却没有直接表达 PlaySession、一次迁移、一个 VM Generation 或一次资源请求。于是代码仍会借助进程静态标记、跨 World 遍历和手写 Generation 补齐中间寿命;宿主类型正确,也不等于内部每份状态都有正确作用域。

五、目标设计

先定义生命周期格,再决定 UE 容器:

Process
  -> GameInstance / PlayerSession
    -> World / Match
      -> RuntimeDomain / VM Generation
        -> Entity / Presentation
          -> Operation / Async Request

长寿命对象不得直接持有短寿命 UObject,只保存带作用域的值身份;真正访问时再经当前域解析。

struct ScopedHandle {
    SessionId session;
    WorldId world;
    DomainGeneration domain;
    StableId object;
};

T* Resolve(ScopedHandle h) {
    if (!Scopes.IsCurrent(h.session, h.world, h.domain)) return nullptr;
    return Worlds[h.world].Objects.Resolve<T>(h.object);
}

六、取舍与验证

更细的作用域会增加句柄、校验和测试成本,但能把跨 World 串台从偶发崩溃变成明确的 stale rejection。测试至少覆盖 Edit -> Play -> Edit、PIE ClientTravel、断线重连、同进程多 World、LocalPlayer 替换、Session 延续但 World 更换,以及旧回调晚于新域建立的情况。

Subsystem 的正确标准不是“任何地方都能 Get 到”,而是它只在自己的业务寿命内可用,并且无法误用上一代状态。