[TOC]
导读
上一篇 SaveInventory 把背包从"指针态"翻译成"货号态",塞进了 GameInstance.CurrentSaveGame 的内存缓存,最后甩出一句 WriteSaveGame() 就收工了。这一篇接着走完两段路:
- 写盘:
WriteSaveGame怎么把内存缓存异步刷到磁盘,又怎么用一个三态状态机做"节流",让你连续改 10 次背包最多只写 2 次盘。 - 读档:反方向的
LoadInventory——把存档里的货号一个个ForceLoadItem回指针,重建运行时两张表,以及它和 GameInstance 之间那条容易绕晕的启动时序。
读完你应该能回答:
- 为什么写盘要异步?同步写会怎样?
bSavingEnabled/bCurrentlySaving/bPendingSaveRequested三个 bool 各管什么,怎么合起来"节流"?- 读档时那些空槽是怎么冒出来的?老存档没记槽位怎么办?
BeginPlay、LoadInventory、HandleSaveGameLoaded这几个入口是什么关系?
阅读前提:读过存档 01,知道运行时态↔存档态的指针↔货号翻译,知道
CurrentSaveGame挂在 GameInstance 上。源码范围:
RPGGameInstanceBase.cpp(WriteSaveGame/HandleAsyncSave/LoadOrCreateSaveGame/HandleSaveGameLoaded)、RPGPlayerControllerBase.cpp(LoadInventory)。引擎版本 4.27.2。
一、为什么写盘必须异步
磁盘 IO 是慢操作。如果在游戏线程同步写盘,玩家每捡一件物品,主线程就得卡在那儿等磁盘写完——轻则掉帧,重则卡顿一下。移动端存储更慢,一次同步写可能卡掉好几帧,体验直接崩。
所以 ActionRPG 用 UGameplayStatics::AsyncSaveGameToSlot——在后台线程写盘,游戏线程立刻返回继续跑,写完之后通过回调在游戏线程通知你。但异步带来一个新问题:写盘期间,玩家又改了背包怎么办? 这就需要"节流"。
二、WriteSaveGame:三态节流状态机
先认识三个 bool 成员(RPGGameInstanceBase.h:94-104):
bool bSavingEnabled; // 总开关:关掉则永远不存(演示模式/新角色)
bool bCurrentlySaving; // 当前是否正有一个异步写盘在飞
bool bPendingSaveRequested; // 写盘期间是否又来了新请求(只排一个)
WriteSaveGame 的实现(RPGGameInstanceBase.cpp:107-126):
bool URPGGameInstanceBase::WriteSaveGame()
{
if (bSavingEnabled)
{
if (bCurrentlySaving)
{
// 正在写盘 → 不再发起新的,只把"待办"标记立起来(只排一个)
bPendingSaveRequested = true;
return true;
}
// 否则:占用"正在写"标记,发起一次后台写盘
bCurrentlySaving = true;
// 后台线程写盘,完成后回调 HandleAsyncSave
UGameplayStatics::AsyncSaveGameToSlot(
GetCurrentSaveGame(), SaveSlot, SaveUserIndex,
FAsyncSaveGameToSlotDelegate::CreateUObject(this, &URPGGameInstanceBase::HandleAsyncSave));
return true;
}
return false;
}

写盘完成后的回调 HandleAsyncSave(cpp:134-145):
void URPGGameInstanceBase::HandleAsyncSave(const FString& SlotName, const int32 UserIndex, bool bSuccess)
{
ensure(bCurrentlySaving);
bCurrentlySaving = false; // 先把"正在写"放掉
if (bPendingSaveRequested) // 写盘期间有人来过 → 补写一次
{
bPendingSaveRequested = false;
WriteSaveGame(); // 递归再发起一次,带上最新数据
}
}

