[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):
boolbSavingEnabled;// 总开关:关掉则永远不存(演示模式/新角色)boolbCurrentlySaving;// 当前是否正有一个异步写盘在飞boolbPendingSaveRequested;// 写盘期间是否又来了新请求(只排一个)WriteSaveGame的实现(RPGGameInstanceBase.cpp:107-126):
boolURPGGameInstanceBase::WriteSaveGame(){if(bSavingEnabled){if(bCurrentlySaving){// 正在写盘 → 不再发起新的,只把"待办"标记立起来(只排一个)bPendingSaveRequested=true;returntrue;}// 否则:占用"正在写"标记,发起一次后台写盘bCurrentlySaving=true;// 后台线程写盘,完成后回调 HandleAsyncSaveUGameplayStatics::AsyncSaveGameToSlot(GetCurrentSaveGame(),SaveSlot,SaveUserIndex,FAsyncSaveGameToSlotDelegate::CreateUObject(this,&URPGGameInstanceBase::HandleAsyncSave));returntrue;}returnfalse;}写盘完成后的回调HandleAsyncSave(cpp:134-145):
voidURPGGameInstanceBase::HandleAsyncSave(constFString&SlotName,constint32 UserIndex,boolbSuccess){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,把存档态翻译回运行时态:
boolARPGPlayerControllerBase::LoadInventory(){// ── 第 1 步:清空运行时两张表 ──InventoryData.Reset();SlottedItems.Reset();UWorld*World=GetWorld();URPGGameInstanceBase*GameInstance=World?World->GetGameInstance<URPGGameInstanceBase>():nullptr;if(!GameInstance){returnfalse;}// ── 第 2 步:订阅"存档被重载"事件(只订一次)──if(!GameInstance->OnSaveGameLoadedNative.IsBoundToObject(this)){GameInstance->OnSaveGameLoadedNative.AddUObject(this,&ARPGPlayerControllerBase::HandleSaveGameLoaded);}// ── 第 3 步:按 ItemSlotsPerType 预建所有空槽 ──for(constTPair<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 步:翻译"拥有的物品" 货号→指针 ──boolbFoundAnySlots=false;for(constTPair<FPrimaryAssetId,FRPGItemData>&ItemPair:CurrentSaveGame->InventoryData){URPGItem*LoadedItem=AssetManager.ForceLoadItem(ItemPair.Key);// 货号 → 指针(同步加载)if(LoadedItem!=nullptr){InventoryData.Add(LoadedItem,ItemPair.Value);}}// ── 第 5 步:翻译"槽位" 货号→指针,带合法性校验 ──for(constTPair<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();returntrue;}// 读档失败也要广播,否则 UI 停在旧状态NotifyInventoryLoaded();returnfalse;}逐步看关键点:
第 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):
boolURPGGameInstanceBase::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两次。