一、问题的起点
场景 1:保存游戏进度
struct PlayerState { FVector3 Position; float Health; int32 Inventory[50];};怎么把这个结构体存到磁盘?最朴素的做法:
void Save(FILE* file, const PlayerState& state) { fwrite(&state, sizeof(state), 1, file);}这个问题很大。FVector3 内部可能有虚表指针、Inventory 的数组长度可能变化、Health 和 Position 的排列顺序在 32 位和 64 位平台上不一致。更致命的是:下个版本你给 PlayerState 加了一个 Stamina 字段后,旧存档全部损坏。
场景 2:编辑器属性面板
你在 UE 编辑器里选中一个 Actor,Detail 面板自动列出所有可编辑的属性——位置、旋转、材质引用、自定义的 float 变量。编辑器的代码不认识你的自定义类,但它知道”这个 Actor 有哪些 UPROPERTY”。
这就是反射(Reflection):程序在运行时获取自身类型信息的能力。C++ 原生不支持反射。UE 的解决方案是 UHT (Unreal Header Tool) 代码生成 + UCLASS/USTRUCT/UPROPERTY 宏体系。
二、运行时类型信息:从 void* 到 UClass
1 有了运行时类型信息能干什么
void* ptr = GetObjectOfUnknownType();
// 没有反射:做不了任何事// 有反射:UClass* cls = GetClass(ptr); // 查类型UProperty* prop = cls->FindProperty("Health"); // 查属性float* value = prop->GetValuePtr<float>(ptr); // 读属性值有了这些能力,引擎可以在不重新编译的情况下:
- 序列化任意
UObject到磁盘 - 在编辑器里显示任意类的属性面板
- 在蓝图中暴露任意 C++ 变量
- 做网络同步(把标记了
Replicated的属性自动同步到客户端) - 做 GC(检查哪些
UObject*已经不可达)
反射是 UE 架构里最核心的胶水层。没有它,编辑器、GC、序列化、蓝图、网络同步全部得推倒重来。
2 UE 的宏体系
你在 UE 里写的:
UCLASS(Blueprintable)class UMyObject : public UObject { GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite) float Health;
UFUNCTION(BlueprintCallable) void TakeDamage(float Damage);};UHT 在编译前会扫所有头文件,找到标记了 UCLASS/USTRUCT/UPROPERTY/UFUNCTION 的声明,为它们生成额外的 C++ 代码。生成的内容包括:
- 每个类的
UClass单例对象——内嵌在生成代码中的静态变量 - 属性偏移量表——
Health在UMyObject实例中的字节偏移 - 函数注册表——
TakeDamage的调用地址 - 网络同步元数据——
Health的ReplicationCondition - 序列化指令——属性的加载/保存逻辑
本质上,UHT 是一个编译器插件,它把宏标记翻译成了 C++ 不具备的运行时类型信息。
3 生成的代码长什么样
简化后的 GENERATED_BODY 展开:
static UClass* StaticClass() { static UClass* Class = nullptr; if (!Class) { Class = new UClass(); Class->ClassConstructor = &StaticConstructor;
// 注册属性 FFloatProperty* HealthProp = new FFloatProperty(); HealthProp->SetOffset(offsetof(UMyObject, Health)); HealthProp->SetPropertyFlags(CPF_Edit); // EditAnywhere Class->AddProperty(HealthProp);
// 注册函数 Class->AddFunction("TakeDamage", &execTakeDamage); } return Class;}你在运行时调用 GetClass(),返回的就是这个 UClass 单例。所有属性、函数、网络同步标记,都以元数据的形式存放在了 UClass 对象里。
三、FArchive:序列化的核心抽象
1 统一读写接口
UE 没有”SaveToFile”和”LoadFromFile”两套 API。它用一种巧妙的统一抽象——FArchive:
void UObject::Serialize(FArchive& Ar) { Super::Serialize(Ar);
if (Ar.IsSaving()) { // 写操作 Ar << Health; Ar << Position; } if (Ar.IsLoading()) { // 读操作 Ar << Health; Ar << Position; }}<< 操作符依据 Ar.IsSaving() / Ar.IsLoading() 自动选择读写方向。同一段代码既可以存盘、也可以读盘、也可以做网络序列化——只需传入不同的 FArchive 子类:
| FArchive 子类 | 用途 |
|---|---|
FMemoryArchive | 序列化到内存缓冲区 |
FArchiveFileWriter | 写文件(.uasset) |
FArchiveFileReader | 读文件 |
FPackageArchive | .pak 包的序列化 |
FNetArchive | 网络复制(FObjectReplicator) |
2 自动属性序列化
UE 的大多数 UObject 不需要手写 Serialize。UHT 生成的代码里已经包含了属性遍历逻辑:
// UHT 生成的 Serialize 函数(伪代码)void UMyObject::Serialize(FArchive& Ar) { Super::Serialize(Ar); // 先序列化父类属性
for (UProperty* Prop : Class->Properties) { if (!Prop->HasAnyFlag(CPF_Transient)) { // Transient 跳过 Prop->SerializeItem(Ar, this + Prop->GetOffset()); } }}这就是为什么你标记 UPROPERTY() 之后,属性的存盘、读档、网络同步自动生效。UHT 替你写了序列化循环,FFloatProperty::SerializeItem 替你处理了 << 操作。
3 版本兼容(Schema Evolution)
加字段是存档系统的噩梦。UE 的应对:
方法一:SerializeVersion / CustomVersion
每个 FArchive 携带一个引擎版本号。属性读取时可以检查版本:
void UMyObject::Serialize(FArchive& Ar) { Super::Serialize(Ar);
if (Ar.UEVer() >= VER_UE4_ADDED_STAMINA) { Ar << Stamina; // 只有新版本存档才有这个字段 }}方法二:UPROPERTY(SkipSerialization) + 手动兼容
新增字段在旧存档里不存在,加载后取默认值:
UPROPERTY()float Stamina = 100.0f; // 旧存档加载后保持默认值UE 的序列化系统遇到”存档有但类没有”的字段时,不会崩溃——它会把不认识的属性数据存入 FObjectReader 的”未知数据缓冲区”,在写回时原样保留(Round-Trip 兼容)。
四、没有反射时怎么办:手动序列化
如果你的自研引擎没有 UE 级别的反射系统,你需要手动管理。以下是几种常见策略。
1 手工注册属性
struct Property { const char* name; size_t offset; // 在结构体中的偏移 enum Type { INT, FLOAT, STRING, VECTOR3 } type;};
// 手写注册表const Property PlayerProperties[] = { { "Health", offsetof(PlayerState, Health), Property::FLOAT }, { "Position", offsetof(PlayerState, Position), Property::VECTOR3 },};
// 通用序列化循环void Serialize(Stream& s, void* obj, const Property* props, int count) { for (int i = 0; i < count; i++) { void* fieldPtr = (char*)obj + props[i].offset; switch (props[i].type) { case Property::FLOAT: s.SerializeFloat(*(float*)fieldPtr); break; case Property::VECTOR3: s.SerializeVector3(*(Vector3*)fieldPtr); break; // ... } }}几十行代码,已经有了反射雏形。维护成本在于增加字段时得手动更新注册表——漏一个就是 bug。但好处是完全不侵入编译流程,不需要 UHT。
2 协议缓冲区(Protobuf / FlatBuffers / Cap’n Proto)
另一种思路:不在运行时做反射,而在编译时生成序列化代码:
Person.proto → protoc → Person.pb.h + Person.pb.cc生成代码里包含了 SerializeToArray() / ParseFromArray(),完全不依赖运行时类型信息。许多自研服务端用 Protobuf 做网络消息序列化。客户端侧用类似思路也可以管理存档格式。
缺点是你需要维护一份 IDL(接口定义语言)文件,且生成的代码体积较大。
3 为什么 UE 不直接用 Protobuf
UE 诞生于 1998 年,Protobuf 诞生于 2001 年。UE 早年没有选择。后来发展出了 Editor、Blueprint、GC——这些东西需要的远不止序列化,而是运行时的完整体类型信息(函数调用、属性编辑、网络复制条件)。Protobuf/FlatBuffers 专注序列化,做不了这些。
五、GC:反射系统的另一个依赖者
UE 的 GC 是一个标记-清扫(Mark & Sweep)算法。它工作的前提是知道每个 UObject 持有哪些 UObject 指针。
靠的就是 UHT 生成的属性元数据:
void UObject::AddReferencedObjects(FReferenceCollector& Collector) { for (UProperty* Prop : GetClass()->Properties) { if (Prop->IsA<FObjectProperty>()) { // UObject* 属性 UObject** ref = (UObject**)(this + Prop->GetOffset()); Collector.AddReferencedObject(*ref); // 标记引用 } if (Prop->IsA<FArrayProperty>()) { // TArray<UObject*> 属性 // 遍历数组,标记每个元素 } }}这就是你为什么必须把 UObject* 标记为 UPROPERTY()——如果你写了一个裸的 UObject* Ptr; 而不加宏,GC 看不见这个引用。当被引用的对象变成废弃时,GC 会回收它,你的裸指针变成悬空指针,导致随机崩溃。
UPROPERTY() 不只是编辑器或序列化的需求——它是 GC 正确性的硬约束。
六、对比与总结
UE 方案的优缺点
| 优点 | 缺点 |
|---|---|
| 零手写序列化代码 | UHT 侵入编译流程,增量编译变慢 |
| 编辑器自动发现所有属性 | 宏体系复杂,新手看到 GENERATED_BODY() 不知道发生了什么 |
| GC + 网络同步 + 序列化共享同一套元数据 | 编译出来的包体包含反射数据,占用额外空间 |
蓝图可以访问任意标记了 BlueprintCallable 的函数 | 大量旧的 UCLASS 拖慢启动时的类型注册 |
自研引擎可能的选择
小团队(< 10 人)→ 手动注册表或 Proto IDL,够用中团队(10-50 人)→ 做一个轻量代码生成器,参考 UHT 但裁剪到只做序列化大团队(50+ 人)→ 很可能已经有类似 UHT 的系统,你入职后读文档看它怎么设计的无论哪种方案,核心概念不变:你需要一种方式让引擎知道”这个结构体里有哪些字段、什么类型、放在哪个偏移”。剩下的序列化、编辑器、网络同步,都是在这个元数据基础上搭建的应用。
关键概念关系图
UHT(编译时代码生成) │ ▼ UClass / UProperty(运行时类型元数据) │ ┌─────────┼─────────┬──────────┐ ▼ ▼ ▼ ▼ 序列化 编辑器 网络同步 GC (FArchive) (DetailPanel) (Replication) (Mark&Sweep)一套元数据,四个基础设施。这就是 UE 用侵入式宏换来的架构红利。