节流是怎么生效的
把两段连起来看,状态机只有三种情形:
| 当前状态 | 来了个 WriteSaveGame | 结果 |
|---|---|---|
| 空闲(没在写) | 发起后台写盘,bCurrentlySaving=true | 真写一次 |
| 正在写 | 只置 bPendingSaveRequested=true | 不写,排一个待办 |
| 正在写、待办已置 | 还是只置 bPendingSaveRequested=true | 合并,不累加 |
关键在第三行:待办只有一个 bool,不是队列。所以无论写盘期间你又改了多少次背包,最多只排一个"还需要再写一次"。写盘完成时 HandleAsyncSave 看到待办,就用当时最新的 CurrentSaveGame 再写一遍。
效果:连续改 10 次背包,最多只发生 2 次磁盘 IO——正在飞的那一次 + 收尾补的那一次。中间 8 次请求全被合并掉了。这就是用三个 bool 实现的极简节流。
bSavingEnabled的用途:它是总开关。演示模式、或者想让某次游玩"永远算新角色不留档",把它设false,WriteSaveGame直接返回、什么都不写。读档侧(下一节)也会看它:关掉时连已存在的存档都当不存在。
三、LoadInventory:货号 → 指针
写盘讲完,看反方向。LoadInventory 在 RPGPlayerControllerBase.cpp:266-338,把存档态翻译回运行时态:
bool ARPGPlayerControllerBase::LoadInventory()
{
// ── 第 1 步:清空运行时两张表 ──
InventoryData.Reset();
SlottedItems.Reset();
UWorld* World = GetWorld();
URPGGameInstanceBase* GameInstance = World ? World->GetGameInstance<URPGGameInstanceBase>() : nullptr;
if (!GameInstance) { return false; }
// ── 第 2 步:订阅"存档被重载"事件(只订一次)──
if (!GameInstance->OnSaveGameLoadedNative.IsBoundToObject(this))
{
GameInstance->OnSaveGameLoadedNative.AddUObject(this, &ARPGPlayerControllerBase::HandleSaveGameLoaded);
}
// ── 第 3 步:按 ItemSlotsPerType 预建所有空槽 ──
for (const TPair<FPrimaryAssetType, int32>& Pair : GameInstance->ItemSlotsPerType)
{
for (int32 SlotNumber = 0; SlotNumber < Pair.Value; SlotNumber++)
{
SlottedItems.Add(FRPGItemSlot(Pair.Key, SlotNumber), nullptr); // 先全填 null
}
}
URPGSaveGame* CurrentSaveGame = GameInstance->GetCurrentSaveGame();
URPGAssetManager& AssetManager = URPGAssetManager::Get();
if (CurrentSaveGame)
{
// ── 第 4 步:翻译"拥有的物品" 货号→指针 ──
bool bFoundAnySlots = false;
for (const TPair<FPrimaryAssetId, FRPGItemData>& ItemPair : CurrentSaveGame->InventoryData)
{
URPGItem* LoadedItem = AssetManager.ForceLoadItem(ItemPair.Key); // 货号 → 指针(同步加载)
if (LoadedItem != nullptr)
{
InventoryData.Add(LoadedItem, ItemPair.Value);
}
}
// ── 第 5 步:翻译"槽位" 货号→指针,带合法性校验 ──
for (const TPair<FRPGItemSlot, FPrimaryAssetId>& SlotPair : CurrentSaveGame->SlottedItems)
{
if (SlotPair.Value.IsValid())
{
URPGItem* LoadedItem = AssetManager.ForceLoadItem(SlotPair.Value);
if (GameInstance->IsValidItemSlot(SlotPair.Key) && LoadedItem) // 防止老存档的非法槽位
{
SlottedItems.Add(SlotPair.Key, LoadedItem);
bFoundAnySlots = true;
}
}
}
// ── 第 6 步:老存档兜底——没记任何槽位就自动装备 ──
if (!bFoundAnySlots)
{
FillEmptySlots();
}
// ── 第 7 步:广播"背包整体重载" ──
NotifyInventoryLoaded();
return true;
}
// 读档失败也要广播,否则 UI 停在旧状态
NotifyInventoryLoaded();
return false;
}
逐步看关键点:
第 3 步:空槽是这里"生"出来的。 上一篇说过 SaveInventory 会把空槽也写进存档,但即便存档没记槽,LoadInventory 也会先按 GameInstance 的 ItemSlotsPerType 配置,把所有合法槽位全建出来、初值 nullptr。也就是说槽位结构由配置决定,存档只负责往里填东西。

