【UE源码精读-ActionRPG】存档系统02:异步写盘与读档链路

📅 2026/6/20 15:58:31
【UE源码精读-ActionRPG】存档系统02:异步写盘与读档链路
[TOC]导读上一篇SaveInventory把背包从指针态翻译成货号态塞进了GameInstance.CurrentSaveGame的内存缓存最后甩出一句WriteSaveGame()就收工了。这一篇接着走完两段路写盘WriteSaveGame怎么把内存缓存异步刷到磁盘又怎么用一个三态状态机做节流让你连续改 10 次背包最多只写 2 次盘。读档反方向的LoadInventory——把存档里的货号一个个ForceLoadItem回指针重建运行时两张表以及它和 GameInstance 之间那条容易绕晕的启动时序。读完你应该能回答为什么写盘要异步同步写会怎样bSavingEnabled/bCurrentlySaving/bPendingSaveRequested三个 bool 各管什么怎么合起来节流读档时那些空槽是怎么冒出来的老存档没记槽位怎么办BeginPlay、LoadInventory、HandleSaveGameLoaded这几个入口是什么关系阅读前提读过存档 01知道运行时态↔存档态的指针↔货号翻译知道CurrentSaveGame挂在 GameInstance 上。源码范围RPGGameInstanceBase.cppWriteSaveGame/HandleAsyncSave/LoadOrCreateSaveGame/HandleSaveGameLoaded、RPGPlayerControllerBase.cppLoadInventory。引擎版本 4.27.2。一、为什么写盘必须异步磁盘 IO 是慢操作。如果在游戏线程同步写盘玩家每捡一件物品主线程就得卡在那儿等磁盘写完——轻则掉帧重则卡顿一下。移动端存储更慢一次同步写可能卡掉好几帧体验直接崩。所以 ActionRPG 用UGameplayStatics::AsyncSaveGameToSlot——在后台线程写盘游戏线程立刻返回继续跑写完之后通过回调在游戏线程通知你。但异步带来一个新问题写盘期间玩家又改了背包怎么办这就需要节流。二、WriteSaveGame三态节流状态机先认识三个 bool 成员RPGGameInstanceBase.h:94-104boolbSavingEnabled;// 总开关关掉则永远不存演示模式/新角色boolbCurrentlySaving;// 当前是否正有一个异步写盘在飞boolbPendingSaveRequested;// 写盘期间是否又来了新请求只排一个WriteSaveGame的实现RPGGameInstanceBase.cpp:107-126boolURPGGameInstanceBase::WriteSaveGame(){if(bSavingEnabled){if(bCurrentlySaving){// 正在写盘 → 不再发起新的只把待办标记立起来只排一个bPendingSaveRequestedtrue;returntrue;}// 否则占用正在写标记发起一次后台写盘bCurrentlySavingtrue;// 后台线程写盘完成后回调 HandleAsyncSaveUGameplayStatics::AsyncSaveGameToSlot(GetCurrentSaveGame(),SaveSlot,SaveUserIndex,FAsyncSaveGameToSlotDelegate::CreateUObject(this,URPGGameInstanceBase::HandleAsyncSave));returntrue;}returnfalse;}写盘完成后的回调HandleAsyncSavecpp:134-145voidURPGGameInstanceBase::HandleAsyncSave(constFStringSlotName,constint32 UserIndex,boolbSuccess){ensure(bCurrentlySaving);bCurrentlySavingfalse;// 先把正在写放掉if(bPendingSaveRequested)// 写盘期间有人来过 → 补写一次{bPendingSaveRequestedfalse;WriteSaveGame();// 递归再发起一次带上最新数据}}节流是怎么生效的把两段连起来看状态机只有三种情形当前状态来了个WriteSaveGame结果空闲没在写发起后台写盘bCurrentlySavingtrue真写一次正在写只置bPendingSaveRequestedtrue不写排一个待办正在写、待办已置还是只置bPendingSaveRequestedtrue合并不累加关键在第三行待办只有一个 bool不是队列。所以无论写盘期间你又改了多少次背包最多只排一个还需要再写一次。写盘完成时HandleAsyncSave看到待办就用当时最新的CurrentSaveGame再写一遍。效果连续改 10 次背包最多只发生 2 次磁盘 IO——正在飞的那一次 收尾补的那一次。中间 8 次请求全被合并掉了。这就是用三个 bool 实现的极简节流。bSavingEnabled的用途它是总开关。演示模式、或者想让某次游玩永远算新角色不留档把它设falseWriteSaveGame直接返回、什么都不写。读档侧下一节也会看它关掉时连已存在的存档都当不存在。三、LoadInventory货号 → 指针写盘讲完看反方向。LoadInventory在RPGPlayerControllerBase.cpp:266-338把存档态翻译回运行时态boolARPGPlayerControllerBase::LoadInventory(){// ── 第 1 步清空运行时两张表 ──InventoryData.Reset();SlottedItems.Reset();UWorld*WorldGetWorld();URPGGameInstanceBase*GameInstanceWorld?World-GetGameInstanceURPGGameInstanceBase():nullptr;if(!GameInstance){returnfalse;}// ── 第 2 步订阅存档被重载事件只订一次──if(!GameInstance-OnSaveGameLoadedNative.IsBoundToObject(this)){GameInstance-OnSaveGameLoadedNative.AddUObject(this,ARPGPlayerControllerBase::HandleSaveGameLoaded);}// ── 第 3 步按 ItemSlotsPerType 预建所有空槽 ──for(constTPairFPrimaryAssetType,int32Pair:GameInstance-ItemSlotsPerType){for(int32 SlotNumber0;SlotNumberPair.Value;SlotNumber){SlottedItems.Add(FRPGItemSlot(Pair.Key,SlotNumber),nullptr);// 先全填 null}}URPGSaveGame*CurrentSaveGameGameInstance-GetCurrentSaveGame();URPGAssetManagerAssetManagerURPGAssetManager::Get();if(CurrentSaveGame){// ── 第 4 步翻译拥有的物品 货号→指针 ──boolbFoundAnySlotsfalse;for(constTPairFPrimaryAssetId,FRPGItemDataItemPair:CurrentSaveGame-InventoryData){URPGItem*LoadedItemAssetManager.ForceLoadItem(ItemPair.Key);// 货号 → 指针同步加载if(LoadedItem!nullptr){InventoryData.Add(LoadedItem,ItemPair.Value);}}// ── 第 5 步翻译槽位 货号→指针带合法性校验 ──for(constTPairFRPGItemSlot,FPrimaryAssetIdSlotPair:CurrentSaveGame-SlottedItems){if(SlotPair.Value.IsValid()){URPGItem*LoadedItemAssetManager.ForceLoadItem(SlotPair.Value);if(GameInstance-IsValidItemSlot(SlotPair.Key)LoadedItem)// 防止老存档的非法槽位{SlottedItems.Add(SlotPair.Key,LoadedItem);bFoundAnySlotstrue;}}}// ── 第 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/HandleSaveGameLoadedGameInstance 这边负责把存档对象准备好准备好后广播OnSaveGameLoadedNative。ARPGPlayerControllerBase::HandleSaveGameLoadedController 订阅了上面那个广播存档一旦被换掉读档/重置它就再LoadInventory()一次重填背包。GameInstance 侧的存档准备RPGGameInstanceBase.cpp:68-99boolURPGGameInstanceBase::HandleSaveGameLoaded(USaveGame*SaveGameObject){// ...bSavingEnabled 关掉则忽略传入对象CurrentSaveGameCastURPGSaveGame(SaveGameObject);if(CurrentSaveGame){AddDefaultInventory(CurrentSaveGame,false);// 补上新增的默认物品}else{// 没有存档就现造一个并塞入默认背包CurrentSaveGameCastURPGSaveGame(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_LOGPIE 里快速连续捡 3 件物品数日志里实际写盘次数验证节流。在LoadInventory第 4 步加日志打印ForceLoadItem加载出的物品名退出重进观察货号是怎么变回指针的。把 GameInstance 的ItemSlotsPerType某个类型槽数改小造一个超界槽位的旧存档验证第 5 步IsValidItemSlot把它丢弃。验收清单能解释为什么写盘要异步以及同步写盘的后果。能用三个 bool 复述节流状态机并说清连续改 10 次最多写 2 次是怎么来的。能复述LoadInventory的 7 个步骤重点说清空槽从ItemSlotsPerType预建。能解释第 5 步IsValidItemSlot和第 6 步FillEmptySlots各自防的是什么情况。能画出BeginPlay / LoadOrCreateSaveGame / HandleSaveGameLoaded的启动时序并解释为什么要LoadInventory两次。