开篇:那个让我通宵三天的存档Bug
凌晨四点,测试妹子第17次冲进办公室:“Boss战第二阶段存档后,角色直接卡进墙里,背包里的传说武器全变成了白板!”我盯着屏幕上的SaveGame对象,脑子里只有一个念头——为什么序列化出来的数据,和内存里的对象长得不一样?
如果你也在用UE5的SaveGame系统,并且天真地以为“UObject序列化就是自动的”,那今天这篇文章就是为你写的。我花了五年时间,在十几个商业项目里反复踩坑,总结出8个最容易翻车的点。每个坑都附带现象、根因、解决方案和预防措施,全是血泪换来的。
先上结论:SaveGame序列化不是“写个变量就完事”,它涉及UObject的反射系统、属性标记、引用图遍历和版本兼容策略。不懂底层原理,你的存档系统迟早会炸。

坑1:UPROPERTY(BlueprintReadWrite) 就能存?太天真了
现象
你定义了一个USTRUCT或者UCLASS,给变量加了UPROPERTY(BlueprintReadWrite),然后SaveGame->SaveGame时发现——这个变量根本没被序列化进去。读档后全是默认值。
根因
UE5的USaveGame序列化走的是FObjectAndNameAsStringProxyArchive或FMemoryWriter配合ArIsSaveGame标记。默认情况下,只有标记了SaveGame说明符的UPROPERTY才会被序列化。BlueprintReadWrite只影响蓝图访问,不影响存档。
解决方案
// 正确写法:必须加 SaveGame 标记
UPROPERTY(BlueprintReadWrite, SaveGame)
int32 PlayerLevel;
// 对于USTRUCT,内部字段也要加
USTRUCT()
struct FPlayerSaveData
{
GENERATED_BODY()
UPROPERTY(SaveGame)
FString PlayerName;
};
预防措施
在项目初期就建立规范:所有需要存档的变量,统一加SaveGame标记。用UCLASS()的USaveGame子类专门管理,不要图省事直接存Actor。
技巧:用
UPROPERTY(SaveGame, meta=(AllowPrivateAccess="true"))保护私有变量,同时保证序列化。
坑2:对象引用存了,读档后全变None
现象
存档里保存了UInventoryComponent*或者AWeapon*引用,读档后这些指针全部变成nullptr,或者指向了错误的临时对象。
根因
UE5的序列化系统在处理对象引用时,默认使用FObjectAndNameAsStringProxyArchive,它会把对象路径存成字符串。但如果对象在存档时没有被正确标记为“可序列化”或者没有稳定的路径,读档时反序列化就会失败。更常见的是:你存了一个动态生成的Actor,它没有固定名称,读档时根本找不到。
解决方案
不要直接存对象指针!存少见标识符(如GUID或自定义ID),读档时通过ID查找对象。UE5提供了FGuid和FUniqueNetId,但最简单的是给每个可存档对象分配一个FName或int32 ID。
// 存档时存ID
UPROPERTY(SaveGame)
FGuid WeaponID;
// 读档时通过ID查找
AWeapon* Weapon = FindWeaponByID(WeaponID);
预防措施
建立全局的UObjectIDManager,在对象生成时注册ID,销毁时注销。存档只存ID,不存指针。

坑3:版本更新后,旧存档直接崩溃
现象
游戏发布补丁,加了一个新字段,玩家读旧存档时游戏崩溃,报错“Serialized data is incompatible”或直接访问越界。
根因
UE5的序列化默认是二进制流,它按顺序读写属性。如果你在USaveGame类里插入了新字段,旧存档的字节流和新代码的读取顺序不匹配,导致解析错位。
解决方案
必须使用版本号 + 自定义序列化。在USaveGame子类里重写Serialize函数,根据版本号决定读哪些字段。
void UMySaveGame::Serialize(FArchive& Ar)
{
Super::Serialize(Ar);
int32 Version = 1;
if (Ar.IsSaving())
{
Ar << Version;
}
else
{
Ar << Version;
}
// 根据版本读不同字段
if (Version >= 2)
{
Ar << NewField;
}
}
预防措施
从第一个版本开始就加版本号,永远不要删字段,只增字段。用Ar.ArIsSaveGame判断是否在存档流程中。
注意:UE5的
FArchive在序列化时默认会跳过Transient和Deprecated属性,但不会帮你处理版本迁移。
坑4:存档体积爆炸,100MB只是起步
现象
一个简单的RPG存档,居然有50MB,玩家抱怨加载慢,云存档同步失败。
根因
你把整个UWorld或者大量UObject直接序列化了。UE5的序列化会递归遍历所有引用,包括纹理、材质、甚至编辑器临时数据。默认的FObjectAndNameAsStringProxyArchive会把对象路径字符串也存进去,非常占空间。
解决方案
只存纯数据,不存对象图。用FMemoryWriter配合FObjectAndNameAsStringProxyArchive,但手动控制哪些属性被序列化。更推荐用USaveGame只存基础类型和USTRUCT。
// 不要这样:直接存Actor
UPROPERTY(SaveGame)
AActor* MyActor; // 会递归序列化整个Actor及其组件
// 应该这样:只存需要的数据
UPROPERTY(SaveGame)
FVector ActorLocation;
UPROPERTY(SaveGame)
FRotator ActorRotation;
预防措施
用SaveGame标记时,只标记必要字段。定期用FArchive的GetTotalSize()检查存档大小,超过1MB就要审查。
坑5:异步存档导致数据竞争和崩溃
现象
在游戏线程外调用UGameplayStatics::SaveGameToSlot,偶尔崩溃,报错“Access violation”,或者存档内容不完整。
根因
UE5的SaveGameToSlot默认是同步的,但如果你在AsyncTask里调用,它会访问游戏线程的对象,而游戏线程可能正在修改这些对象,导致数据竞争。序列化过程不是线程安全的。
解决方案
要么在主线程同步存档,要么用AsyncSaveGameToSlot(UE5.1+)并确保在存档前冻结相关对象。更稳妥的做法:把要存档的数据复制到一个独立的USaveGame对象,然后在异步线程序列化这个副本。
// 复制数据到副本
UMySaveGame* SaveCopy = DuplicateObject(CurrentSave, nullptr);
// 异步序列化副本
AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [SaveCopy]()
{
FMemoryWriter Writer(...);
SaveCopy->Serialize(Writer);
});
预防措施
永远不要在异步线程直接访问游戏线程的UObject。用FGraphEventRef或UE::Tasks::Launch时,确保数据已经拷贝。