第 4/5 步:ForceLoadItem 是翻译器。 它拿货号去 Asset Manager 同步加载出物品对象(指针)。这是上一篇 GetPrimaryAssetId() 的逆操作。注意它是同步加载——会阻塞,但读档时机通常藏在加载屏后面,可接受(这点和异步加载的取舍后面 UI 篇还会展开)。
第 5 步:IsValidItemSlot 防老存档脏数据。 玩家可能玩的是旧版本存档,里面记着一个现在已经不存在的槽位(比如老版本有 5 个武器槽,新版本砍到 3 个)。IsValidItemSlot 校验槽号是否还在合法范围内,把非法槽位丢弃,避免把脏数据填进运行时表。
第 6 步:老存档兼容兜底。 如果遍历完一个有效槽位都没找到(bFoundAnySlots 仍为 false),说明这是个"只记了拥有、没记装备"的老存档,调 FillEmptySlots() 把拥有的物品自动归位到空槽。
第 7 步:无论成败都要广播。 读档成功广播,失败(CurrentSaveGame 为空)也广播。因为 UI 是靠 NotifyInventoryLoaded 整体重刷的,不广播的话 UI 会卡在上一局的旧画面。
四、启动时序:三个入口怎么串起来
读档最容易绕晕的是"到底谁调谁"。理清三个角色:
ARPGPlayerControllerBase::BeginPlay:控制器开始游戏,第一件事就是LoadInventory()(cpp:404-410)。URPGGameInstanceBase::LoadOrCreateSaveGame/HandleSaveGameLoaded:GameInstance 这边负责"把存档对象准备好",准备好后广播OnSaveGameLoadedNative。ARPGPlayerControllerBase::HandleSaveGameLoaded:Controller 订阅了上面那个广播,存档一旦被换掉(读档/重置),它就再LoadInventory()一次重填背包。
GameInstance 侧的存档准备(RPGGameInstanceBase.cpp:68-99):
bool URPGGameInstanceBase::HandleSaveGameLoaded(USaveGame* SaveGameObject)
{
// ...(bSavingEnabled 关掉则忽略传入对象)
CurrentSaveGame = Cast<URPGSaveGame>(SaveGameObject);
if (CurrentSaveGame)
{
AddDefaultInventory(CurrentSaveGame, false); // 补上新增的默认物品
}
else
{
// 没有存档就现造一个,并塞入默认背包
CurrentSaveGame = Cast<URPGSaveGame>(UGameplayStatics::CreateSaveGameObject(URPGSaveGame::StaticClass()));
AddDefaultInventory(CurrentSaveGame, true);
}
// 通知所有订阅者:存档对象已就绪/已更换
OnSaveGameLoaded.Broadcast(CurrentSaveGame);
OnSaveGameLoadedNative.Broadcast(CurrentSaveGame);
return /* 是否真的从磁盘读到 */;
}
把它和 Controller 串起来,完整时序是:
① Controller::BeginPlay
└─ LoadInventory() ← 第一次填背包(此时存档可能还没就绪)
└─ 订阅 GameInstance.OnSaveGameLoadedNative
② GameInstance::LoadOrCreateSaveGame
└─ 从磁盘读 / 现造一个 URPGSaveGame
└─ HandleSaveGameLoaded
└─ CurrentSaveGame 就绪 + AddDefaultInventory
└─ OnSaveGameLoadedNative.Broadcast()
└─ ③ Controller::HandleSaveGameLoaded
└─ LoadInventory() ← 再填一次,这次数据齐了
为什么要填两次?因为 BeginPlay 和"存档就绪"谁先谁后并不固定。Controller 先 LoadInventory 一遍(可能扑空),同时订阅好事件;等 GameInstance 把存档准备好并广播,Controller 再被回调 LoadInventory 一遍,这次一定能拿到就绪的 CurrentSaveGame。用"先订阅 + 事件回调"消除时序依赖,这是 UE 里非常典型的解耦手法。
AddDefaultInventory的作用:游戏更新后可能新增了默认物品(比如送所有玩家一把新武器)。AddDefaultInventory(SaveGame, false)会把GameInstance.DefaultInventory里"存档还没有的"补进去——老玩家也能拿到新福利,且不覆盖他已有的数据。
五、动手与验收
动手任务
- 在
WriteSaveGame和HandleAsyncSave各加一行UE_LOG,PIE 里快速连续捡 3 件物品,数日志里实际写盘次数,验证"节流"。 - 在
LoadInventory第 4 步加日志打印ForceLoadItem加载出的物品名,退出重进,观察货号是怎么变回指针的。 - 把 GameInstance 的
ItemSlotsPerType某个类型槽数改小,造一个"超界槽位"的旧存档,验证第 5 步IsValidItemSlot把它丢弃。
验收清单
- 能解释"为什么写盘要异步",以及同步写盘的后果。
- 能用三个 bool 复述节流状态机,并说清"连续改 10 次最多写 2 次"是怎么来的。
- 能复述
LoadInventory的 7 个步骤,重点说清"空槽从ItemSlotsPerType预建"。 - 能解释第 5 步
IsValidItemSlot和第 6 步FillEmptySlots各自防的是什么情况。 - 能画出"BeginPlay / LoadOrCreateSaveGame / HandleSaveGameLoaded"的启动时序,并解释为什么要
LoadInventory两次。


被折叠的 条评论
为什么被折叠?



