把存档当生产系统设计:官方持久化最佳实践精读
存档不是普通变量,它是要陪玩家活很多年的生产数据。Epic 专门写了一页《Verse Persistence Best Practices》,本文把它拆成四个可执行的设计原则,外加一份上线前检查清单。
一、加载失败 = 拒之门外:先理解这条保护线
官方文档里最容易被略读、却最能改变你设计思路的一句话是:玩家加入之前,引擎会先加载他的持久化数据;如果加载失败,这位玩家会被禁止加入。乍看很粗暴——为什么不让他用默认值先玩着?官方的解释是:这是防止存档被覆盖的保护措施。想象一下,如果允许「加载失败就当新玩家」,那么一次网络抖动就可能让老玩家以空白存档进场,你的代码兢兢业业地把「金币 0」写回去——真存档就这样被合法地冲掉了。
对创作者的推论有两条。第一,你不需要(也不应该)为「存档半加载」状态写防御代码:只要玩家在场,他的数据就是完整加载过的,读不到某位玩家的记录只意味着「他是新玩家」,而不是「加载坏了」。第二,存档结构越稳健、越小,加载失败的概率就越低——这直接引出下一节的体积预算。
二、256 KB 的预算怎么花:控体积、降频率
每位玩家在单个持久化变量下的记录上限是 256 KB,超出则保存失败并抛出 Verse 运行时错误。256 KB 听着不小,但「把整局的事件日志塞进存档数组」这种写法可以在几十局内把它吃穿。官方建议是两手抓:
- ▢ 控体积:存「结论」,不存「过程」。排行榜需要的是最好成绩,不是每一局的成绩数组;任务系统需要的是当前进度,不是完整操作历史。上限是硬的,超了就是运行时错误。
- ▢ 降频率:在关键节点写入(回合结束、购买完成、任务达成),不要在类似 Event Tick 的每帧循环里一直往存档里 Set。写存档不是免费操作,像每帧那样高频写既浪费也增加出错面。
# ✗ 反面教材:每帧把位置塞进存档数组
# loop:
# Sleep(0.0)
# if (set TravelLog[Player] = ...) {} # 高频写入 + 体积无限增长
# ✓ 正确姿势:回合结束时,一次性写入"结论"
OnRoundEnd(Player:player, RoundScore:int):void =
var Best:int = RoundScore
if (Old := BestScore[Player], Old > RoundScore):
set Best = Old
if (set BestScore[Player] = Best) {}
读图翻译:下半段的 OnRoundEnd 就是「回合一结束才写一次」的正确姿势——先把本局分数 RoundScore 放进一个变量 Best;再用一个 Branch 查这位玩家的历史最好成绩,如果查到了、而且旧成绩比这局还高,就把 Best 换回旧的(保留更高分);最后用一个 Set 把 Best 写回存档表。整套逻辑只在回合结束时跑一次,不在每帧里连。
三、给未来留门:class 是唯一会长大的容器
基础课第五节讲过发布锁定:发布那一刻,存档表里「每格放什么类型」就参加了终身制的向后兼容检查。最佳实践页把这条规则升格成设计原则:凡是可能随版本演化的数据,从第一天起就用 class(蓝图类)来装,而不是光秃秃塞一个 int、struct 或 tuple。因为在所有可持久化类型里,只有 class 支持「发布后再追加带默认值的字段」——光存一个 int,想日后升级成「int + 时间戳」?没有通道,只能另开一张存档表,而每个项目的存档表数量有硬上限,这份预算比你想象中珍贵。
官方的 Speedway Race 模板就是这条原则的活例子:它用一个持久化的统计类通过 PlayerStatsMap 保存比赛成绩,后来官方给模板加新功能时,靠的正是「追加带默认值的字段」这条演化通道。理论在最佳实践页,实例在模板里,两边对照着读收获最大。
四、上线前检查清单
把整页最佳实践浓缩成四问,发布前逐条打钩:
- ▢ 类型选型:会演化的数据都包进「持久化蓝图类」(class<final><persistable>)了吗?有没有图省事、直接拿一个光秃秃的 int / tuple 当存档格类型的侥幸?
- ▢ 版本预案:未来最可能加的字段想过了吗?字段默认值是否让「旧存档玩家」的体验合理?
- ▢ 失败路径:所有读、写存档都套在失败上下文(Branch 那样的地方)里了吗?「查不到记录」走的是不是「当新玩家、给初始值」那条分支?
- ▢ 体积与频率:最坏情况下单条记录多大?写入是否只发生在关键节点?
某玩家进场时,他的持久化数据加载失败了。按官方设计,会发生什么?
来源
本文整理自 Epic 官方文档与官方博客: