2204 字
11 分钟
游戏引擎的序列化与反射系统

一、问题的起点#

场景 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 的数组长度可能变化、HealthPosition 的排列顺序在 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 单例对象——内嵌在生成代码中的静态变量
  • 属性偏移量表——HealthUMyObject 实例中的字节偏移
  • 函数注册表——TakeDamage 的调用地址
  • 网络同步元数据——HealthReplicationCondition
  • 序列化指令——属性的加载/保存逻辑

本质上,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 用侵入式宏换来的架构红利。

游戏引擎的序列化与反射系统
https://www.m4doka.xyz/posts/engine/engine-4-serialization-reflection/
作者
m4doka
发布于
2026-07-05
许可协议
CC BY-NC-SA 4.0