技术博客

序列化机制:FArchive 与 UPROPERTY 的存取框架

第 15 / 100 篇 · 2026-02-17 · ue5 · 序列化FArchiveSaveGame源码分析

UE 的序列化是个容易被低估的系统。表面上看它只是"把对象写到磁盘",实际上它同时支撑着三件事:资产加载(.uasset 文件)、SaveGame 存档、网络复制(属性同步也用序列化框架)。这三件事共用同一套 FArchive 抽象,差异只在具体的 Archive 实现上。下面基于 UE5.3(Engine/Source/Runtime/CoreUObject/Public/Serialization/Archive.h)把这套框架拆开。

FArchive:一个双向的流抽象

FArchive 是 UE 序列化的基类,设计上它同时是 reader 和 writer——同一个 Archive 对象,构造时决定是读模式还是写模式,之后通过 operator<< 统一操作:

// 同一个 Serialize 函数,既用于保存也用于加载
void UMyData::Serialize(FArchive& Ar)
{
    Ar << Health;        // Ar 是读模式时加载,写模式时保存
    Ar << Position;
    Ar << Inventory;
}

这个"双向"设计是刻意的。它强制序列化和反序列化用同一份代码,从根上避免了"存的格式和读的格式不一致"这类 bug。代价是没法在 Serialize 里做"只写不读"或"只读不写"的逻辑——如果某个字段只在保存时需要(比如旧版本兼容字段),得用版本号判断:

void UMyData::Serialize(FArchive& Ar)
{
    Ar << Health;
    if (Ar.UEVer() >= VER_UE5_ADDED_MAXCOUNT)
    {
        Ar << MaxCount;  // 新版本才有这个字段
    }
    else
    {
        MaxCount = DefaultMaxCount;  // 老版本给默认值
    }
}

Ar.UEVer() 返回的是包文件记录的引擎版本号,UE 在每个涉及格式变更的提交里会 bump 一个 VER_UE5_xxx 枚举值(Runtime/CoreUObject/Public/UObject/ObjectVersion.h)。这套版本号机制是 UE 资产向后兼容的基础。

两种序列化模式:Tagged vs Binary

UObject 的属性序列化有两条路,取决于 Archive 的类型:

Tagged 序列化(资产文件用):每个属性写成 FPropertyTag + 值的格式。Tag 里记录了属性名、类型、大小。加载时如果遇到不认识的 Tag(旧资产里没有新加的属性),跳过;遇到 Tag 但当前类里没这个属性(资产比代码新),也跳过。这是 UE 资产格式能向前向后兼容的核心机制。

Binary 序列化(SaveGame 用):直接把对象内存按固定布局写,没有 Tag。优点是快、体积小;缺点是格式刚性——类定义变了,旧存档就废了。FObjectAndNameAsStringProxyArchive 是 SaveGame 常用的 Archive,它在 Binary 基础上加了 UObject 指针的名字序列化(把指针转成对象路径字符串)。

资产加载走的是 Tagged 路径,SaveGame 走的是 Binary 路径,但两者都通过同一个 Serialize 虚函数驱动——这就是"共用一套框架"的含义。

UStruct::SerializeBin:属性驱动的序列化

UObject 的默认 Serialize 实现(UObject/Class.cpp 的 UStruct::SerializeBin)不逐个写成员变量,而是遍历 FProperty 链,对每个 UPROPERTY 调 SerializeItem:

// 简化后的逻辑
for (FProperty* Prop = GetPropertyLink(); Prop; Prop = Prop->PropertyLinkNext)
{
    Prop->SerializeItem(Ar, ValuePtr);  // ValuePtr = 对象内存 + Prop->Offset
}

这意味着序列化不需要手写每个字段——加了 UPROPERTY() 的成员自动被序列化,没加的自动跳过。这和反射/GC 共用同一份元数据:反射系统登记的属性,序列化和 GC 都自动感知。

SaveGame 的实际工作方式

UGameplayStatics::SaveGameToMemory / SaveGameToSlot 的流程:

  1. 创建 USaveGame 派生类实例,填充数据。
  2. 用 FObjectAndNameAsStringProxyArchive 包装一个 FMemoryWriter。
  3. 调 SaveGame->Serialize(Ar),把所有 UPROPERTY 写进内存 buffer。
  4. buffer 写到磁盘文件。

