8.1 本地存储与云存档抽象

小游戏要在浏览器与多个小游戏宿主上跑,存储能力各不相同:有的无上限,有的只有几 MB 且会静默丢写。本节给出一层可断言的 KV 抽象——内存、限额、降级到最小存档三种实现,容量按整数字节记账、超限按优先级淘汰,云存档只按整数版本号对账取新者。

站内已有一篇小游戏开发综述 ,那篇是平台能力综述,把存储当成「注意容量」的一句话;本章给的是可跑、可断言的存储抽象层与迁移器——把容量、淘汰、降级、对账写成能跑测试的代码。另有《游戏服务端编程实践》 那 43 篇讲的是服务端权威逻辑与协议,本章讲的是客户端侧:本地存储降级、存档迁移,以及哪些校验必须在客户端做、哪些必须交给服务端。两者分工明确——客户端校验为什么不能替代服务端权威,8.3 会用一段可跑的仿真把这件事算清楚。

从这一章起,《合成小岛》开始碰「会留在磁盘上的数据」。这件事看起来简单:一个存档对象,JSON.stringify 存进去,读出来 JSON.parse。但它其实是这一卷里最容易埋雷的地方——因为存进去的代码和读出来的代码可能隔着好几个版本,而且存的地方不止一处。

三种宿主,三种存储脾气

小游戏要跑在浏览器、微信小游戏、抖音小游戏三种宿主上(具体差异见 6.3 的矩阵),它们的存储能力并不一样。浏览器给的是 localStorage,同步、容量通常几 MB、写满会抛异常;小游戏宿主给的是自己的 setStorageSync 一类接口,容量更小,而且写失败时可能只是静默返回、不抛错。这意味着「写存档」这个动作在三处的失败语义都不一致。

如果游戏逻辑直接调宿主的存储 API,那么「容量写满了怎么办」这件事就会散落在几十处调用点里,每处都要判断一次,且很难测。更麻烦的是云存档:本地存一份、云端存一份,玩家换设备登录时两边可能都有数据,谁新谁旧?如果比时间戳,玩家改一次设备时间就能让旧存档「变新」。

所以正确的做法不是「写一个存存档的函数」,而是先定义一个存储抽象,再让三处实现去满足它。这就是本节要做的:一个 KV 接口,三种实现,加一层对账。

存储抽象:一个接口,三种实现

下面这段是 P 层内核代码,定义 KV 接口与三种实现:memoryKV(无上限,底座)、quotaKV(模拟 localStorage 的有限容量,按整数字节记账)、degradeKV(配额也放不下时降级到最小存档)。文件还给出云存档对账函数 pickNewer。

// file: core/src/e08/storage.ts
// tier: P
// verify: node --test 'core/test/e08_*.test.ts'
//
// 本地存储与云存档抽象:一个 KV 接口 + 三种实现(内存 / 限额模拟 / 降级最小存档),
// 外加一层云存档对账(按整数版本号取新者)。全整数、无浮点、无 Math.random()。

export interface KV {
  get(key: string): string | null;
  set(key: string, value: string): boolean;
  remove(key: string): boolean;
  keys(): string[];
  usedBytes(): number;
}

/** UTF-8 字节数:整数计数,多字节码点按 2/3/4 累加。 */
export function utf8Bytes(s: string): number {
  let n = 0;
  for (const ch of s) {
    const cp = ch.codePointAt(0) as number;
    if (cp <= 0x7f) n += 1;
    else if (cp <= 0x7ff) n += 2;
    else if (cp <= 0xffff) n += 3;
    else n += 4;
  }
  return n;
}

/** 一条记录的字节数 = 键字节 + 值字节。 */
export function entryBytes(key: string, value: string): number {
  return utf8Bytes(key) + utf8Bytes(value);
}

interface Entry { value: string; priority: number; seq: number; }

/** 内存 KV:无容量上限,作为底座与测试基线。 */
export function memoryKV(): KV {
  const map = new Map<string, string>();
  let bytes = 0;
  return {
    get: (k) => (map.has(k) ? (map.get(k) as string) : null),
    set: (k, v) => {
      const old = map.get(k);
      if (old !== undefined) bytes -= entryBytes(k, old);
      map.set(k, v);
      bytes += entryBytes(k, v);
      return true;
    },
    remove: (k) => {
      const old = map.get(k);
      if (old === undefined) return false;
      bytes -= entryBytes(k, old);
      map.delete(k);
      return true;
    },
    keys: () => [...map.keys()],
    usedBytes: () => bytes,
  };
}

