UE5 UObject类详解

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 使用 newdelete

  • 创建 :必须使用引擎专用的顶层函数,例如 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 的内存与生命周期避坑指南

  1. 绝对不要用 std::shared_ptr 管理 UObject : 虚幻有自己的生态。原生 C++ 的智能指针(TSharedPtr)只能用于非 UObject 的普通 C++ 类(即 F 开头的结构体或普通类)。UObject 的智能指针是 TWeakObjectPtr(弱引用)或直接依赖 UPROPERTY() 强引用。

  2. 写了 UObject* 指针,必须加 UPROPERTY()

    复制代码
    // 错误示范:GC 无法追踪此指针,MySubObject 随时可能被引擎回收,变成野指针导致崩溃!
    UMyObject* MySubObject;
    
    // 正确示范:向引擎宣告所有权,只要当前类存活,MySubObject 就绝对不会被 GC 清理
    UPROPERTY()
    UMyObject* MySubObject;
  3. 构造函数与生命周期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(根集) :引擎在启动时,会将一些绝对不会被销毁的核心对象(如 UEngineGameInstance、基础系统管理类)放入一个特殊的集合,称为 Root Set 。这些对象被打上了 EInternalObjectFlags::RootSet 标记,是 GC 扫描的起点。

  • UClass 反射注册表 :前面提到,每个 UObject 都有对应的 UClass 元数据。UClass 内部记录了该类中所有被 UPROPERTY() 标记的指针的内存偏移量(Offsets)

怎么构建引用关系?

当 GC 开始时,它会从 Root Set 出发,利用 UClass 提供的内存偏移量信息,直接去读取每个对象内部的 UPROPERTY() 指针,找到它们指向的下一个 UObject。 通过这种方式,引擎在内存中顺藤摸瓜,构建出一张巨大的对象引用网。

2. 经典的"两阶段"实现:Mark & Sweep

虚幻的 GC 主要分为两个主阶段:Mark(标记)阶段Sweep(清除)阶段

第一阶段:Mark(标记存活对象)

  1. 清空标记 :首先,GC 会将内存中所有由引擎管理的 UObject 的存活标记位(EInternalObjectFlags::Reachable)清空。

  2. 递归遍历(并行化):从 Root Set 开始,利用多线程(Task Graph)并行遍历所有可达的对象。

  3. 染色/打标签 :只要一个对象能从根节点通过 UPROPERTY() 链条触达,GC 就会给它打上 Reachable(可达)标签。

  4. 终点:遍历结束时,所有在引网络中的对象都被标记为了"存活"。

第二阶段:Sweep(清除死亡对象)

  1. 全局扫描(GObjectArray) :UE5 维护了一个全局的超级大数组 GUObjectArray,里面记录了当前游戏里诞生出的每一个 UObject 的指针。

  2. 筛选未标记对象 :Sweep 阶段会遍历这个大数组,只要发现哪个 UObject 没有 被标记 Reachable,说明它已经沦为孤岛,没人再需要它了。

  3. 反序列化与析构

    • 调用对象的 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 是一套以反射数据为指引、在全局对象数组中进行并行标记、并利用时间切片(增量)来消除卡顿的托管内存管理系统

相关推荐
ZhangShao06071 小时前
题解:AT_abc459_e
c++
chengO_o2 小时前
AVL树详解与实现(C++)
数据结构·c++·avl树·平衡二叉搜索树
玉树临风ives2 小时前
atcoder ABC 458 题解
数据结构·c++·算法
chengO_o2 小时前
STL关联式容器:map 与 set 的使用
c++·stl·set·map·平衡二叉搜索树
charlie1145141912 小时前
现代C++特性指南(5)——RAII 深入理解:资源管理的基石
开发语言·c++·现代c++
神仙别闹3 小时前
基于QT(C++)+Sqlite3实现单词消除游戏系统
c++·qt·sqlite
yunn_3 小时前
基于C++ 11的线程池实现
c++
平行云3 小时前
实时云渲染预启动技术解析:UE数字孪生应用的延迟优化机制(一)
linux·ue5·webgl·数字孪生·云渲染·实时云渲染·像素流
人间乄惊鸿客3 小时前
c++自记录
java·开发语言·c++