序列化与存档
自动关联目录:序列化与存档
序列化在 UE 里是同一套机制服务三个场景:存到磁盘(.uasset)、运行时存档(SaveGame)、跨网络复制。三者都依赖 反射 提供的属性描述。
一句话定位:序列化是按"与 CDO 的差异"来存的——所以你改了默认值,旧的存档和资产会跟着变,这是很多"诡异 bug"的根源。
三个场景的区别
| 场景 | 载体 | 序列化什么 | 关键类 |
|---|---|---|---|
| 资产 | .uasset / .umap | UObject 的属性差异 | FPackageSaveContext、FLinkerSave |
| 存档 | .sav | USaveGame 子树 | FObjectAndNameAsStringProxyArchive |
| 网络复制 | 网络包 | 标了 Replicated 的属性 | FNetBitWriter(见 Replication) |
本页只讲前两个;网络复制在 GamePlay / Replication。
FArchive:统一读写接口
FArchive 是读写的抽象:同一个 Serialize 函数,传入写归档就是存,传入读归档就是读。
void UMySave::Serialize(FArchive& Ar) override
{
Super::Serialize(Ar);
Ar << CustomInt;
Ar << CustomStr;
// 自定义结构要自己展开
}| Archive | 用途 |
|---|---|
FMemoryWriter / FMemoryReader | 内存 ↔ 字节流 |
FFileHelper::SaveArrayToFile | 落盘 |
FObjectAndNameAsStringProxyArchive | SaveGame 标准用法 |
FMemoryArchive | 基类,自定义归档从这里派生 |
版本兼容:CustomVersion
存档一定会遇到"旧版本数据,新版本代码"。UE 的做法是给每段结构注册一个 GUID 版本号。
// 1. 定义版本 GUID(每个项目/系统一个,不要复用)
static const FGuid FMySaveVersionGuid(0x12345678, 0x1234, 0x1234, 0x12, 0x34, 0x56, 0x78, 0x9A);
FCustomVersionRegistration GRegisterMySaveVersion(FMySaveVersionGuid, 3, TEXT("MySaveVer"));
// 2. 写:存当前版本
Ar.SetCustomVersion(FMySaveVersionGuid, 3, TEXT("MySaveVer"));
// 3. 读:按版本分支处理
const int32 Ver = Ar.CustomVer(FMySaveVersionGuid);
if (Ver < 2) { /* 老数据迁移 */ }
if (Ver >= 3) { Ar << NewField; }| 规则 | 说明 |
|---|---|
| GUID 一旦发布不能改 | 改了等于换了套版本体系 |
| 版本号只增不减 | 减了旧存档无法判断 |
| 新增字段要有默认值 | 老存档读不到,靠 CDO/默认值兜底 |
| 删除字段不要立刻删代码 | 留一版迁移逻辑再清理 |
最常见的存档事故:直接改了 UPROPERTY 的类型(比如 int32 → FString),旧存档读进来直接崩。正确做法是加新字段 + 迁移,而不是改类型。
SaveGame 实操
UCLASS()
class UMySaveGame : public USaveGame
{
GENERATED_BODY()
public:
UPROPERTY(SaveGame) int32 Level;
UPROPERTY(SaveGame) FString PlayerName;
UPROPERTY(SaveGame) TArray<int32> Inventory;
};
// 存
UMySaveGame* Save = CastChecked<UMySaveGame>(UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass()));
Save->Level = 10;
UGameplayStatics::SaveGameToSlot(Save, TEXT("Slot1"), 0);
// 读
if (USaveGame* Loaded = UGameplayStatics::LoadGameFromSlot(TEXT("Slot1"), 0))
{
UMySaveGame* Data = Cast<UMySaveGame>(Loaded);
}只有标了 SaveGame 的 UPROPERTY 才会被写入。漏标的表现是"存档成功但读回来是默认值",非常难查。
什么能序列化,什么不能
| 能 | 不能 |
|---|---|
UPROPERTY 标记的标量、FString、FName | 裸指针(不标 UPROPERTY) |
UPROPERTY 的 TArray/TMap/TSet | 非 USTRUCT 的自定义类(除非自己实现 Serialize) |
USTRUCT 且成员都是可序列化类型 | 委托(Delegate)、TFunction |
TSubclassOf、FSoftObjectPath | 运行时状态(线程、Socket、句柄) |
需要存"对象的引用"时,存路径而不是指针:
UPROPERTY(SaveGame)
FSoftObjectPath ItemAssetPath; // 存路径,读取时再异步加载直接序列化 UObject* 指针,在不同机器上地址全无意义。
与资产序列化的关系
.uasset 的序列化用的是同一套属性遍历,区别在:
- 资产存的是"与 CDO 的差异",所以改默认值会影响所有已存资产
- 资产支持拆包(Package)、引用其它包(
ImportMap/ExportMap) - 资产的加载与打包流程见 Asset&Pak&Patch
改 UPROPERTY 默认值后一定要重新保存相关资产,否则编辑器里看到的是新的、磁盘里是旧的,行为不一致。
性能
| 手段 | 说明 |
|---|---|
减少 UPROPERTY 字段 | 序列化是逐属性遍历,字段越多越慢 |
大数组用自定义 Serialize | 跳过逐个属性的元数据开销 |
| 异步写盘 | SaveGame 走后台线程,别卡主线程 |
| 控制存档频率 | 每次序列化都是一次全量遍历 |
Transient 标记 | 明确不需要序列化的字段省掉开销 |
// 大块数据自定义序列化,比逐属性快很多
Ar.Serialize(RawData.GetData(), RawData.Num() * sizeof(int32));常见坑
| 坑 | 说明 |
|---|---|
漏标 SaveGame | 存档成功但读回默认值 |
| 直接改字段类型 | 旧存档读取崩溃 |
| 存了裸指针 / 委托 | 读回来是野指针 |
没用 CustomVersion | 无法迁移,只能清档 |
在 Serialize 里调用会触发 GC 的 API | 归档持有对象期间 GC 是危险的 |
| 把运行时状态写进存档 | 读回来是一堆失效句柄 |
依赖 UPROPERTY 的声明顺序 | 序列化按名字走,但迁移时按名字取字段,改名会断 |
| 存档写盘不校验 | 崩溃在写盘中途会产生截断的坏档,要做原子替换(先写临时文件再 rename) |
许可协议:CC BY
作者:Davids
本文链接:https://hustjjd.github.io/7e7cdc1b.html
更新于:2026年10月10日