”模块自治“的存档系统 设计指南
修改于05/12143 浏览开发心得 疑似 AI 合成内容
可结合下方方案一起使用
存档系统设计指南 - TapTap 制造开发心得 - TapTap TapTap 制造论坛
多槽位云端存档系统设计适用于 WASM/移动端场景的云端优先、本地缓存的多槽位存档方案。1. 设计背景与约束| 约束 | 说明 || --------------------- | -----...

https://www.taptap.cn/moment/782744444460860557

大部分游戏的存档系统是这样诞生的:项目初期,有人写了一个 SaveGame() 函数,把几个变量塞进 JSON 存到本地。
能跑。
后来系统越来越多——背包、成就、任务、好友、赛季——每个系统都往这个函数里加几行。
半年后 SaveGame() 变成了三千行的巨型函数,碰一行就可能把整个存档搞坏,没有人敢重构。
这篇文章讲的是:一个存档系统从第一天开始就该长什么样
一个完整的存档系统由四层构成,每一层建立在上一层的基础上:调度、所有权、契约、效率。
大部分存档系统只有第一层。
后面三层做不做、做到什么程度,取决于项目规模和团队纪律。
但如果你在第一天就知道这四层的存在,很多设计选择会自然地往正确的方向走。
调度:每个模块管自己
存档框架的核心是一个注册表。
每个子系统在自己的文件里注册三样东西:怎么存、怎么读、默认值是什么。
框架按注册顺序依次调用,用 pcall 隔离——一个模块出错,其他模块不受影响,出错的模块回退到默认值。
新增一个子系统只需要加一个注册调用,不需要碰框架代码,不需要碰其他模块。
order 字段解决加载顺序依赖——如果背包依赖配置表先加载完,配置表的 order 设小一点就行。
这一层做好了,你就不会有三千行的 SaveGame()。但光有调度还不够。
所有权:数据是模块的局部变量
光有调度还不够。
如果数据仍然挂在一个全局对象上,全代码库任何位置都能直接改,那存档逻辑虽然分散了,数据的混乱程度没变——运行时临时数据会被无脑存进去,变更无法追踪,脏标记无从做起。
数据应该是模块自己的局部变量。
Lua 的 local 变量文件外看不到,不是靠团队规范"请不要直接改全局数据",而是语言层面不让你改。
外部只能通过模块暴露的 API 来读写。
这带来三个好处。
第一,serialize 显式列出要存的字段,模块内部的临时变量不会意外被存进去。
第二,所有写操作都经过模块的 API 入口,可以在入口调 MarkDirty 标脏,存档系统能知道"这个模块的数据变了",这是后面做增量保存的前提。
第三,数据变更可追踪,可以加日志、加断言、加校验。
所有权这一层越早建立越好。
一旦"直接改全局对象"的写法扩散开,再改就要找几十个文件、几百个调用点。
新模块从第一天就把数据放自己手里,旧模块大改的时候顺手迁移,不追求一步到位。
契约:一份 Schema 定义一切
光有 Register 的话,每个模块要自己写防御性检查,自己维护默认值——initDefault 里写一次,deserialize 的 else 分支里再写一次。
模块越多,这些手写防御的写法越不统一,默认值越容易两处不同步。
想知道一个模块的存档格式有哪些字段,得翻三个函数合并。
在 Register 之上封装一层 Define,用一份 schema 声明把这些全替代掉。
每个模块声明自己有哪些字段、每个字段什么类型、默认值是什么。
框架根据 schema 自动做四件事。
默认值填充
——存档里缺字段,用 schema 里的 default 补上,模块拿到的数据一定是完整的。
新玩家初始化时也用同一份 default,不需要单独维护 initDefault,不存在两处默认值不一致的问题。
类型检查
——存档里的某个字段被 bug 写成了错误类型,框架打一行警告并用 default 替换,模块不用自己写防御代码。
版本迁移
——每个模块有自己的 version 号,存档里记录着各模块的版本。
加载时框架比对版本号,自动链式执行迁移函数直到追平。
背包改了格式不需要碰全局版本号,不需要通知其他模块。
新增一个带 default 的字段不需要升版本号(schema 自动补全),字段重命名或结构变更才需要写 migration。
可检视
——schema 本身就是存档格式的文档。
任何人想知道这个模块存了什么,看 schema 就行。
Define 在内部调用 Register,和已有的 Register 模块完全共存。
早期快速试错的模块可以用 Register,格式稳定后迁移到 Define,新模块从一开始就用 Define。
两者共存,不强制一步到位。
效率:只存变了的部分
每 30 秒自动保存一次,不需要每次都全量序列化所有模块。
本地存档差别不大,但云端存档的网络上传远比序列化贵——放置挂机时几十个模块里可能只有货币在涨,其他纹丝不动。
两个层次的优化可以互补使用。
脏标记减少序列化次数。
数据被修改时打标记,保存时只序列化有标记的模块,其他复用上次的快照。
脏标记的前提是数据所有权——所有写操作都经过模块 API,在入口调 MarkDirty。
数据挂在全局对象上,任何代码都能直接改,MarkDirty 就会漏掉。
第二层(所有权)是第四层(效率)的地基。
如果所有权还没到位,脏标记不可靠,跳过这一步,直接做快照对比。
快照对比减少上传次数。
把这次编码的 JSON 和上次成功上传的比较,一样就跳过。
代价是每次保存仍然需要全量序列化(要编码出来才能比较),但对于"序列化快、上传慢"的云端存档场景,省掉网络上传的收益远大于序列化的开销。
如果存档按功能分了几个分组,可以按分组粒度做快照对比,挂机时可能一半的分组都能跳过。
脏标记是代码级粗筛,快照对比是数据级细筛。
脏标记可能误报(标了脏但数据实际没变,比如加了 10 又减了 10),快照对比负责兜底。
两者配合效果最好,但快照对比可以独立工作。
存档浏览器
有 schema 之后,可以自动生成结构化的存档查看工具——每个模块按 schema 结构化展示,标注版本号和分组,遇到异常数据自动告警。
比肉眼翻几千行 JSON 快一个数量级。
更进一步,两个存档之间的 diff:一眼看出什么变了、什么丢了。
开发成本不高——schema 提供结构信息,剩下的就是遍历和比较。
团队小不想维护工具的话,把存档 JSON 和 schema 一起丢给 AI 分析也行,成本接近零。
取舍
这四层不是都必须做。
Schema 有成本,要求先定义数据结构再写业务。
快速试错阶段 schema 会频繁变动,每次变动都要写 migration。
折中方案是早期 Register,稳定后 Define,两者共存。
脏标记有前提,需要数据所有权到位。
代码库里大量直接操作全局对象的话,引入脏标记之前要先解决所有权。
解决不了就用快照对比替代。
模块级版本号有心智负担,几十个模块几十个版本号,每次改字段都要判断要不要升版本号。
判断本身不难(加字段不需要,改结构才需要),但分散在每个模块的开发者头上。
Jam 和原型做到调度就够了。
小团队长线项目做到调度加所有权。
中型项目四层都值得做。
大型项目四层必须做,而且还需要更多——分布式存档、事务性保存、回滚机制等等。
第一天的清单
如果从零开始,按优先级排:
注册式框架,数据归模块所有,
分组策略(云端存档提前规划),
Schema(新模块用 Define),
快照对比,脏标记(等所有权到位后加),
存档浏览器(有余力再做)。
最后
一个好的存档系统不是一个复杂的系统。它是一个边界清晰的系统。
数据归谁管,清晰。
每个模块守好自己的局部变量,通过 API 暴露访问。
数据长什么样,清晰。
一份 schema 声明字段、类型、默认值,不靠代码逻辑隐式定义。
什么时候需要存,清晰。
知道哪些模块变了、哪些分组动了,只碰需要碰的部分。
存档系统是所有子系统里最不容许"先上线再重构"的。
有了线上玩家,每一次格式变更都要写 migration,每一次重构都要向后兼容。
地基越早打好,后面的日子越轻松。



