1125 字
6 分钟
UE 存档系统——SaveGame、序列化与版本迁移
一、最简单的存档
UCLASS()class UMySaveGame : public USaveGame { GENERATED_BODY()public: UPROPERTY() int32 PlayerLevel;
UPROPERTY() float TotalPlayTime;
UPROPERTY() TArray<FString> CompletedQuests;};
// 存档UMySaveGame* SaveGame = Cast<UMySaveGame>( UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass()));SaveGame->PlayerLevel = 10;UGameplayStatics::SaveGameToSlot(SaveGame, "Slot1", 0);
// 读档UMySaveGame* Loaded = Cast<UMySaveGame>( UGameplayStatics::LoadGameFromSlot("Slot1", 0));UPROPERTY() 标记的属性自动序列化——和第 4 篇(反射与序列化)里的 FArchive 流程一模一样。SaveGameToSlot 内部走的是 FArchive → 二进制文件。
但是这太简单了。实际问题才开始。
二、存档的四个实际挑战
1 你要存什么
错误做法: 把整个 UWorld 序列化 → 几 GB,加载 30 秒
正确做法: 只存"差异状态"——默认值不存、临时数据不存 ├── 玩家进度(等级、经验、资产) ← 必须 ├── 当前关卡、角色位置 ← 必须 ├── 已完成的任务、解锁的区域 ← 必须 ├── 当前技能的冷却剩余时间 ← 可能 └── UI 的打开状态、相机的当前 FOV ← 通常不存(恢复后重置为默认)2 存档文件在哪里
PC: %LOCALAPPDATA%/GameName/Saved/SaveGames/Console: Platform-specific save directoryMobile: App sandbox document directory
每个 Slot 一个 .sav 文件3 版本迁移——最大的坑
你的游戏更新了,加了新属性。旧存档怎么办?
// v1.0 的存档UCLASS()class UMySaveGame : public USaveGame { UPROPERTY() int32 PlayerLevel; UPROPERTY() float TotalPlayTime;};
// v1.1 加了新属性UCLASS()class UMySaveGame : public USaveGame { UPROPERTY() int32 PlayerLevel; UPROPERTY() float TotalPlayTime; UPROPERTY() int32 PrestigeLevel; // ← 新增!旧存档里没有};UE 的默认行为:新属性取 UPROPERTY() 的默认值。PrestigeLevel 会初始化为 0——旧存档加载不会崩溃。
但有时你需要更复杂的迁移:
// 手动版本管理UCLASS()class UMySaveGame : public USaveGame { UPROPERTY() int32 SaveVersion; // 自己维护一个版本号
virtual void Serialize(FArchive& Ar) override { Super::Serialize(Ar);
if (Ar.IsLoading()) { if (SaveVersion < 2) { // v1 → v2 迁移:把旧的 SeparateGold + SeparateGem 合并到 CurrencyMap CurrencyMap.Add("Gold", OldGold); CurrencyMap.Add("Gem", OldGem); } if (SaveVersion < 3) { // v2 → v3:给所有老玩家送一个补偿礼包 PendingGifts.Add("MigrationGift_v3"); } // 迁移完后更新版本号 SaveVersion = CURRENT_SAVE_VERSION; } }};三、存档策略模式
1 单一存档 vs 多槽位
单一存档(AutoSave): 简单——每局自动覆盖,玩家不需要选 Slot 适合:开放世界 RPG 的单人模式
多槽位: 玩家可以手动选择存哪个位置 适合:RPG、策略游戏UE 的 SaveGameToSlot 的第三个参数 UserIndex 就是槽位编号(0 / 1 / 2…)。
2 增量存档 vs 全量存档
全量:整个 USaveGame 序列化一次 → 简单但大
增量:只存变化的数据 ├── Slot0:基础数据(角色、进度) ├── Slot1_QuestProgress:任务数据(独立文件,更新时只存这个) └── Slot2_Settings:玩家设置大数据量下拆分成多个 Slot 是更好的做法——每次只存变的部分,I/O 更少。
四、异步存档
存档在 Game Thread 上同步写文件会卡帧——文件 I/O 是不可预测的。
// 异步存档——不卡帧FAsyncSaveGameToSlotDelegate SavedDelegate;SavedDelegate.BindLambda([](const FString& SlotName, const int32 UserIndex, bool bSuccess) { if (bSuccess) { UE_LOG(LogTemp, Log, TEXT("存档成功")); }});
UGameplayStatics::AsyncSaveGameToSlot(SaveGameObject, "Slot1", 0, SavedDelegate);异步存档的注意事项:
- SaveGameObject 在回调触发前不能被 GC 回收——需要持有一个引用
- 如果玩家在异步存档过程中退出游戏——需要等待存档完成或取消
- 同一槽位不能并发写——第二次
AsyncSaveGameToSlot前等第一次回调
五、RPG 的存档设计示例
UCLASS()class URPGSaveGame : public USaveGame { // ===== 基础进度(全量存 ===== UPROPERTY() int32 PlayerLevel;
UPROPERTY() int64 Experience; // 总经验
// ===== 任务(增量存 ===== UPROPERTY() TMap<FGuid, FQuestSaveData> QuestStates; // 任务状态
// ===== 赛季信息 ===== UPROPERTY() FString CurrentSeasonId;
UPROPERTY() int32 SeasonRank;
// ===== 设置(独立的 Slot ===== UPROPERTY() float MasterVolume;
UPROPERTY() EGraphicsQuality GraphicsQuality;
// ===== 版本管理 ===== UPROPERTY() int32 SaveVersion = CURRENT_SAVE_VERSION;};
// 存档时机void UQuestSubsystem::OnQuestCompleted() { // 任务完成时自动存档(异步) UGameplayStatics::AsyncSaveGameToSlot(SaveGame, "Progress", 0, ...);}
void UWorldProgressSubsystem::OnRegionUnlocked(FGuid RegionId) { // 解锁新区域时仅更新世界进度存档(增量) UGameplayStatics::AsyncSaveGameToSlot(WorldProgressSave, "WorldProgress", 0, ...);}六、总结
| 概念 | 一句话 |
|---|---|
| USaveGame | 用 UPROPERTY 标记属性 → 自动序列化到二进制文件 |
| Slot 槽位 | 多存档位 + UserIndex——玩家可以选存哪个 |
| 版本迁移 | 手写 Serialize → 检查版本号 → 执行迁移逻辑 |
| 增量存档 | 把不同类别的数据拆到不同 Slot——只存变化的部分 |
| 异步存档 | AsyncSaveGameToSlot——不卡帧,但注意 GC 和并发写 |
存档系统是”做完就行”直到出 bug 的类型。花一点时间设计版本号迁移策略(从 Day 1 就开始)和合理的 Slot 拆分——三个月后的你会感谢现在的你。
UE 存档系统——SaveGame、序列化与版本迁移
https://www.m4doka.xyz/posts/ue/ue-8-save-load/