export interface QuotaOpts {
  readonly capacityBytes: number;
  readonly priorityOf: (key: string) => number;
}

export interface QuotaKV extends KV {
  evicted(): string[]; // 至今被淘汰的键,按淘汰先后排列
}

/**
 * 限额 KV:容量按整数字节记账。写入放不下时,只淘汰「优先级不高于本次写入」的键
 * (同优先级淘汰最旧的),所以一次低优先级写入不会挤掉高优先级数据;算不拢就整体
 * 放弃、不留副作用(先在内存里定好淘汰名单,再一次性落盘)。
 */
export function quotaKV(opts: QuotaOpts): QuotaKV {
  const map = new Map<string, Entry>();
  const evictedKeys: string[] = [];
  let bytes = 0;
  let seq = 0;
  function commit(k: string, v: string, need: number, freed: number): void {
    bytes = bytes - freed + need;
    map.set(k, { value: v, priority: opts.priorityOf(k), seq: seq++ });
  }
  return {
    get: (k) => {
      const e = map.get(k);
      return e === undefined ? null : e.value;
    },
    set: (k, v) => {
      const need = entryBytes(k, v);
      if (need > opts.capacityBytes) return false; // 单条就超限
      const existing = map.get(k);
      const freed = existing === undefined ? 0 : entryBytes(k, existing.value);
      let projected = bytes - freed + need;
      if (projected <= opts.capacityBytes) {
        commit(k, v, need, freed);
        return true;
      }
      const newPriority = opts.priorityOf(k);
      const candidates: Array<[string, Entry]> = [];
      for (const [key, e] of map) {
        if (key === k || e.priority > newPriority) continue;
        candidates.push([key, e]);
      }
      candidates.sort((a, b) => a[1].priority - b[1].priority || a[1].seq - b[1].seq);
      const toEvict: string[] = [];
      for (const [key, e] of candidates) {
        if (projected <= opts.capacityBytes) break;
        projected -= entryBytes(key, e.value);
        toEvict.push(key);
      }
      if (projected > opts.capacityBytes) return false; // 算不拢,整体放弃
      for (const key of toEvict) {
        bytes -= entryBytes(key, (map.get(key) as Entry).value);
        map.delete(key);
        evictedKeys.push(key);
      }
      commit(k, v, need, freed);
      return true;
    },
    remove: (k) => {
      const e = map.get(k);
      if (e === undefined) return false;
      bytes -= entryBytes(k, e.value);
      map.delete(k);
      return true;
    },
    keys: () => [...map.keys()],
    usedBytes: () => bytes,
    evicted: () => [...evictedKeys],
  };
}

export interface DegradeKV extends KV {
  dropped(): string[]; // 因降级而被丢弃的键
}

/** 降级 KV:写入失败时先丢弃全部非关键键再重试;仍失败才返回 false。 */
export function degradeKV(backing: QuotaKV, essential: (key: string) => boolean): DegradeKV {
  const droppedKeys: string[] = [];
  return {
    get: (k) => backing.get(k),
    set: (k, v) => {
      if (backing.set(k, v)) return true;
      for (const key of backing.keys()) {
        if (key === k || essential(key)) continue;
        if (backing.remove(key)) droppedKeys.push(key);
      }
      return backing.set(k, v);
    },
    remove: (k) => backing.remove(k),
    keys: () => backing.keys(),
    usedBytes: () => backing.usedBytes(),
    dropped: () => [...droppedKeys],
  };
}

export interface SaveEnvelope {
  readonly rev: number;
  readonly payload: string;
}

/** 云存档对账:整数版本号高者胜;同版本偏好本地。两边皆空返回 null。 */
export function pickNewer(local: SaveEnvelope | null, cloud: SaveEnvelope | null): SaveEnvelope | null {
  if (local === null) return cloud;
  if (cloud === null) return local;
  return cloud.rev > local.rev ? cloud : local;
}

接口只有五个方法:get / set / remove / keys / usedBytes。注意 set 返回 boolean 而不是 void——「写成功了没有」必须能问出来,否则容量满了只能靠宿主抛不抛异常来猜。usedBytes() 让上层能算出「还剩多少字节」,为后面的淘汰策略留出空间。

容量记账与淘汰策略

