开篇:别让你的存档系统成为项目埋下的雷
做了十多年UE项目,我见过太多团队在Demo阶段用最粗暴的SaveGameToSlot应付了事,结果上线前发现:存档体积30MB、读档卡3秒、版本更新后老存档全崩。更致命的是,这些问题往往在项目后期才暴露,改造成本极高。存档系统不是“能存就行”的功能模块,它涉及序列化(Serialization)——将内存中的对象状态转换为可持久化字节流的核心机制。UE5的SaveGame建立在FArchive和反射系统之上,理解它,你就能掌控存档的每一个字节。下面这10个技巧,每一条都来自真实项目的血泪教训,请按需取用,但务必吃透原理。

技巧一:用USaveGame子类而非裸结构体,让反射为你打工
一句话核心:始终继承USaveGame,把要存的数据声明为UPROPERTY()。
为什么有用:UE的序列化依赖反射系统(UTrust)。只有被UPROPERTY标记的成员才会被FArchive自动遍历,否则你需要手写Serialize函数,维护成本极高。继承USaveGame还能直接使用UGameplayStatics::SaveGameToSlot/LoadGameFromSlot,省去手动管理文件路径。
怎么操作:
UCLASS()
class USaveGame_MyProject : public USaveGame
{
GENERATED_BODY()
public:
UPROPERTY()
FVector PlayerLocation;
UPROPERTY()
TArray<FItemData> Inventory;
UPROPERTY()
int32 Gold;
// 注意:不要用裸指针或TSharedPtr,反射不认
};
效果展示:读档后所有字段自动还原,无需逐字段赋值;蓝图也能直接Cast到你的SaveGame类并读写属性。
坑点:
USTRUCT必须用USTRUCT(BlueprintType),且成员同样要UPROPERTY(),否则嵌套数据会丢失。
技巧二:用FMemoryWriter+FObjectAndNameAsStringProxyArchive实现运行时快照
一句话核心:不写盘,直接把Actor序列化成字节数组,用于回放、网络同步或临时快照。
为什么有用:SaveGameToSlot最终也是走FArchive,但如果你需要“存档预览”“时间回溯”或“自定义压缩”,直接操作FMemoryWriter更灵活。配合代理归档,可以跳过对象引用,只存路径字符串,避免加载顺序问题。
怎么操作:
TArray<uint8> Snapshot;
FMemoryWriter Writer(Snapshot);
FObjectAndNameAsStringProxyArchive ProxyArchive(Writer, true);
ProxyArchive.ArIsSaveGame = true; // 只序列化标记为SaveGame的UPROPERTY
MyActor->Serialize(ProxyArchive);
效果展示:几KB的字节流即可完整描述一个复杂Actor的状态,适合做撤销/重做栈或帧同步快照。
技巧三:ArIsSaveGame标记——只存“该存”的,体积砍半
一句话核心:给UPROPERTY加上SaveGame说明符,并设置ArIsSaveGame = true,过滤掉临时变量。
为什么有用:默认情况下,FArchive会序列化所有UPROPERTY,包括缓存、UI引用、调试变量。这些数据不仅浪费空间,还可能导致读档时引用失效。开启ArIsSaveGame后,只有标记了SaveGame的属性才会被写入。
怎么操作:
UPROPERTY(SaveGame)
float Health;
UPROPERTY() // 不会被存档
float CachedDamageMultiplier;
在序列化前设置:Archive.ArIsSaveGame = true;
效果展示:一个典型角色存档从B降到B,读档速度提升明显。
技巧四:版本兼容——用CustomVersion做存档迁移
一句话核心:为存档定义自定义版本号,读档时根据版本号执行升级逻辑。
为什么有用:游戏更新后,数据字段增删是常态。没有版本控制,老存档直接崩。UE的FCustomVersionRegistry允许你注册自己的版本,并在Serialize中根据版本分支处理。
怎么操作:
// 注册版本
FCustomVersionRegistry::Get().RegisterCustomVersion(MyGameVersion::Guid, TEXT("MyGameSave"), 1);
// 在SaveGame的Serialize中
int32 Version = 0;
Ar << Version;
if (Version >= 2) { Ar << NewField; }
效果展示:1.0版本的存档在2.0客户端加载时,自动补默认值,玩家无感。
建议:版本号从1开始,且永远不要删除旧分支代码,直到确认不再支持旧存档。

