《Cocos 游戏开发入门》12.2 序列化格式、版本与迁移

存档是玩家最舍不得丢的数据,但字段会随版本增删。本节为 mini2d 设计带版本号的存档结构,实现 v1→v2→v3 的迁移函数链、字段增删的兼容策略,以及校验和损坏恢复;末节对照 Cocos Creator 3.8 的 sys.localStorage 读写与 JSON 序列化。

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 本地存储与云存档冲突 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画