跳到主要内容Unreal 对 C++ 的改造:UObject 对象模型详解 | 极客日志C++算法
Unreal 对 C++ 的改造:UObject 对象模型详解
本文深入解析 Unreal 引擎中 UObject 对象模型的核心机制。UObject 作为所有重要对象的基类,提供了垃圾回收、反射、序列化和网络同步等统一能力。文章对比了标准 C++ 与 Unreal 对象管理的差异,详细说明了 NewObject、CreateDefaultSubobject 和 SpawnActor 三种创建方式。重点阐述了 Class Default Object (CDO) 的作用及其在属性默认值和序列化差量中的应用,解释了 Outer 指针构建的逻辑归属树。最后梳理了 UObject 从创建、初始化到销毁的完整生命周期,强调不能手动 delete 而应依赖 GC 机制。
第 3 章 · UObject:万物之祖
上一章的全景图中,所有箭头最终都汇聚到一个核心节点——反射系统。而反射系统的一切又建立在一个更基础的前提之上:Unreal 中几乎所有重要的对象,都继承自同一个基类。
这个基类叫 UObject。
在标准 C++ 中,没有统一的根基类。std::vector 和你自己写的 Player 类之间没有任何继承关系,语言也不强迫它们有关系。这是 C++ 的设计哲学——'你不用的东西不应该为它付出代价'。
但 Unreal 需要做到一些事情,这些事情要求所有对象共享一组基础能力:
反射需要为每个类存储元数据序列化需要一个统一的 Serialize() 接口网络同步需要知道对象的身份和所有权编辑器需要通过统一的方式查询和显示对象属性UObject 就是这个"共享基础能力"的承载者。它不是 C++ 语言的一部分,而是 Unreal 对 C++ 对象模型的第一刀改造。
3.1 UObject 的继承版图
UObject
├── UActorComponent (组件)
│ ├── USceneComponent (带空间变换的组件)
│ │ ├── UStaticMeshComponent
│ │ ├── UCameraComponent
│ │ └── ...
│ ├── UMovementComponent
│ └── ...
├── AActor (可放置在世界中的实体)
│ ├── APawn (可被控制的实体)
│ │ ├── ACharacter (有移动能力的 Pawn)
│ │ └── ...
│ ├── APlayerController
│ ├── AGameModeBase
│ └── ...
├── USubsystem (子系统)
├── UDataAsset (数据资产)
└── UBlueprintFunctionLibrary
第一,AActor 本身继承自 UObject。A 前缀的类不是一个独立的继承树——它是 UObject 继承树的一个分支。第 1 章里我们说"A 前缀表示继承自 AActor",现在可以更精确地说:A 前缀表示"继承自 AActor,而 AActor 继承自 UObject"。
第二,并非所有 Unreal 类型都继承自 UObject。F 前缀的类型——FVector、FString、FName——不在这棵树上。它们是普通的 C++ 结构体/类,不受 GC 管理,不参与反射(除非用 USTRUCT() 标记),用完即销毁。这是一个刻意的设计:不是所有东西都需要反射和 GC 的开销,轻量级的数据类型应该保持轻量。
所以 Unreal 的类型世界实际上分成两个阵营:
| 阵营 | 基类 | 前缀 | 内存管理 | 反射 | 典型例子 |
|---|
| UObject 阵营 | UObject | U、A | GC 自动管理 | 完整反射 | AActor、UTexture |
| 值类型阵营 | 无 | F、T | 栈分配或手动/智能指针 | 有限或无 | FVector、TArray |
理解这条分界线至关重要——它决定了你如何创建对象、如何管理内存、如何传递所有权。
3.2 创建 UObject:告别 new
auto* obj = new MyClass();
delete obj;
在 Unreal 中,你 不能 用 new 创建 UObject 子类的实例。如果你写了 new AMyCharacter(),编译器不会报错(构造函数是可访问的),但后果很严重——这个对象不会被 GC 追踪,不会被注册到引擎的对象系统中,反射、序列化、蓝图等一切能力都失效。
NewObject()
UMyComponent* Comp = NewObject<UMyComponent>(this);
NewObject<T>() 是创建 UObject 子类最通用的方式。它做了比 new 多得多的事情:
- 分配内存:使用 Unreal 的内存分配器(而非默认的
operator new)
- 注册到对象系统:给对象分配唯一的名称和路径,注册到全局 UObject 表中
- 关联 Outer:第一个参数就是这个对象的 Outer(逻辑归属者,稍后详解)
- 初始化属性:从 CDO(Class Default Object,稍后详解)复制默认属性值
- 通知 GC:把这个对象纳入 GC 的追踪范围
这就是为什么你不能绕过 NewObject 直接 new——上面的步骤 2-5 全部不会发生。
CreateDefaultSubobject()
AMyCharacter::AMyCharacter()
{
MeshComp = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
MeshComp->SetupAttachment(RootComponent);
}
这个函数只能在 AActor 或 UActorComponent 的构造函数中使用。它创建的是一个"默认子对象"——与 CDO 绑定的组件实例。当编辑器或蓝图创建这个 Actor 时,这些默认子对象会自动存在。
如果你在构造函数之外调用 CreateDefaultSubobject,引擎会直接崩溃。这是一条硬性的生命周期规则。
SpawnActor()
FActorSpawnParameters Params;
Params.Owner = this;
AMyProjectile* Projectile = GetWorld()->SpawnActor<AMyProjectile>(
ProjectileClass, SpawnLocation, SpawnRotation, Params);
SpawnActor 专门用于在游戏世界中生成 Actor。它在 NewObject 的基础上还做了额外工作:将 Actor 注册到 World、设置变换、触发 BeginPlay 等。
| 场景 | 使用 |
|---|
| 创建普通 UObject 子类 | NewObject<T>() |
| 在构造函数中创建默认组件 | CreateDefaultSubobject<T>() |
| 在运行时往世界中生成 Actor | GetWorld()->SpawnActor<T>() |
3.3 CDO:Class Default Object
每一个用 UCLASS() 标记的类,在引擎启动时都会自动创建一个 默认实例——这就是 CDO(Class Default Object)。CDO 不是你创建的,是引擎在注册类的时候自动创建的。
const AMyCharacter* DefaultChar = GetDefault<AMyCharacter>();
CDO 的用途
属性默认值来源。 当你通过 NewObject 创建一个新实例时,它的属性值不是"未初始化"的——而是从 CDO 拷贝过来的。CDO 的属性值由构造函数决定。换句话说,构造函数不是在"初始化对象",而是在"定义这个类的默认属性值模板"。
序列化的差量基准。 当 Unreal 序列化一个对象时,它不会保存所有属性——只保存与 CDO 不同的属性。这大大减小了存档和网络传输的数据量。反序列化时,先从 CDO 拷贝所有默认值,再覆盖差异部分。
编辑器的默认值显示。 编辑器属性面板中,加粗显示的值是"与 CDO 不同的值",灰色显示的是"与 CDO 相同的默认值"。点击重置按钮就是把值还原到 CDO 的值。
CDO 的陷阱
CDO 带来了一些在标准 C++ 中不存在的心智负担:
构造函数的语义变了。 在标准 C++ 中,构造函数是"创建一个可用的对象"。在 Unreal 中,UObject 子类的构造函数更像是"定义这个类的默认配置"。构造函数会被调用多次——至少一次是创建 CDO,之后每创建一个实例也会调用。如果你在构造函数中做了有副作用的事情(比如写日志、连接网络、注册回调),这些副作用会在意想不到的时刻被触发。
AMyCharacter::AMyCharacter()
{
MaxHealth = 100.0f;
CurrentHealth = MaxHealth;
UE_LOG(LogTemp, Warning, TEXT("构造函数被调用"));
GetWorld();
}
CDO 会在 Hot Reload 时重建。 如果你使用 Hot Reload 或 Live Coding 修改了构造函数中的默认值,CDO 会被重新创建,已有实例中"与旧 CDO 相同的值"会跟着变,但"用户手动修改过的值"不会变。这种行为有时候符合直觉,有时候不符合——取决于你是否理解 CDO 差量机制。
3.4 Outer:逻辑归属关系
每个 UObject 都有一个 Outer 指针,指向它的"逻辑所有者"。这构成了一棵逻辑包含树:
UPackage "/Game/Maps/TestMap" (最外层:资产包)
└── UWorld (世界)
└── ULevel (关卡)
└── AMyCharacter (Actor)
├── UStaticMeshComponent (组件,Outer = AMyCharacter)
└── UCameraComponent (组件,Outer = AMyCharacter)
当你调用 NewObject<UMyComponent>(this) 时,this 就是新对象的 Outer。
Outer 的作用
对象路径。 每个 UObject 都有一个基于 Outer 链的全限定路径,类似文件系统路径:
FString Path = MeshComp->GetPathName();
资产管理。 当一个 UPackage 被卸载时,它内部所有 Outer 指向它的对象都会被一起回收。
GC 辅助。 GC 在遍历对象引用时,Outer 关系帮助它理解对象的逻辑归属,用于 GC 集群优化(第 5 章详述)。
Outer vs Owner vs Parent
| 概念 | 含义 | 适用范围 |
|---|
| Outer | 逻辑归属者(UObject 层面) | 所有 UObject |
| Owner | 游戏逻辑上的拥有者 | AActor(GetOwner()) |
| AttachParent | 场景层级中的父节点 | USceneComponent(空间变换继承) |
一个子弹(Projectile)的 Outer 可能是它所在的关卡,Owner 是发射它的角色,AttachParent 可能没有(自由飞行)。三者各管各的维度,不要混为一谈。
3.5 对象生命周期
一个 UObject 从出生到死亡,经历的阶段比标准 C++ 对象复杂得多:
创建阶段
NewObject<T>() / SpawnActor<T>() / CreateDefaultSubobject<T>()
├── 分配内存
├── 调用构造函数(从 CDO 拷贝默认值)
├── 注册到对象系统
└── 设置 Outer
初始化阶段
创建完成后,引擎会在不同时机调用一系列初始化函数:
virtual void PostInitProperties();
virtual void PostLoad();
virtual void BeginPlay();
PostInitProperties():对象刚创建、属性刚从 CDO 拷贝完时。此时对象已经有了 Outer,但可能还没有 World。
PostLoad():对象从磁盘(资产文件)加载后。此时属性已经从文件反序列化完成。
BeginPlay():仅对 Actor 和 ActorComponent。游戏正式开始运行时。此时 World 已经就绪,其他 Actor 也已就位。
在构造函数中不能做的事(访问 World、查找其他 Actor),通常可以在 BeginPlay() 中做。
使用阶段
正常使用。对于 Actor 有 Tick() 每帧更新。
销毁阶段
这是标准 C++ 开发者最需要适应的部分:你不能也不应该手动 delete 一个 UObject。
对于 Actor,你调用 Destroy() 方法:
但这并不会立即销毁对象。Destroy() 只是把 Actor 标记为"待销毁"状态——从世界中移除、停止 Tick、对其他系统变得不可见。对象的内存仍然存在,直到下一次 GC 运行时才真正释放。
在 UE4 中,标记待销毁使用 MarkPendingKill(),之后可以用 IsPendingKill() 检查。UE5 弃用了这个机制,改用更细粒度的对象有效性检查——但核心思路不变:标记,然后等 GC。
对于非 Actor 的 UObject,没有 Destroy() 方法。你只需要确保没有 UPROPERTY 引用指向它,GC 自然会在下次运行时回收它。如果你想强制保持一个对象不被回收(即使没有 UPROPERTY 引用),可以调用 AddToRoot()——但记得之后要 RemoveFromRoot(),否则就是内存泄漏。
完整的生命周期图
NewObject / SpawnActor
▼
┌──────────────┐
│ 构造函数 │ 从 CDO 拷贝默认值
└──────┬───────┘
▼
┌──────────────┐
│ PostInitProperties │ 属性初始化完成
└──────┬───────┘
▼
(如果是从磁盘加载)
┌──────────────┐
│ PostLoad │ 反序列化完成
└──────┬───────┘
▼
(如果是 Actor / Component)
┌──────────────┐
│ BeginPlay │ 游戏世界就绪
└──────┬───────┘
▼
┌──────────────┐
│ 使用 / Tick │ 正常运行
└──────┬───────┘
▼
┌──────────────┐
│ EndPlay │ 从世界移除(Actor)
└──────┬───────┘
▼
┌──────────────┐
│ BeginDestroy │ 开始销毁流程
└──────┬───────┘
▼
┌──────────────┐
│ FinishDestroy│ 释放所有资源
└──────┬───────┘
▼
GC 回收内存
3.6 UObject 与标准 C++ 对象的对照
让我们把 UObject 的行为与标准 C++ 对象做一个直接对比:
| 维度 | 标准 C++ 对象 | UObject |
|---|
| 创建 | new T() 或栈分配 | NewObject<T>(),必须堆分配 |
| 身份 | 只有内存地址 | 有名称、路径、所属 UClass |
| 类型信息 | 仅 typeid(如果启用 RTTI) | 完整的 UClass 反射元数据 |
| 默认值 | 构造函数定义 | CDO 定义,实例从 CDO 拷贝 |
| 归属关系 | 无内置机制 | Outer 链构成逻辑包含树 |
| 内存管理 | 手动 delete 或智能指针 | GC 自动回收 |
| 序列化 | 无内置支持 | 内置 Serialize(),差量序列化 |
| 销毁 | 立即(delete) | 延迟(标记后等 GC) |
每一行差异都是 Unreal 对 C++ 对象模型的一处改造。有些带来了便利(GC 省心),有些带来了约束(不能 new、不能立即销毁)。但它们共同构成了一个一致的体系——而这个体系的核心好处,将在下一章(反射系统)中完全展现。
一句话总结
UObject 是 Unreal 对 C++ 的第一刀改造——通过一个统一基类为所有重要对象注入了身份、归属、默认值模板和自动内存管理,代价是你必须遵守它的创建规则和生命周期协议。现在你也能回头理解第 1 章的命名前缀了:U 前缀表示继承自 UObject,A 前缀表示继承自 AActor(而 AActor 继承自 UObject),F 前缀是不在这棵树上的值类型——前缀不是风格,是告诉你"这个类型在 UObject 世界的哪一边"。
微信扫一扫,关注极客日志
微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown 转 HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML 转 Markdown 互为补充。 在线工具,Markdown 转 HTML在线工具,online
- HTML 转 Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML 转 Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online