news 2026/6/20 15:58:19

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【UE源码精读-ActionRPG】存档系统02:异步写盘与读档链路

[TOC]

导读

上一篇SaveInventory把背包从"指针态"翻译成"货号态",塞进了GameInstance.CurrentSaveGame的内存缓存,最后甩出一句WriteSaveGame()就收工了。这一篇接着走完两段路:

  1. 写盘WriteSaveGame怎么把内存缓存异步刷到磁盘,又怎么用一个三态状态机做"节流",让你连续改 10 次背包最多只写 2 次盘。
  2. 读档:反方向的LoadInventory——把存档里的货号一个个ForceLoadItem回指针,重建运行时两张表,以及它和 GameInstance 之间那条容易绕晕的启动时序。

读完你应该能回答:

  • 为什么写盘要异步?同步写会怎样?
  • bSavingEnabled/bCurrentlySaving/bPendingSaveRequested三个 bool 各管什么,怎么合起来"节流"?
  • 读档时那些空槽是怎么冒出来的?老存档没记槽位怎么办?
  • BeginPlayLoadInventoryHandleSaveGameLoaded这几个入口是什么关系?

阅读前提:读过存档 01,知道运行时态↔存档态的指针↔货号翻译,知道CurrentSaveGame挂在 GameInstance 上。

源码范围RPGGameInstanceBase.cppWriteSaveGame/HandleAsyncSave/LoadOrCreateSaveGame/HandleSaveGameLoaded)、RPGPlayerControllerBase.cppLoadInventory)。引擎版本 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;}

写盘完成后的回调HandleAsyncSavecpp: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的用途:它是总开关。演示模式、或者想让某次游玩"永远算新角色不留档",把它设falseWriteSaveGame直接返回、什么都不写。读档侧(下一节)也会看它:关掉时连已存在的存档都当不存在。


三、LoadInventory:货号 → 指针

写盘讲完,看反方向。LoadInventoryRPGPlayerControllerBase.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里"存档还没有的"补进去——老玩家也能拿到新福利,且不覆盖他已有的数据。


五、动手与验收

动手任务

  1. WriteSaveGameHandleAsyncSave各加一行UE_LOG,PIE 里快速连续捡 3 件物品,数日志里实际写盘次数,验证"节流"。
  2. LoadInventory第 4 步加日志打印ForceLoadItem加载出的物品名,退出重进,观察货号是怎么变回指针的。
  3. 把 GameInstance 的ItemSlotsPerType某个类型槽数改小,造一个"超界槽位"的旧存档,验证第 5 步IsValidItemSlot把它丢弃。

验收清单

  • 能解释"为什么写盘要异步",以及同步写盘的后果。
  • 能用三个 bool 复述节流状态机,并说清"连续改 10 次最多写 2 次"是怎么来的。
  • 能复述LoadInventory的 7 个步骤,重点说清"空槽从ItemSlotsPerType预建"。
  • 能解释第 5 步IsValidItemSlot和第 6 步FillEmptySlots各自防的是什么情况。
  • 能画出"BeginPlay / LoadOrCreateSaveGame / HandleSaveGameLoaded"的启动时序,并解释为什么要LoadInventory两次。
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/20 15:28:57

嵌入式GUI多语言支持:从编码原理到emWin实战指南

1. 嵌入式GUI多语言支持&#xff1a;从原理到实战的完整指南在开发面向全球市场的嵌入式设备时&#xff0c;无论是工业HMI触摸屏、智能家电的控制面板&#xff0c;还是便携式医疗设备的操作界面&#xff0c;多语言支持都是一个绕不开的核心需求。这不仅仅是把界面上的“OK”按钮…

作者头像 李华
网站建设 2026/6/20 15:01:58

Linux下gpgsplit命令详解:OpenPGP数据包拆分与安全分析实战

1. 项目概述&#xff1a;为什么需要关注gpgsplit&#xff1f; 在Linux的日常运维、安全审计或者软件包管理工作中&#xff0c;我们经常会与GnuPG&#xff08;GNU Privacy Guard&#xff09;打交道。无论是验证软件源签名的完整性&#xff0c;还是加密一封敏感邮件&#xff0c;G…

作者头像 李华
网站建设 2026/6/20 14:59:02

5分钟上手:无需训练的AI换脸工具roop-unleashed终极指南

5分钟上手&#xff1a;无需训练的AI换脸工具roop-unleashed终极指南 【免费下载链接】roop-unleashed Evolved Fork of roop with Web Server and lots of additions 项目地址: https://gitcode.com/gh_mirrors/ro/roop-unleashed 你是否曾想过&#xff0c;只需一张照片…

作者头像 李华
网站建设 2026/6/20 14:57:17

ab融帧视频怎么调色:2026视频融合工作流,5款工具横评

二创与矩阵号为什么总在AB融帧调色上翻车做影视二创或短视频矩阵的团队&#xff0c;经常遇到一个棘手问题&#xff1a;素材拼接后画面色彩割裂。尤其是涉及AB融帧去重时&#xff0c;A素材和B素材的色温、对比度不一致&#xff0c;导致成品看起来像劣质拼接&#xff0c;甚至直接…

作者头像 李华
网站建设 2026/6/20 14:42:31

OpenClaw部署实战:AI工具链落地的最后一公里

1. OpenClaw 是什么&#xff1f;它解决的不是“部署问题”&#xff0c;而是“AI 工具链落地的最后一公里”OpenClaw 这个名字在最近三个月的技术社区里出现频率陡增&#xff0c;但绝大多数人点开 GitHub 仓库后第一反应是&#xff1a;“这到底是个 CLI 工具&#xff1f;还是个 …

作者头像 李华