加载反过来:FMemoryReader + 同一个 Archive 类型,调 Serialize 把数据读回来。关键约束:读写必须用同一个类定义。如果存档后修改了 USaveGame 派生类的字段(加了一个 int),旧存档读出来那个位置的数据会被错误解释。UE 没有内置的 SaveGame 版本迁移机制,需要自己做——常见做法是在 SaveGame 类里加一个 SaveVersion 字段,读出来后按版本走不同的反序列化分支。

FStructuredArchive:5.x 的新层

UE5 引入了 FStructuredArchive(Serialization/StructuredArchive.h),在 FArchive 之上加了一层"记录-字段"的结构化抽象。它解决的问题是:传统 operator<< 是纯粹的顺序流,没法表达"这个字段属于哪个记录"这种结构信息,而现代格式(JSON、MessagePack)天然是结构化的。

FStructuredArchive 把序列化从"字节流"变成"结构化文档":

void UMyData::Serialize(FStructuredArchive::FRecord Record)
{
    Record << SA_VALUE(TEXT("Health"), Health);
    Record << SA_VALUE(TEXT("Position"), Position));
}

目前主要用于引擎内部的配置和缓存格式,资产文件仍以传统 Tagged 序列化为主。但方向是明确的:逐步把结构化序列化推广到更多格式。

FArchive 的 flag 体系

Archive 的行为由一组 flag 控制,读代码时经常要靠它们判断当前处于什么场景:

  • IsSaving() / IsLoading():当前是写还是读。Ar << X 内部据此选择序列化还是反序列化。
  • IsPersistent():操作的是持久化存储(资产文件、SaveGame),而不是网络或内存临时流。CDO 引用只在非 persistent 的流里直接存指针。
  • IsCooking():cook 过程中,编辑器专用数据(EditorOnly 属性)会在这时被跳过。
  • IsTransacting():编辑器撤销/重做的序列化。

自定义 Archive 时继承 FArchive 后主要重写 Serialize(void* Data, int64 Num) 这个最底层的字节读写函数,上层的 operator<< 重载最终都会汇聚到它。

自定义 Archive 的最小例子

想把对象序列化成自定义格式(比如 JSON 导出工具),骨架是:

class FMyJsonArchive : public FArchive
{
public:
    virtual void Serialize(void* Data, int64 Num) override
    {
        // 把 Num 字节按当前模式写入/读出内部 buffer
    }
    virtual bool IsSaving() const override { return true; }
    // ... 其余 flag 按场景返回
};

// 使用
FMyJsonArchive Ar;
MyObject->Serialize(Ar);  // 走 FProperty 链,数据流进你的 Archive

编辑器里的不少工具(复制粘贴、diff)就是靠自定义 Archive 实现的——FEdGraphUtilities 的节点复制就是把选中节点序列化到内存 Archive,再反序列化成新节点。

实战结论

  • 自定义序列化格式时优先用 Tagged 模式(继承 FArchive 并重写 ShouldSkipProperty),兼容性远好于裸 Binary。
  • SaveGame 里加字段必须同步加版本号判断,否则旧存档会数据错乱。
  • UPROPERTY(SaveGame) 说明符只是标记,不自动触发序列化——真正驱动序列化的是 Serialize 函数里的 Ar << 调用。
  • 资产文件里的 Tagged 格式让"引擎升级后旧资产还能打开"成为可能,但反向不成立:新版本存的资产,旧版本引擎打不开(版本号检查会拦)。

FArchive 分层:同一 Serialize 驱动三种场景 UObject::Serialize(FArchive&) 资产加载 (.uasset) Tagged 模式,FPropertyTag SaveGame 存档 Binary 模式,FMemoryReader 网络复制 FNetSerializeCB,增量序列化 Tagged:属性带 Tag(名字+类型+大小),格式自描述,跨版本兼容。 Binary:裸内存布局,快但刚性,类定义变更即失效。 两者共用同一个 Serialize 虚函数,由具体 Archive 决定行为。