写 UE 代码头几年,我遇到的大部分诡异崩溃都可以归类成一句话:在错误的生命周期阶段做了不该做的事。在构造函数里 SpawnActor、在 BeginDestroy 里 NewObject、在 PostLoad 里假定对象已经加载完——每一条都能在源码里找到对应的 ensure 或断言。下面基于 UE5.3(Engine/Source/Runtime/CoreUObject)把 UObject 的一生走完,顺便说清每个阶段引擎在背后做了什么。
创建:NewObject 不是简单 new
NewObject<T>() 的完整实现在 UObject/UObjectGlobals.cpp 的 StaticConstructObject_Internal。它做的事远比"分配内存 + 调构造函数"多:
- 查参数合法性:Outer 不能是 NULL 也不能是 Trash,类不能是 abstract、deprecated(会被 ensure 拦下)。
- 分配 slot:
StaticAllocateObject在GUObjectArray里拿一个 index,同时向 FName 系统注册对象名。名字冲突时会自动加后缀,这就是为什么运行时对象常叫MyActor_123。 - C++ 构造:通过 placement new 在分配好的内存上调构造函数。这里的关键是构造函数拿到的 Outer、Name 已经就位(
FObjectInitializer会传进来),但属性还是 CDO 的值。 - 属性初始化:用 archetype(默认是 CDO)的内存做模板,把新对象的属性区 copy 一遍,再处理指针类属性的深拷贝(
UClass::InstanceObjectGraph)。这步决定了"改 CDO 影响所有新实例"。 PostInitProperties:属性就位后的第一个虚函数钩子,此时对象已经完全可用了。
整个过程伪代码大致是:
// StaticConstructObject_Internal 的主干(简化)
UObject* Obj = StaticAllocateObject(Class, InOuter, Name, Flags, Template);
(*Class->ClassConstructor)(FObjectInitializer(Obj, Template, ...));
Obj->PostInitProperties();
FObjectInitializer 值得一提:它不是普通的参数包,而是一个贯穿整个构造链的上下文。CreateDefaultSubobject 创建的子对象会先登记到 initializer 里,等父对象构造完成后再统一处理属性覆盖(子对象的属性可能被蓝图改写过)。这也是为什么构造函数里创建的 component 能感知蓝图默认值。
加载期:PostLoad 与 ConditionalPostLoad
资产从磁盘反序列化时走的是另一条路:对象先被创建出来(属性从包里流进来),然后引擎对每个新加载的对象调 ConditionalPostLoad → PostLoad。这条路不跑 C++ 构造函数——构造函数在 cook 时就跑过了,磁盘上存的是序列化后的属性值。
PostLoad 的常见用途是"版本迁移":老资产加载进来后按当前代码修正数据。比如:
void UMyDataAsset::PostLoad()
{
Super::PostLoad();
// 老版本没有 MaxCount 字段,加载出来是 0,这里给个合理下限
if (MaxCount <= 0)
{
MaxCount = FMath::Max(1, DefaultMinCount);
}
}
注意 PostLoad 只在编辑器或加载路径上触发,运行时 NewObject 出来的对象不会走这里。想覆盖两条路的话,逻辑要同时放 PostInitProperties 和 PostLoad,或者干脆抽到 PostInitProperties 里调(运行时路径会走到,加载路径下 PostInitProperties 其实也会先执行,但要小心此时属性还没反序列化进来——这正是很多人踩的坑:PostInitProperties 里读到的 UPROPERTY 是 CDO 值,不是磁盘值)。
销毁:三段式
销毁不是一步完成的。GC 判定对象不可达后,调用链是:
Object->ConditionalBeginDestroy(); // UObject/UObject.cpp
ConditionalBeginDestroy 内部:
- 检查
IsReadyForFinishDestroy()——异步物理、渲染资源还没释放完的对象会推迟到下一帧再销毁。这就是"标记了 GC 但对象还活着"的官方机制,比如UPhysicsMaterial等持有物理线程资源的对象会在这里等 PT 同步。 - 调
BeginDestroy():虚函数钩子,用来停止异步操作、解注册外部系统。此时对象内存还完全有效,其他对象还能安全访问它。 FinishDestroy():真正释放。清掉对象在 hash 表里的登记、归还 FUObjectArray 的 slot、释放属性占用的内存(UPROPERTY里的 FString、TArray 的析构在这跑),最后调 C++ 析构函数。
这里有个容易误解的点:BeginDestroy 和 FinishDestroy 之间可能隔了好几帧(等异步资源),但两个函数都跑在 GameThread。所以 BeginDestroy 里可以做"通知其他系统我要死了"这类需要和其他 UObject 交互的事,但不能 NewObject 新的 UObject——GC 上下文是受限的,源码里有明确的 ensure 拦着(ensureMsgf(!IsGarbageCollectionLocked(), ...))。我实测在 BeginDestroy 里 NewObject,Editor 下会直接 ensure 弹窗,Shipping 下则是未定义行为。
生命周期总览
各阶段该干什么
| 阶段 | 该做 | 不该做 |
|---|---|---|
| 构造函数 | 设默认值、CreateDefaultSubobject | 访问 World、SpawnActor、依赖其他运行时对象 |
| PostInitProperties | 属性初始化后的自我修正 | 假定加载的属性已就位 |
| PostLoad | 资产版本迁移、派生数据重建 | 运行时 NewObject 路径依赖这里 |
| BeginDestroy | 停止异步任务、解注册回调 | NewObject、访问 Outer 链假定其存活 |
| FinishDestroy | 纯内存清理 | 访问任何其他 UObject |
手动销毁的 API 对比
除了等 GC,还有几个主动销毁的入口,语义差别要分清:
ConditionalBeginDestroy():立即走 BeginDestroy → FinishDestroy 流程。绕过可达性分析,调用者自己保证没人再引用它。适合"确定独占所有权"的对象(比如手动 NewObject 出来、从没交给引擎的临时对象)。MarkAsGarbage()(5.x 替代老的MarkPendingKill):只打EInternalObjectFlags::Garbage标记,不立即销毁,等下一轮 GC 回收。适合"我不想要了但可能还有别人引用"的场景——GC 会帮你确认。Rename(Outer=GetTransientPackage()):把对象挪到 transient package 下断开引用链,配合 MarkAsGarbage 是"温和销毁"的常见组合。
Actor 不走上面任何一个——用 AActor::Destroy(),它会处理组件销毁、网络同步、World 移除这一整套,之后 GC 才接管内存。
实战结论
- 构造函数只在 CDO 和新实例创建时跑,不要在构造函数里做全局注册——CDO 构造时会注册一次,那个对象永远不会进游戏。
- "对象还在但行为异常"优先怀疑
IsReadyForFinishDestroy延迟:异步资源没释放完,对象会一直挂着。 ConditionalBeginDestroy是公开 API,可以手动调(比如立即销毁一个不想等 GC 的对象),但调完之后除了IsValidLowLevel级别的检查外不要再碰它。- 5.x 里
UObject::BeginDestroy之后到FinishDestroy的间隔,官方文档叫 "deferred destruction",物理和渲染资源是主要延迟源。
理解了这条生命周期线,后面讲 AActor 的 BeginPlay/EndPlay、Component 的注册时机时就清楚了——Actor 生命周期本质上是 UObject 生命周期在游戏框架层的扩展。