坑6:存档加密后,读档失败率飙升
现象
为了防作弊,你给存档加了AES加密,结果部分玩家读档时提示“Corrupted save”,尤其是跨平台时。
根因
UE5的FArchive加密需要字节对齐,而不同平台的FMemoryWriter可能产生不同的字节序或填充。更常见的是:你加密了整个存档流,但USaveGame的头部信息(如版本号)也被加密了,导致解密时无法识别。
解决方案
只加密数据部分,保留头部明文。或者用FCryptoArchive(UE5内置)并确保FArchive的SetCryptoKey在正确位置调用。
// 正确:先写头部,再加密数据
FMemoryWriter Writer(Data);
Writer << Header;
Writer.SetCryptoKey(Key);
Writer << Payload;
预防措施
加密前用FArchive::GetArchiveName()确认流状态。测试时覆盖Windows、Android、iOS三个平台。
坑7:USTRUCT嵌套太深,序列化静默失败
现象
一个嵌套了5层的USTRUCT,存档后读出来,最内层的字段全是默认值,但没有任何报错。
根因
UE5的序列化对USTRUCT有深度限制(默认约10层),超过后FArchive会静默跳过。更隐蔽的是:如果内层USTRUCT没有GENERATED_BODY(),反射系统找不到它,序列化直接忽略。
解决方案
确保每个USTRUCT都有GENERATED_BODY()和USTRUCT()标记。减少嵌套深度,用TArray或TMap扁平化数据。
USTRUCT()
struct FInnerData
{
GENERATED_BODY() // 必须!
UPROPERTY(SaveGame)
int32 Value;
};
USTRUCT()
struct FOuterData
{
GENERATED_BODY()
UPROPERTY(SaveGame)
FInnerData Inner; // 嵌套
};
预防措施
用UStruct::GetCppStructOps()检查结构体是否支持序列化。嵌套超过3层就重构。
坑8:跨平台存档不兼容,玩家怒打差评
现象
PC上存的档,在Xbox上读不了;或者iOS存档到Android后,浮点数精度丢失导致位置偏移。
根因
不同平台的FArchive默认字节序不同(大端/小端),浮点数格式也可能有细微差异。UE5的FMemoryWriter默认使用平台字节序,跨平台时没有自动转换。
解决方案
强制使用网络字节序(大端),或者用FArchive::SetByteSwapping(true)。对于浮点数,用FMath::FloatToHalf或定点数存储。
// 存档时设置字节交换
Writer.SetByteSwapping(true);
// 读档时同样设置
Reader.SetByteSwapping(true);
预防措施
在项目设置里统一SaveGame的字节序。用FPlatformProperties::IsLittleEndian()检测并转换。
总结:存档系统的核心原则
这8个坑,每一个都让我损失过至少一个通宵。总结下来,UE5的SaveGame序列化有三条铁律:
- 显式标记:只有
SaveGame标记的UPROPERTY才会被序列化,别指望自动。 - 版本控制:永远加版本号,永远不删字段,只增不改。
- 数据与对象分离:存ID不存指针,存纯数据不存对象图。
如果你正在做UE5项目,建议把USaveGame子类的Serialize函数重写一遍,手动控制每个字段的读写。虽然麻烦,但这是少见能保证存档系统稳定的方法。
最后一句忠告:测试存档系统时,一定要模拟“旧版本读新存档”和“新版本读旧存档”两种场景,否则上线后必炸。
关于火星人教育
火星人教育创办24年来,一直专注于数字艺术与AIGC实战职业技能培训。我们的课程覆盖AI漫剧创作、AIGC视觉设计、UE5游戏特效、室内全屋定制、MAYA三维动画、C4D动态设计、工业设计、NUKE影视后期、摄影摄像实训等方向。北京、深圳双校区开设线下面授班,面向美术生、在校大学生及意向转行的职场人群,以项目化实训教学,夯实专业技能,助力就业与职业提升。如果你对UE5存档系统或游戏开发有更多疑问,欢迎咨询:010-65918670。








评论(0)