站内已有一篇小游戏开发综述 ,那篇是平台能力综述,把存储当成「注意容量」的一句话;本章给的是可跑、可断言的存储抽象层与迁移器——把容量、淘汰、降级、对账写成能跑测试的代码。另有《游戏服务端编程实践》 那 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 的迁移链,和断言「旧存档能升到最新且不丢数据」的测试。
阅读导航:上一节:物理手感调参 · 下一节:存档结构与版本迁移 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。