11.1 清单与差异计算

热更新要下多少字节,取决于两份清单差在哪。本节把客户端已发布的 base 清单与目标 target 清单按 id 求并集,用 hash 判成 added/removed/changed/unchanged 四类,S 层 Python 与 P 层 TS 两侧的差异清单与 digest 逐位一致:并集 14、要下载 7 个资产共 230400 字节、下载占比 812‰。

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 增量更新与回滚 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则