开篇:那个让我通宵三天的存档Bug

凌晨四点,测试妹子第17次冲进办公室:“Boss战第二阶段存档后,角色直接卡进墙里,背包里的传说武器全变成了白板!”我盯着屏幕上的SaveGame对象,脑子里只有一个念头——为什么序列化出来的数据,和内存里的对象长得不一样?

如果你也在用UE5的SaveGame系统,并且天真地以为“UObject序列化就是自动的”,那今天这篇文章就是为你写的。我花了五年时间,在十几个商业项目里反复踩坑,总结出8个最容易翻车的点。每个坑都附带现象、根因、解决方案和预防措施,全是血泪换来的。

先上结论:SaveGame序列化不是“写个变量就完事”,它涉及UObject的反射系统、属性标记、引用图遍历和版本兼容策略。不懂底层原理,你的存档系统迟早会炸。

UE5存档系统避坑指南:SaveGame序列化,我踩过的8个血泪坑 - 配图1
UE5存档系统避坑指南:SaveGame序列化,我踩过的8个血泪坑

坑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,不存指针。

UE5存档系统避坑指南:SaveGame序列化,我踩过的8个血泪坑 - 配图2
UE5存档系统避坑指南:SaveGame序列化,我踩过的8个血泪坑

坑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时,确保数据已经拷贝。

UE5存档系统避坑指南:SaveGame序列化,我踩过的8个血泪坑 - 配图3
UE5存档系统避坑指南:SaveGame序列化,我踩过的8个血泪坑

坑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。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。