技巧五:压缩与加密——FArchive的SetCompression与SetEncryption
一句话核心:在写盘前套一层压缩/加密归档,体积减70%,防篡改。
为什么有用:纯文本序列化极易被玩家修改(改金币、改属性)。UE提供了FArchiveSaveCompressedProxy和AES加密接口,几行代码就能实现安全存储。
怎么操作:
TArray<uint8> RawData;
FMemoryWriter MemWriter(RawData);
SaveGame->Serialize(MemWriter);
TArray<uint8> Compressed;
FArchiveSaveCompressedProxy Compressor(Compressed, ECompressionFlags::COMPRESS_ZLIB);
Compressor << RawData;
Compressor.Flush();
// 加密
FAES::EncryptData(Compressed.GetData(), Compressed.Num(), Key);
FFileHelper::SaveArrayToFile(Compressed, *FilePath);
效果展示:30MB存档压到6MB,且十六进制编辑器打开全是乱码。
技巧六:异步存档——AsyncSaveGameToSlot与AsyncTask
一句话核心:把序列化和写盘丢到后台线程,主线程零卡顿。
为什么有用:同步SaveGameToSlot在大型存档上会阻塞游戏线程数百毫秒,造成明显卡顿。UE5提供了AsyncSaveGameToSlot,但更彻底的做法是用AsyncTask在后台线程完成序列化+压缩+写文件。
怎么操作:
AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [SaveGame, SlotName]()
{
UGameplayStatics::SaveGameToSlot(SaveGame, SlotName, 0);
// 完成后可广播委托通知主线程
});
效果展示:存档时帧率曲线平稳,无尖峰。
注意:
USaveGame对象在后台线程被访问时,确保没有其他线程同时修改它,否则需加锁或拷贝。
技巧七:用FObjectAndNameAsStringProxyArchive解决对象引用
一句话核心:存档中不要直接存UObject*指针,用代理归档存路径。
为什么有用:对象指针在重新加载后地址改变,直接序列化指针会导致崩溃。代理归档会将对象引用转换为FString路径(如/Game/Blueprints/BP_Item.BP_Item_C),读档时再解析。
怎么操作:
FObjectAndNameAsStringProxyArchive Proxy(Writer, true);
Proxy.ArIsSaveGame = true;
Proxy.ArNoDelta = true; // 避免delta序列化
Actor->Serialize(Proxy);
效果展示:存档中的引用在重新加载后自动指向正确的实例,即使关卡已重载。
技巧八:Serialize中的条件写入——用Ar.IsLoading()做双向逻辑
一句话核心:在Serialize函数中根据Ar.IsSaving()/IsLoading()执行不同代码,实现数据转换。
为什么有用:有时存档格式和运行时格式不同(如枚举值变更、坐标压缩)。在Serialize中做转换,比在加载后统一处理更高效。
怎么操作:
void UMySaveGame::Serialize(FArchive& Ar)
{
Super::Serialize(Ar);
if (Ar.IsSaving())
{
uint8 Compact = static_cast<uint8>(MyEnum);
Ar << Compact;
}
else if (Ar.IsLoading())
{
uint8 Compact; Ar << Compact;
MyEnum = static_cast<EMyEnum>(Compact);
}
}
效果展示:枚举从4字节Int变成1字节Byte,1000个物品省B。
技巧九:存档槽管理与元数据——SaveGameToSlot之外的文件头设计
一句话核心:在存档文件开头写入自定义头(版本、时间戳、玩家名),方便UI展示和校验。
为什么有用:UE自带的槽位文件是二进制流,没有元数据。自己加头后,可以快速读取存档列表而不必反序列化整个存档。
怎么操作:
struct FSaveHeader
{
int32 Version;
FDateTime SaveTime;
FString PlayerName;
int32 DataSize;
};
// 写:先写Header,再写压缩数据
// 读:先读Header,再读DataSize字节
效果展示:存档选择界面瞬间列出所有存档的玩家名和时间,无需加载完整存档。
技巧十:内存快照与回滚——FMemoryReader实现“时光倒流”
一句话核心:用FMemoryReader从字节数组还原对象,实现存档预览、回滚、网络纠错。
为什么有用:在多人游戏中,服务器可以定期序列化世界状态,当客户端预测错误时,发送快照强制回滚。单机游戏也可用此做“检查点”系统。
怎么操作:
TArray<uint8> Snapshot = GetSnapshot();
FMemoryReader Reader(Snapshot);
FObjectAndNameAsStringProxyArchive Proxy(Reader, true);
Proxy.ArIsSaveGame = true;
Actor->Serialize(Proxy);
效果展示:角色位置、血量、背包瞬间回到快照时刻,误差为零。

行业案例:某开放世界项目的存档优化实录
我曾参与一个UE5开放世界项目,初期使用同步SaveGameToSlot,存档体积28MB,读档耗时4.2秒。通过应用上述技巧:ArIsSaveGame过滤去掉60%冗余字段,Zlib压缩再减70%,异步写盘消除卡顿,自定义版本解决更新崩溃。最终存档体积降至3.8MB,读档0.9秒,且支持热更新迁移。关键是,这些改动只花了三天。
在火星人教育的UE5游戏特效与开发课程中,我们常把这类真实项目案例拆解成模块化实训,让学员亲手复现优化过程,而不是停留在API调用层面。
关于火星人教育
火星人教育创办24年来,始终专注数字艺术与AIGC实战职业技能培训。我们开设的课程涵盖:AI漫剧创作、AIGC视觉设计、UE5游戏特效、室内全屋定制、MAYA三维动画、C4D动态设计、工业设计、NUKE影视后期、摄影摄像实训。北京、深圳双校区开设线下面授班,面向美术生、在校大学生及意向转行的职场人群,以项目化实训教学,夯实专业技能,助力就业与职业提升。如果你希望深入掌握UE5底层序列化与性能优化,欢迎咨询:010-65918670。








评论(0)