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 directory
Mobile: 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/
作者
m4doka
发布于
2026-08-16
许可协议
CC BY-NC-SA 4.0