quotaKV 是这个抽象里最有信息量的一段。它做三件事:

第一,按整数字节记账。 utf8Bytes 逐个码点累加:ASCII 1 字节、中文 3 字节、增补平面 4 字节。不用 TextEncoder,也不用浮点,就是为了让「容量还剩多少」这个数字在任何平台上都逐位一致——同一份存档,在浏览器和宿主上占的字节数必须一样,否则降级判断会不同。

第二,淘汰只碰「优先级不高于本次写入」的键。 优先级由调用方通过 priorityOf 给:核心进度(save.core)给高分,可再生的缓存(cache.atlas)给低分。当写入放不下时,算法只淘汰优先级 ≤ 本次写入的键,同优先级里淘汰最早写入的。这条规则的含义是:一次低优先级的缓存写入,永远不会把高优先级的进度挤掉。反过来,如果写的是高优先级数据,它可以合理地挤掉缓存。

第三,失败是原子的。 淘汰名单先在内存里算好(projected 一路递减),如果算到最后还是放不下,就整体 return false,一个键都不删。这一点很容易写错——直觉写法是「边删边试」,结果写入失败时已经删了一半数据,留下一个残缺的存储。原子性在这里不是洁癖,而是「失败后状态仍可用」的前提。

degradeKV 是最后一层保险:当 quotaKV 连淘汰都放不下时(比如要写的键优先级低于所有已有键),它丢弃全部非关键键——由 essential() 判定哪些是「最小存档」——再重试一次。最小存档的意思是:哪怕只剩这几个键,玩家的核心进度也在,游戏能从「残缺但可用」的状态恢复。这比「写入失败、存档回退到上一个版本」要好得多。

云存档对账:只认整数版本号

云存档最难的不是上传下载,而是对账:本地一份、云端一份,用哪一份?pickNewer 的规则只有一句——整数版本号高者胜,同版本偏好本地。这里刻意不比较时间戳:时间戳来自设备时钟,玩家改一次时间就能让旧存档「变新」,而版本号(rev)由存档的写入方在每次保存时自增,改不动。同版本为什么偏好本地?因为本地那一份是「玩家这台设备上最后一次落盘的结果」,云端可能是更早一次上传的、尚未被本地覆盖的旧值。

rev 是整数、单调递增,这一点和 8.2 的迁移链是一套思路:能用整数表达的版本关系,就不要用时间。时间只能用来算间隔(如 8.3 的离线收益封顶),不能用来定先后。

P 层测试:把淘汰与降级钉死

下面这段是 P 层测试,把上面三件事各自钉死:字节记账是整数且正确、超限淘汰按优先级升序(同优先级按写入先后)、降级只在配额淘汰也放不下时才丢非关键键。

// file: core/test/e08_storage.test.ts
// tier: P
// verify: node --test 'core/test/e08_*.test.ts'
//
// 断言:字节记账是整数且正确;超限淘汰按「优先级升序、同优先级按写入先后」;
// 降级只在配额淘汰也放不下时才丢弃非关键键,且关键键始终留存。

import { test } from "node:test";
import assert from "node:assert/strict";

import { memoryKV, quotaKV, degradeKV, utf8Bytes, entryBytes, pickNewer } from "../src/e08/storage.ts";

test("utf8Bytes:ASCII 1 字节、中文 3 字节、增补平面 4 字节", () => {
  assert.equal(utf8Bytes("abc"), 3);
  assert.equal(utf8Bytes("岛"), 3);
  assert.equal(utf8Bytes("𠮷"), 4); // U+20BB7,增补平面
  assert.equal(entryBytes("a", "1"), 2);
});

test("memoryKV:get/set/remove/keys/usedBytes 记账正确", () => {
  const kv = memoryKV();
  assert.equal(kv.get("a"), null);
  assert.equal(kv.set("a", "1"), true);
  assert.equal(kv.usedBytes(), 2); // "a" 1 + "1" 1
  kv.set("a", "123"); // 覆盖:旧值字节先退回
  assert.equal(kv.usedBytes(), 4);
  assert.deepEqual(kv.keys(), ["a"]);
  assert.equal(kv.remove("a"), true);
  assert.equal(kv.remove("a"), false);
  assert.equal(kv.usedBytes(), 0);
});

