UObject类
在虚幻引擎5(UE5)中,UObject 是整个引擎大厦的"第一块砖"。
如果你看虚幻引擎的源码,会发现除了纯底层的数学库(如 FVector, FRotator)和基础字符串外,几乎所有游戏逻辑类、资源类、甚至引擎核心系统,最终都继承自 UObject。
理解 UObject 不能只把它当成一个简单的基类,而是要把它看作一个"让标准 C++ 具备现代高级语言特性的运行容器"。
1. 为什么不能直接用标准 C++ 的 class?
标准 C++ 是为极致性能设计的,但它缺乏现代游戏引擎必需的几个核心能力:
-
标准 C++ 没有反射:运行时无法知道类里有什么变量。
-
标准 C++ 没有原生 GC :要么手动
delete(容易内存泄漏/野指针),要么用std::shared_ptr(在游戏这种高频动态交互的场景下,循环引用和多线程析构极易引发性能瓶颈或死锁)。 -
标准 C++ 无法直接与编辑器交互:美术和策划没办法在不改代码的情况下调整属性。
为了解决这些痛点,Epic 设计了 UObject。任何类只要继承了 UObject,就相当于放弃了完全自由的"裸 C++"身份,转而向引擎"注册了学籍",从而获得了引擎底层提供的四大超能力。
2. UObject 的四大超能力(核心功能)
① 垃圾回收(Garbage Collection)
在 UE5 中,你不能对 UObject 使用 new 和 delete。
-
创建 :必须使用引擎专用的顶层函数,例如
NewObject<T>()。 -
销毁 :你不需要、也不能手动释放它。UE5 拥有一个内置的垃圾回收器。GC 会定期从一个"根对象列表(Root Set)"开始,顺着所有被
UPROPERTY()标记的指针向下追踪。只要一个UObject还能被访问到,它就存活;如果没有任何有效的UPROPERTY()指针指向它,它就会在下一次 GC 周期中被自动清理
② 反射与元数据支持(Reflection)
每一个 UObject 派生类,在编译时都会被虚幻标头工具(UHT)扫描。在运行时,引擎会为这个类生成一个独一无二的 UClass 对象。 这个 UClass 就是该 UObject 的"DNA说明书"。通过它,引擎在运行时可以:
-
动态获取这个类有哪些成员变量、哪些成员函数。
-
实现高效的类型安全转换:在 UE5 中放弃
dynamic_cast,改用极其高效的Cast<T>(),其底层就是通过对比UClass实现的。
③ 序列化(Serialization)
游戏需要保存存档,资产(如纹理、网格体、蓝图)需要保存到硬盘。 因为 UObject 拥有反射信息,引擎知道它里面的每一个变量占多少字节、是什么类型。所以 UObject 天生具备"一键序列化"的能力------引擎可以自动将整个对象的所有属性打包成二进制文件(.uasset),或者从二进制文件中重新组装出一个一模一样的 UObject。
④ 编辑器集成与数据驱动(Editor Integration)
你在 C++ 中定义一个继承自 UObject 的类,并在变量前加上 UPROPERTY。当你回到 UE5 编辑器里,你会发现这个变量已经自动变成了细节面板(Details)上的一个输入框、滑动条或者颜色选择器。 这使得程序员可以用 C++ 写核心逻辑,而美术和策划可以在编辑器中利用 UObject 的派生类(如蓝图)自由调整游戏数值。
3. UObject 的家族谱系
在实际开发中,你很少会直接实例化一个纯 UObject,你更多使用的是它的"子孙后代"。
UObject (虚幻万物之源:提供 GC、反射、序列化,但没有空间概念)
│
└── UActorComponent (组件:不能独立存在,必须挂载到 Actor 身上,如聚光灯组件、移动组件)
│
└── AActor (一类特殊的 UObject:能够被放入游戏世界,拥有 Transform 坐标,可以被渲染)
│
└── APawn (可被玩家或 AI 控制的 Actor,比如小兵、汽车)
│
└── ACharacter (带有人形移动组件和碰撞体的高级 Pawn,游戏主角)
-
纯
UObject:它就像一个"隐形的数据包"或"逻辑处理器"。它没有空间坐标(Transform),不能摆放在游戏场景里。比如:一个技能系统里的"伤害计算公式类"、一个"背包里的道具数据类"。 -
AActor:继承自UObject,但加入了空间概念(位置、旋转、缩放)。只有AActor及其子类才能被SpawnActor放入关卡中。
4. C++ 程序员专属:UObject 的内存与生命周期避坑指南
-
绝对不要用
std::shared_ptr管理UObject: 虚幻有自己的生态。原生 C++ 的智能指针(TSharedPtr)只能用于非UObject的普通 C++ 类(即F开头的结构体或普通类)。UObject的智能指针是TWeakObjectPtr(弱引用)或直接依赖UPROPERTY()强引用。 -
写了
UObject*指针,必须加UPROPERTY():// 错误示范:GC 无法追踪此指针,MySubObject 随时可能被引擎回收,变成野指针导致崩溃! UMyObject* MySubObject; // 正确示范:向引擎宣告所有权,只要当前类存活,MySubObject 就绝对不会被 GC 清理 UPROPERTY() UMyObject* MySubObject; -
构造函数与生命周期 :
UObject的构造函数由引擎管理(用于创建类默认对象 CDO),千万不要在构造函数里写运行时逻辑。-
纯
UObject的初始化应该写在自定义的Initialize()函数中。 -
如果是
AActor,应该写在BeginPlay()(游戏开始或物体生成时调用)和Tick(float DeltaTime)(每帧循环)中。
-
UObject 底层实现
理解虚幻引擎 5 (UE5) 的垃圾回收(GC)机制会非常直观。UE5 的 GC 并没有采用 Java 那种复杂的、分代(Generational)且会频繁暂停线程的并发复杂算法,而是采用了一种高度定制的、基于反射系统的"标记-清除"(Mark-Sweep)算法。
它在游戏线程(Game Thread)的控制下,定时或按需分步(Incremental)执行。下面我们拆解它的核心实现原理和底层细节
1. 核心基石:根集(Root Set)与引用图(Reference Graph)
UE5 的 GC 能够知道哪些对象存活,全靠一张引用图(Reference Graph)。
-
Root Set(根集) :引擎在启动时,会将一些绝对不会被销毁的核心对象(如
UEngine、GameInstance、基础系统管理类)放入一个特殊的集合,称为 Root Set 。这些对象被打上了EInternalObjectFlags::RootSet标记,是 GC 扫描的起点。 -
UClass 反射注册表 :前面提到,每个
UObject都有对应的UClass元数据。UClass内部记录了该类中所有被UPROPERTY()标记的指针的内存偏移量(Offsets)。
怎么构建引用关系?
当 GC 开始时,它会从 Root Set 出发,利用 UClass 提供的内存偏移量信息,直接去读取每个对象内部的 UPROPERTY() 指针,找到它们指向的下一个 UObject。 通过这种方式,引擎在内存中顺藤摸瓜,构建出一张巨大的对象引用网。
2. 经典的"两阶段"实现:Mark & Sweep
虚幻的 GC 主要分为两个主阶段:Mark(标记)阶段 和 Sweep(清除)阶段。
第一阶段:Mark(标记存活对象)
-
清空标记 :首先,GC 会将内存中所有由引擎管理的
UObject的存活标记位(EInternalObjectFlags::Reachable)清空。 -
递归遍历(并行化):从 Root Set 开始,利用多线程(Task Graph)并行遍历所有可达的对象。
-
染色/打标签 :只要一个对象能从根节点通过
UPROPERTY()链条触达,GC 就会给它打上Reachable(可达)标签。 -
终点:遍历结束时,所有在引网络中的对象都被标记为了"存活"。
第二阶段:Sweep(清除死亡对象)
-
全局扫描(GObjectArray) :UE5 维护了一个全局的超级大数组
GUObjectArray,里面记录了当前游戏里诞生出的每一个UObject的指针。 -
筛选未标记对象 :Sweep 阶段会遍历这个大数组,只要发现哪个
UObject没有 被标记Reachable,说明它已经沦为孤岛,没人再需要它了。 -
反序列化与析构:
-
调用对象的
BeginDestroy(),通知对象"你马上要被销毁了",让它有机会释放一些原生 C++ 的非托管资源(如自己new的内存或第三方库句柄)。 -
确认准备就绪后,调用
FinishDestroy()。 -
最后,真正调用 C++ 的析构函数,并把内存归还给虚幻的内存分配器(如 FMallocBinned2)。
-
3. 游戏开发者的痛点:避免卡顿的"增量 GC"
如果游戏进行到后期,内存里有几十万个 UObject,一次性完整执行 Mark-Sweep 会导致游戏线程严重堵塞,表现为游戏画面突然卡顿一下(这就是常说的 GC Spike)。
为了解决这个问题,UE5 引入了增量垃圾回收(Incremental GC):
-
引擎会将 Mark 阶段 拆分成很多个小切片(Time Slice)。
-
比如规定每一帧只允许 GC 运行
2 毫秒。如果 2 毫秒到了,Mark 还没遍历完,GC 就会暂停,把当前的遍历上下文(栈状态)保存下来,把控制权还给游戏线程,让游戏继续跑。 -
下一帧,GC 接着上一次中断的地方继续往下标记。
-
注:Sweep(清除)阶段一般也是分批进行的(通过集群 Cluster 机制),防止一次性销毁太多对象导致卡顿。
4. 底层细节:Cluster(集群)优化
对于高度关联的对象(比如一个复杂的 ACharacter 蓝图,它身上挂了 50 个 UActorComponent 组件),如果 GC 还要去一个一个遍历组件的 UPROPERTY(),是非常低效的。
UE5 引入了 GC Cluster(垃圾回收集群) 优化:
-
当一个 Actor 被创建时,引擎会把这个 Actor 和它所有的组件打包成一个"集群"(Cluster)。
-
在 Mark 阶段,GC 只要检查到这个 Actor 是可达的,就会直接把整个集群里的所有组件全部标记为可达,而不需要去逐个解析反射指针。
-
这极大地节省了 CPU 遍历引用图的开销。
5. C++ 程序员必须遵守的 GC 铁律
明白底层原理后,你在写 C++ 代码时需要建立以下条件反射:
-
裸指针必死:
// 错误:GC 扫描时完全看不见普通指针,MyUI 随时可能被 Sweep 掉 UMyUserWidget* MyUI; -
如何主动通知 GC 销毁对象 : 你不能手动
delete MyObject。如果你想让一个对象在下一次 GC 时被回收,你可以切断所有指向它的UPROPERTY()指针,或者显式地调用:MyObject->ConditionalBeginDestroy(); // 标记该对象准备走向毁灭 -
如果不想要强引用,又想防止野指针:使用
TWeakObjectPtr<T>: 类似于std::weak_ptr,它不会阻止对象被 GC 回收。但当对象被回收后,它会自动安全地指向nullptr,避免了原生 C++ 让人头疼的野指针崩溃问题。TWeakObjectPtr<UMyObject> WeakPtr = TextureObject; if (WeakPtr.IsValid()) // 安全检查 { WeakPtr->DoSomething(); }
总结来说,UE5 的 GC 是一套以反射数据为指引、在全局对象数组中进行并行标记、并利用时间切片(增量)来消除卡顿的托管内存管理系统。