11.1 清单与差异计算
卷一 15.1 资产版本治理 从版本库的角度讲了「一份资产该有版本号、该怎么升」;本节换到运行时热更新的角度:客户端手上是一份已发布清单(base),服务器端是一份目标清单(target),热更新要回答的第一个问题不是「怎么下」,而是**「差在哪、要下多少」**——差异是热更新的唯一输入,下哪些文件、下多少字节、能不能分批、回滚要下什么,全由它推出来。传输层(CDN、断点续传、分片)与构建系统(增量编译、缓存)属于基础设施,本节只做清单层的数据结构与判据;资产从 DCC 导出到运行时装载的实现属于 glTF 资产管线 ,本卷不重推。
11.1.1 一条清单条目就是一份契约
清单是 id → 条目 的表。条目只放可判定的字段,每一条都能跨语言复算:
| 字段 | 类型 | 作用 |
|---|---|---|
id | 字符串 | 资产唯一键,跨版本稳定(改名即换 id) |
path | 字符串 | 运行时装载路径,同 id 在不同版本可换路径 |
sizeBytes | 整数 | 下载与预算的字节账 |
hash | 字符串 | 内容摘要,差异判定的唯一依据 |
version | 整数 | 人工可读的版本号,供 11.3 做一致性检查 |
关键是 hash 与 version 的分工:hash 描述内容,version 描述人怎么记这件事。差异只认 hash——这样「改了内容忘了改版本号」不会让更新漏下文件。
11.1.2 样本:base 12 条 / target 13 条
D 层样本 config/ta11_manifest.json 存两份清单(《星屑旅店》一屏大厅 + 一间客房所需的资产),外加更新批次预算与缓存策略。target 相对 base 删了 tex_counter_wood,加了 icon_moon 与 vfx_steam_puff,另有 5 条内容变了;样本里埋了两个版本一致性问题(icon_coin 与 tex_lamp_brass),留给 11.3:
{
"project": "stardust-inn",
"chapter": 11,
"specVersion": 1,
"note": "第 11 章热更新清单样本:base(客户端当前已发布版本)与 target(目标版本)两份清单,外加更新批次预算与缓存策略。所有量都是整数;hash 是 8 位十六进制占位内容摘要(真实管线用内容寻址摘要,这里为可复现用固定串)。",
"updatePolicy": {
"batchBudgetBytes": 65536,
"note": "单批下载字节上限;单个资产超过上限时独占一批(oversize)"
},
"cachePolicy": {
"keying": "content",
"note": "content = 以内容 hash 为缓存键(安全);pathVersion = 以 path:version 为键(版本漏升会喂陈旧内容)"
},
"base": {
"manifestVersion": 41,
"entries": [
{ "id": "scene_inn_lobby", "path": "scenes/scene_inn_lobby.prefab", "sizeBytes": 18432, "hash": "3f1a2b7c", "version": 4 },
{ "id": "ui_hud", "path": "ui/ui_hud.prefab", "sizeBytes": 9216, "hash": "8c2d4e11", "version": 2 },
{ "id": "atlas_ui", "path": "ui/atlas_ui.png", "sizeBytes": 131072, "hash": "aa01ff23", "version": 7 },
{ "id": "icon_coin", "path": "ui/icons/icon_coin.png", "sizeBytes": 2048, "hash": "b7c3d9e0", "version": 1 },
{ "id": "icon_star", "path": "ui/icons/icon_star.png", "sizeBytes": 2048, "hash": "0d5e6f70", "version": 1 },
{ "id": "portrait_innkeeper", "path": "ui/portraits/portrait_innkeeper.png", "sizeBytes": 16384, "hash": "1122aabb", "version": 3 },
{ "id": "furn_lamp_01", "path": "furniture/furn_lamp_01.prefab", "sizeBytes": 12288, "hash": "c4d5e6f7", "version": 5 },
{ "id": "furn_counter_01", "path": "furniture/furn_counter_01.prefab", "sizeBytes": 14336, "hash": "55667788", "version": 2 },
{ "id": "mat_lamp_brass", "path": "materials/mat_lamp_brass.mat", "sizeBytes": 1024, "hash": "99aabbcc", "version": 2 },
{ "id": "mat_counter_wood", "path": "materials/mat_counter_wood.mat", "sizeBytes": 1024, "hash": "ddeeff00", "version": 1 },
{ "id": "tex_lamp_brass", "path": "textures/tex_lamp_brass.png", "sizeBytes": 65536, "hash": "1234abcd", "version": 3 },
{ "id": "tex_counter_wood", "path": "textures/tex_counter_wood.png", "sizeBytes": 65536, "hash": "deadbeef", "version": 3 }
]
},
"target": {
"manifestVersion": 42,
"entries": [
{ "id": "scene_inn_lobby", "path": "scenes/scene_inn_lobby.prefab", "sizeBytes": 18432, "hash": "3f1a2b7c", "version": 4 },
{ "id": "ui_hud", "path": "ui/ui_hud.prefab", "sizeBytes": 9216, "hash": "7a5b3c1d", "version": 3 },
{ "id": "atlas_ui", "path": "ui/atlas_ui.png", "sizeBytes": 131072, "hash": "55ee66ff", "version": 8 },
{ "id": "icon_coin", "path": "ui/icons/icon_coin.png", "sizeBytes": 2048, "hash": "b7c3d9e0", "version": 2 },
{ "id": "icon_star", "path": "ui/icons/icon_star.png", "sizeBytes": 2048, "hash": "91a2b3c4", "version": 2 },
{ "id": "portrait_innkeeper", "path": "ui/portraits/portrait_innkeeper.png", "sizeBytes": 16384, "hash": "1122aabb", "version": 3 },
{ "id": "furn_lamp_01", "path": "furniture/furn_lamp_01.prefab", "sizeBytes": 12288, "hash": "f0e1d2c3", "version": 6 },
{ "id": "furn_counter_01", "path": "furniture/furn_counter_01.prefab", "sizeBytes": 14336, "hash": "55667788", "version": 2 },
{ "id": "mat_lamp_brass", "path": "materials/mat_lamp_brass.mat", "sizeBytes": 1024, "hash": "99aabbcc", "version": 2 },
{ "id": "mat_counter_wood", "path": "materials/mat_counter_wood.mat", "sizeBytes": 1024, "hash": "ddeeff00", "version": 1 },
{ "id": "tex_lamp_brass", "path": "textures/tex_lamp_brass.png", "sizeBytes": 65536, "hash": "9f8e7d6c", "version": 3 },
{ "id": "icon_moon", "path": "ui/icons/icon_moon.png", "sizeBytes": 2048, "hash": "0a1b2c3d", "version": 1 },
{ "id": "vfx_steam_puff", "path": "vfx/vfx_steam_puff.png", "sizeBytes": 8192, "hash": "4e5f6071", "version": 1 }
]
}
}
11.1.3 S 层:差异计算
下面这段是 S 层脚本,读上面的配置,按 id 求并集并分类。差异清单按 id:status:from->to 排序后取 sha256 前 16 位作 digest——这是字符串类,跨语言必须逐位一致:
#!/usr/bin/env python3
# file: sim/ta11_diff.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta11_diff.py
"""11.1 清单差异的 S 层计算器(卷二第 11 章)。
读 config/ta11_manifest.json 的 base 与 target 两份清单,按 id 求并集,判成
added / removed / changed / unchanged。判据:hash 相同即 unchanged(内容寻址下 size 与
version 只是元数据)。全整数、按 id 排序 → 与 TS 逐位一致。落 out/ta11_diff.json;
被 sim/ta11_update.py 复用。
"""
import hashlib, json, os, sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CFG = os.path.join(ROOT, "config/ta11_manifest.json")
OUT = os.path.join(ROOT, "out")
def load_manifests():
"""返回 (cfg, base_map, target_map),map 是 id → 条目。"""
cfg = json.load(open(CFG, encoding="utf-8"))
base = {e["id"]: e for e in cfg["base"]["entries"]}
target = {e["id"]: e for e in cfg["target"]["entries"]}
return cfg, base, target
def classify(aid, base, target):
if aid in base and aid not in target:
return "removed"
if aid in target and aid not in base:
return "added"
if base[aid]["hash"] != target[aid]["hash"]:
return "changed"
return "unchanged"
def diff_maps(base, target):
"""按 id 排序的差异条目。"""
rows = []
for aid in sorted(set(base) | set(target)):
dst = target.get(aid) or base[aid]
rows.append({"id": aid, "status": classify(aid, base, target),
"fromHash": base[aid]["hash"] if aid in base else "",
"toHash": target[aid]["hash"] if aid in target else "",
"sizeBytes": dst["sizeBytes"]})
return rows
def summarize(rows, base, target):
"""汇总成整数账。download 只含 added + changed(removed 无需下载)。"""
counts = {"added": 0, "removed": 0, "changed": 0, "unchanged": 0}
for r in rows:
counts[r["status"]] += 1
dl = [r["id"] for r in rows if r["status"] in ("added", "changed")]
rm = [r["id"] for r in rows if r["status"] == "removed"]
download_bytes = sum(target[i]["sizeBytes"] for i in dl)
reclaim_bytes = sum(base[i]["sizeBytes"] for i in rm)
base_total = sum(e["sizeBytes"] for e in base.values())
target_total = sum(e["sizeBytes"] for e in target.values())
lines = sorted(f"{r['id']}:{r['status']}:{r['fromHash']}->{r['toHash']}" for r in rows)
digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()[:16]
return {"counts": counts, "downloadIds": dl, "removedIds": rm, "downloadCount": len(dl),
"downloadBytes": download_bytes, "reclaimBytes": reclaim_bytes,
"baseTotalBytes": base_total, "targetTotalBytes": target_total,
"downloadPermille": download_bytes * 1000 // target_total, "lines": lines, "digest": digest}
def main() -> int:
cfg, base, target = load_manifests()
rows = diff_maps(base, target)
s = summarize(rows, base, target)
report = {"project": cfg["project"], "baseManifestVersion": cfg["base"]["manifestVersion"],
"targetManifestVersion": cfg["target"]["manifestVersion"], "unionCount": len(rows),
"counts": s["counts"], "downloadIds": s["downloadIds"], "removedIds": s["removedIds"],
"downloadCount": s["downloadCount"], "downloadBytes": s["downloadBytes"],
"reclaimBytes": s["reclaimBytes"], "baseTotalBytes": s["baseTotalBytes"],
"targetTotalBytes": s["targetTotalBytes"], "downloadPermille": s["downloadPermille"],
"digest": s["digest"], "entries": rows}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "ta11_diff.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2, sort_keys=True)
fh.write("\n")
c = s["counts"]
print(f"union={len(rows)} added={c['added']} removed={c['removed']} "
f"changed={c['changed']} unchanged={c['unchanged']}")
print(f"downloadCount={s['downloadCount']} downloadBytes={s['downloadBytes']} "
f"reclaimBytes={s['reclaimBytes']}")
print(f"baseTotal={s['baseTotalBytes']} targetTotal={s['targetTotalBytes']} "
f"downloadPermille={s['downloadPermille']}")
print(f"digest={s['digest']}")
print("wrote out/ta11_diff.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/ta11_diff.py:
union=14 added=2 removed=1 changed=5 unchanged=6
downloadCount=7 downloadBytes=230400 reclaimBytes=65536
baseTotal=338944 targetTotal=283648 downloadPermille=812
digest=9a64444ed2ea1b1c
wrote out/ta11_diff.json
14 个 id 里只有 7 个要下载,共 230400 字节,占目标包 283648 字节的 812‰。downloadPermille 用整数地板除 downloadBytes * 1000 // targetTotalBytes——整数类,跨语言逐位一致。
11.1.4 P 层:同一套差异的 TS 内核
下面这段是 P 层 TS 内核 core/src/ta11/diff.ts,与上面的 Python 逐条对应——同一个分类顺序、同一套排序与 digest 口径:
// file: core/src/ta11/diff.ts
// tier: P
// verify: node --test 'core/test/ta11_*.test.ts'
// 11.1 清单差异的 P 层内核。与 sim/ta11_diff.py 同一口径:hash 相同即 unchanged,
// size/version 只是元数据不参与判定。全整数、输出按 id 排序 → 与 Python 逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { createHash } from "node:crypto";
export type Entry = { id: string; path: string; sizeBytes: number; hash: string; version: number };
export type DiffRow = { id: string; status: string; fromHash: string; toHash: string; sizeBytes: number };
export function mapById(entries: Entry[]): Map<string, Entry> {
const m = new Map<string, Entry>();
for (const e of entries) m.set(e.id, e);
return m;
}
export function classify(id: string, base: Map<string, Entry>, target: Map<string, Entry>): string {
const inBase = base.has(id);
const inTarget = target.has(id);
if (inBase && !inTarget) return "removed";
if (inTarget && !inBase) return "added";
if ((base.get(id) as Entry).hash !== (target.get(id) as Entry).hash) return "changed";
return "unchanged";
}
export function diffMaps(base: Map<string, Entry>, target: Map<string, Entry>): DiffRow[] {
const ids = [...new Set([...base.keys(), ...target.keys()])].sort();
return ids.map((id) => {
const dst = target.get(id) ?? (base.get(id) as Entry);
return { id, status: classify(id, base, target),
fromHash: base.has(id) ? (base.get(id) as Entry).hash : "",
toHash: target.has(id) ? (target.get(id) as Entry).hash : "",
sizeBytes: dst.sizeBytes };
});
}
export function summarize(rows: DiffRow[], base: Map<string, Entry>, target: Map<string, Entry>) {
const counts: Record<string, number> = { added: 0, removed: 0, changed: 0, unchanged: 0 };
for (const r of rows) counts[r.status] += 1;
const downloadIds = rows.filter((r) => r.status === "added" || r.status === "changed").map((r) => r.id);
const removedIds = rows.filter((r) => r.status === "removed").map((r) => r.id);
const downloadBytes = downloadIds.reduce((n, id) => n + (target.get(id) as Entry).sizeBytes, 0);
const reclaimBytes = removedIds.reduce((n, id) => n + (base.get(id) as Entry).sizeBytes, 0);
const baseTotalBytes = [...base.values()].reduce((n, e) => n + e.sizeBytes, 0);
const targetTotalBytes = [...target.values()].reduce((n, e) => n + e.sizeBytes, 0);
const lines = rows.map((r) => `${r.id}:${r.status}:${r.fromHash}->${r.toHash}`).sort();
const digest = createHash("sha256").update(lines.join("\n"), "utf8").digest("hex").slice(0, 16);
return { counts, downloadIds, removedIds, downloadCount: downloadIds.length, downloadBytes,
reclaimBytes, baseTotalBytes, targetTotalBytes, lines, digest,
downloadPermille: Math.floor((downloadBytes * 1000) / targetTotalBytes) };
}
11.1.5 跨语言对照:digest 逐位一致
P 层测试读的是 Python 落盘的 out/ta11_diff.json(不是同一份源码),做跨语言对照断言:
// file: core/test/ta11_diff.test.ts
// tier: P
// verify: node --test 'core/test/ta11_*.test.ts'
// 11.1 跨语言对照:读 sim/ta11_diff.py 落盘的 out/ta11_diff.json,断言 TS 侧差异清单与
// 摘要与之逐位一致(字符串类)。负例:两份完全相同的清单必须全判 unchanged、下载 0 字节。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { diffMaps, mapById, summarize, type Entry } from "../src/ta11/diff.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta11_manifest.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta11_diff.json"), "utf8"));
const base = mapById(cfg.base.entries as Entry[]);
const target = mapById(cfg.target.entries as Entry[]);
test("跨语言:TS 差异清单 == Python 差异清单(逐位一致)", () => {
const rows = diffMaps(base, target);
assert.deepEqual(rows, py.entries);
const s = summarize(rows, base, target);
assert.deepEqual(s.counts, py.counts);
assert.deepEqual(s.downloadIds, py.downloadIds);
assert.deepEqual(s.removedIds, py.removedIds);
assert.equal(s.downloadBytes, py.downloadBytes);
assert.equal(s.reclaimBytes, py.reclaimBytes);
assert.equal(s.digest, py.digest);
console.log(`diff digest = ${py.digest}`);
});
test("差异计数:14 并集 = added 2 + removed 1 + changed 5 + unchanged 6", () => {
const rows = diffMaps(base, target);
assert.equal(rows.length, 14);
const s = summarize(rows, base, target);
assert.deepEqual(s.counts, { added: 2, removed: 1, changed: 5, unchanged: 6 });
assert.equal(s.downloadBytes, 230400);
assert.equal(s.reclaimBytes, 65536);
assert.equal(s.baseTotalBytes, 338944);
assert.equal(s.targetTotalBytes, 283648);
assert.equal(s.downloadPermille, 812);
});
test("负例:两份相同的清单 → 全 unchanged,下载 0 字节", () => {
const rows = diffMaps(base, base);
const s = summarize(rows, base, base);
assert.deepEqual(s.counts, { added: 0, removed: 0, changed: 0, unchanged: 12 });
assert.equal(s.downloadCount, 0);
assert.equal(s.downloadBytes, 0);
assert.equal(s.downloadPermille, 0);
});
实跑 node --test 'core/test/ta11_*.test.ts'(本节 3 条;本章三节合计 9 条):
diff digest = 9a64444ed2ea1b1c
✔ 跨语言:TS 差异清单 == Python 差异清单(逐位一致)
✔ 差异计数:14 并集 = added 2 + removed 1 + changed 5 + unchanged 6
✔ 负例:两份相同的清单 → 全 unchanged,下载 0 字节
ℹ pass 9
ℹ fail 0
两侧 digest 都是 9a64444ed2ea1b1c——字符串类,逐位一致(不是「容差内一致」;容差那套只适用于色彩派生量,见卷一第 4 章)。
11.1.6 差异判据为什么是 hash 而不是 version
样本里 icon_coin 的 version 从 1 升到 2,但 hash 没变。若差异按 version 判,它会被算成「要下载」——白白下一次内容完全相同的文件。反过来 tex_lamp_brass 的 hash 变了但 version 没升,若按 version 判,它会被算成「不用下载」——客户端永远停在旧内容上。
只有 hash 同时避开这两类错:它直接量的是「内容有没有变」,不依赖人有没有记得改版本号。version 不是没用——它是 11.3 做一致性检查的对象:hash 变了版本没升就报 stale,hash 没变版本升了就报 wasteful。差异计算只认 hash,把版本号的问题交给一致性检查去抓。
阅读导航:上一节:10.3 UI 合批与性能 · 下一节:11.2 增量更新与回滚 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。