test("quotaKV:超限淘汰优先级最低者,同优先级淘汰最旧", () => {
  const prio: Record<string, number> = { a: 10, b: 20, c: 30 };
  const kv = quotaKV({ capacityBytes: 30, priorityOf: (k) => prio[k] ?? 0 });
  assert.equal(kv.set("a", "aaaaaaaa"), true); // 1+8 = 9
  assert.equal(kv.set("b", "bbbbbbbb"), true); // 9 → 18
  assert.equal(kv.set("c", "cccccccc"), true); // 9 → 27
  // 把 b 写大:27 - 9(旧 b) + 13 = 31 > 30 → 淘汰 a(优先级 10 < 20)
  assert.equal(kv.set("b", "bbbbbbbbbbbb"), true);
  assert.deepEqual(kv.evicted(), ["a"]);
  assert.equal(kv.get("a"), null);
  assert.deepEqual(kv.keys().sort(), ["b", "c"]);
  assert.ok(kv.usedBytes() <= 30);
});

test("quotaKV:低优先级写入不挤掉高优先级数据,单条超限直接失败", () => {
  const prio: Record<string, number> = { core: 100, board: 50, log: 5 };
  const kv = quotaKV({ capacityBytes: 30, priorityOf: (k) => prio[k] ?? 0 });
  assert.equal(kv.set("core", "aaaaaaaa"), true); // 4+8 = 12
  assert.equal(kv.set("board", "bbbbbbbb"), true); // 5+8 = 13 → 25
  assert.equal(kv.set("log", "dddddddd"), false); // 优先级 5,淘汰不到人 → 失败且无副作用
  assert.deepEqual(kv.evicted(), []);
  assert.equal(kv.usedBytes(), 25);
  assert.equal(kv.set("x", "0123456789abcdefghijklmnopqrstuvwxyz"), false); // 单条超限
});

test("degradeKV:配额淘汰也放不下时,丢弃非关键键保住关键键", () => {
  const prio: Record<string, number> = { core: 100, hud: 50, cache: 1 };
  const quota = quotaKV({ capacityBytes: 20, priorityOf: (k) => prio[k] ?? 0 });
  const kv = degradeKV(quota, (k) => k === "core");
  assert.equal(kv.set("core", "cccc"), true); // 4+4 = 8
  assert.equal(kv.set("hud", "hhhh"), true); // 3+4 = 7 → 15
  assert.equal(kv.set("cache", "xxxx"), true); // 降级:丢弃非关键的 hud 后重试成功
  assert.deepEqual(kv.dropped(), ["hud"]);
  assert.equal(kv.get("core"), "cccc"); // 关键键留存
  assert.ok(kv.usedBytes() <= 20);
});

test("pickNewer:整数 rev 高者胜,同版本偏好本地", () => {
  assert.equal(pickNewer(null, null), null);
  const local = { rev: 3, payload: "L" };
  const cloudNew = { rev: 5, payload: "C" };
  const cloudSame = { rev: 3, payload: "C2" };
  assert.deepEqual(pickNewer(local, cloudNew), cloudNew);
  assert.deepEqual(pickNewer(cloudNew, local), cloudNew);
  assert.deepEqual(pickNewer(local, cloudSame), local); // 同版本取本地
  assert.deepEqual(pickNewer(null, cloudNew), cloudNew);
});

在本机跑它,六个用例全过:

ℹ tests 6
ℹ pass 6
ℹ fail 0

「低优先级写入不挤掉高优先级数据」那条用例值得多看一眼:log 的优先级是 5,低于已有的 core(100)与 board(50),所以它淘汰不到任何键,set 直接返回 false,而 usedBytes() 停在 25 没变——失败没有留下副作用。这正是「原子性」在测试里的样子。

小结与下一步

这一节把「存存档」从一个动作变成了一层可断言的抽象:一个五方法接口、三种实现、一套整数容量的淘汰与降级规则、一条整数版本号的对账规则。它解决的是「存在哪里、放不下怎么办、两份谁新」这三个问题。

但它没解决第四个问题:存进去的格式变了怎么办。今天存档是 {coins, level},明天产品要加 energy、把 coins 改名成 gold、还要把 level 从字符串收紧成整数——那些已经躺在几百万台设备上的旧存档,怎么升到新格式而不丢数据?这正是下一节「存档结构与版本迁移」要做的,也是这一章真正的核心:一个 v1→v2→v3 的迁移链,和断言「旧存档能升到最新且不丢数据」的测试。

阅读导航:上一节:物理手感调参 · 下一节:存档结构与版本迁移 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制