12.2 序列化格式、版本与迁移
上一节把游戏阶段管住了,接下来要把玩家的进度存下来。《方块冲刺》上线半年,你会改十几版:加一个道具栏、把「金币」拆成「金币 + 钻石」、删掉一个没人用的字段。而玩家的存档是老版本写下的。如果新版本读到旧存档直接崩,或者把数据读成 undefined,玩家丢的是几十小时的进度——这是最不可原谅的一类 bug。这一节给存档设计一套版本化 + 可迁移 + 可校验的格式,末节对照 Cocos Creator 3.8。
12.2.1 存档结构设计
存档的第一原则:顶层带元信息,业务数据放进独立字段。元信息(版本、时间、校验)由框架管理,业务数据由各系统自己序列化:
export interface SaveFile {
/** 存档格式版本,每次不兼容改动 +1 */
version: number;
/** 写入时刻,毫秒时间戳 */
savedAt: number;
/** 校验和,用于检测损坏 */
checksum: string;
/** 业务数据:各系统一份 */
data: SaveData;
}
export interface SaveData {
player: { name: string; coins: number; gems: number };
progress: { level: number; bestScore: number };
settings: { bgm: number; sfx: number };
}
把 version / savedAt / checksum 放在最外层,是为了让迁移逻辑能在不看业务数据的情况下先跑。如果版本号埋在 data 里面,读到旧存档时你得先解析业务结构才知道版本,而旧结构可能恰恰无法解析——死锁。顶层元信息是这套设计的骨架。
12.2.2 版本号字段:为什么必须有
有人会问:加个字段而已,读不到就取默认值,要版本号干嘛?答案是字段语义的变化。看这三个真实场景:
| 改动 | 加字段能兜住吗 | 为什么需要版本号 |
|---|---|---|
新增 gems 字段 | 能 | 缺省取 0 即可 |
coins 拆成 coins + gems | 不能 | 旧数据得按比例换算,必须知道「这是旧格式」 |
level 从 0 基改成 1 基 | 不能 | 旧值要 +1,否则玩家回到上一关 |
删除 tutorialDone | 能 | 新代码不再读它即可 |
第二、三行就是版本号存在的理由:结构没变、语义变了,这种改动单靠「缺省值」兜不住。约定:version 从 1 开始,任何「旧存档读进来会被解释错」的改动都 +1。纯加字段、纯删字段不 +1,交给缺省值机制处理。
12.2.3 迁移函数链 v1→v2→v3
迁移不是「写一堆 if」,而是一条链:每个函数只负责相邻两版之间的一步,从旧版本一路升到当前版本。这样每一步都可独立测试,新增版本时只加一环:
export interface Migration {
from: number;
to: number;
migrate(raw: Record<string, unknown>): Record<string, unknown>;
}
export const CURRENT_VERSION = 3;
export const MIGRATIONS: Migration[] = [
{
// v1 -> v2:coins 拆成 coins + gems,历史金币的一半换成钻石
from: 1,
to: 2,
migrate(raw) {
const player = (raw.player ?? {}) as Record<string, unknown>;
const coins = Number(player.coins ?? 0);
player.coins = Math.floor(coins / 2);
player.gems = coins - (player.coins as number);
raw.player = player;
return raw;
},
},
{
// v2 -> v3:level 从 0 基改成 1 基
from: 2,
to: 3,
migrate(raw) {
const progress = (raw.progress ?? {}) as Record<string, unknown>;
progress.level = Number(progress.level ?? 0) + 1;
raw.progress = progress;
return raw;
},
},
];
驱动这条链的函数循环查找当前版本对应的下一步,直到到达 CURRENT_VERSION:
export function migrate(raw: Record<string, unknown>): Record<string, unknown> {
let version = Number(raw.version ?? 0);
if (version === 0) {
// 完全没有版本号:当作最早版本,或直接判为损坏
version = 1;
raw.version = 1;
}
while (version < CURRENT_VERSION) {
const step = MIGRATIONS.find((m) => m.from === version);
if (!step) {
throw new Error(`[mini2d] 缺少 v${version} 的迁移函数`);
}
raw = step.migrate(raw);
version = step.to;
raw.version = version;
}
return raw;
}
注意两点:链必须连续。如果玩家从 v1 直接跳到 v3,while 会依次跑 v1->v2、v2->v3,这就是「链」的意义——你永远只需要维护相邻两步。迁移函数必须幂等友好:用 ?? 0 兜底,别假设旧数据里字段一定存在,否则一个残缺存档就会在迁移途中抛异常。
12.2.4 字段增删的兼容策略
除了语义变化,日常更多是纯增删。策略很简单,但要让读的一方负责兜底,而不是写的一方:
- 新增字段:读取时用
??给默认值,绝不假设存在。新字段写成optional。 - 删除字段:读取代码直接不再读它。老存档里多出来的字段留着不管,反序列化时忽略未知键。
- 重命名字段:等价于「新增 + 迁移」,进迁移链,不要在业务代码里写
obj.newName ?? obj.oldName,那会把兼容逻辑散落各处。
export function readSave(raw: Record<string, unknown>): SaveData {
const player = (raw.player ?? {}) as Record<string, unknown>;
const progress = (raw.progress ?? {}) as Record<string, unknown>;
const settings = (raw.settings ?? {}) as Record<string, unknown>;
return {
player: {
name: String(player.name ?? '玩家'),
coins: Number(player.coins ?? 0),
gems: Number(player.gems ?? 0), // v2 才有,老存档取 0
},
progress: {
level: Number(progress.level ?? 1),
bestScore: Number(progress.bestScore ?? 0),
},
settings: {
bgm: Number(settings.bgm ?? 1),
sfx: Number(settings.sfx ?? 1),
},
};
}
注意 readSave 只读它认识的字段,老存档里被删掉的键(比如 tutorialDone)不会出现在返回值里——这比「读完再手动 delete」更稳,因为你根本不会把未知字段带进内存。反方向也一样:序列化时只写 SaveData 里声明的字段,多余的东西不会污染存档。这条「只认白名单」的纪律,让新旧版本之间天然兼容。
12.2.5 校验与损坏恢复
localStorage 里的字符串可能被截断、被别的程序写脏、或被用户手动改。读的时候永远不能信,所以存档带一个校验和。这里用一个纯 TS 的 FNV-1a 哈希,无需任何依赖:
export function checksumOf(text: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(16).padStart(8, '0');
}
export function serializeSave(data: SaveData): string {
const payload = JSON.stringify(data);
const file: SaveFile = {
version: CURRENT_VERSION,
savedAt: Date.now(),
checksum: checksumOf(payload),
data,
};
return JSON.stringify(file);
}
写入端还要防「写到一半断电」。localStorage.setItem 是同步原子操作,但跨设备的云端上传不是——如果上传中途失败,云端可能留下一份半截 JSON。稳妥做法是双槽轮换:存主槽和备份槽,写的时候先写备份、再写主槽,读的时候主槽坏了就用备份:
export function saveWithBackup(store: SaveStore, text: string): void {
const prev = store.read(); // 上一份主存档
if (prev) store.write(prev, 'backup'); // 先备份旧的
store.write(text, 'main'); // 再写新的
}
export function loadWithFallback(store: SaveStore): SaveData | null {
const main = deserializeSave(store.read('main'));
if (main) return main;
const backup = deserializeSave(store.read('backup'));
if (backup) {
console.warn('[mini2d] 主存档损坏,已回退到备份');
return backup;
}
return null; // 两份都没了,用默认存档
}
这里的 SaveStore.read(slot) / write(text, slot) 是带槽位参数的简化接口,落到 localStorage 上就是两个不同的键。双槽的代价是存储翻倍,但存档通常只有几 KB,完全值得。
读取端做三层防御,任一层失败都退回默认存档,而不是把异常抛给上层:
export function deserializeSave(text: string | null): SaveData | null {
if (!text) return null;
try {
const file = JSON.parse(text) as SaveFile;
if (checksumOf(JSON.stringify(file.data)) !== file.checksum) {
console.warn('[mini2d] 存档校验失败,可能已损坏');
return null;
}
const raw = migrate(file as unknown as Record<string, unknown>);
return readSave(raw);
} catch (err) {
console.warn('[mini2d] 存档解析失败:', err);
return null;
}
}
三层是:JSON 能否解析(try)、校验和是否一致(防篡改/截断)、迁移能否完成(防版本断层)。任何一层不过就返回 null,调用方用「默认存档 + 提示玩家」处理。别做「部分恢复」——半截存档比没有存档更危险,因为它可能把 bestScore 读成 0 然后覆盖云端的好成绩。
12.2.6 Cocos 对照:sys.localStorage 与 JSON 序列化
Cocos Creator 3.8 把「读写本地存储」交给了 sys.localStorage。cc.d.ts 里它的类型是 any,注释写明「Web 端等价于 window.localStorage」,在小游戏和原生平台上由引擎转发到对应平台的存储 API:
import { sys } from 'cc';
const SAVE_KEY = 'blockdash.save';
export function saveLocal(text: string): void {
sys.localStorage.setItem(SAVE_KEY, text);
}
export function loadLocal(): string | null {
return sys.localStorage.getItem(SAVE_KEY) as string | null;
}
export function clearLocal(): void {
sys.localStorage.removeItem(SAVE_KEY);
}
getItem / setItem / removeItem 的用法与浏览器一致,所以我们的 serializeSave / deserializeSave 是原样可复用的——这正是把序列化与存储解耦的收益。时间戳可以用 sys.now()(cc.d.ts 里返回毫秒),跨平台比 Date.now() 更稳。Cocos 里读一份结构化配置常用 JsonAsset,但存档是运行时可写的数据,不该走资源系统,直接用 sys.localStorage 存字符串即可。
还有一个平台差异要记牢:sys.localStorage 在不同平台有容量与持久性差异。Web 端有 5MB 上限,某些小游戏平台的存储在清理缓存后可能被回收。所以重要存档要考虑同步到云端——这正是下一节的主题。
阅读导航:上一节:12.1 游戏状态机 · 下一节:12.3 本地存储与云存档冲突 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。