4.3 资源热更新与版本管理

小游戏改一张图、加一个关卡,用户要重下多少,取决于热更新的版本模型。本节用 TypeScript 写一个差异计算内核:版本号按整数分段比较,清单按路径与哈希分类,再据此在「差异下载」与「整包更新」之间做决策,让每次更新要下多少变成可复算的数字。

本节目标:把「这次更新要下多少」算成确定数字——版本号怎么比、清单怎么差、什么时候走补丁、什么时候只能下整包。

站内那套 127 篇的 Phaser 引擎专书 里,资源是启动时一次性 preload 进来的,讲的是引擎的加载流程。本节不碰加载 API,而是讲可跑的资源版本与差异模型:本地清单与远端清单怎么比、哪些文件要重下、补丁与整包怎么选——都能在本机跑出确定结果。

4.3 资源热更新与版本管理

4.3.1 热更新解决的是什么问题

小游戏发布后要持续更新内容:改一张图、加一个关卡、修一段逻辑。如果每次都让用户下整包,包体一大,用户就不更了。热更新要回答的就是一个问题:本地已经有的东西,哪些不用再下。

答案是一份资源清单:每个文件记录路径、内容哈希、体积。本地一份、远端一份,两份一差,就知道这次要下什么。清单要能被版本号寻址——用户端记录「我停在哪个版本」,服务端据此给出「从那个版本到最新版的差异」。

4.3.2 D 层:本地与远端清单

下面这份是 D 层配置:本地清单(版本 1.3.0)与远端清单(版本 1.4.0),外加一个补丁阈值。两份清单有增、有删、有改、有不变,正好覆盖差异分类的四种情况。

{
  "observation": "公开可观察,观察时点 2026-10;版本号与差异清单口径为可配置参数",
  "patchThresholdPct": 60,
  "local": {
    "version": "1.3.0",
    "assets": [
      {"path": "assets/atlas/merge_tiles.png", "hash": "a1c9f0", "sizeKB": 13},
      {"path": "assets/atlas/ui_base.png", "hash": "a2d41b", "sizeKB": 22},
      {"path": "assets/atlas/decor.png", "hash": "aa7788", "sizeKB": 560},
      {"path": "assets/atlas/old_event.png", "hash": "af0001", "sizeKB": 180},
      {"path": "assets/audio/bgm_island.aac", "hash": "a3e7c2", "sizeKB": 1153},
      {"path": "assets/audio/sfx_pack.adpcm", "hash": "a4b105", "sizeKB": 48},
      {"path": "assets/config/levels.json", "hash": "a5f6a3", "sizeKB": 90},
      {"path": "assets/config/items.json", "hash": "a6c2e8", "sizeKB": 12},
      {"path": "assets/fonts/ui.woff2", "hash": "a9330f", "sizeKB": 30},
      {"path": "js/main.js", "hash": "a7aa44", "sizeKB": 120},
      {"path": "js/game.js", "hash": "a8bb55", "sizeKB": 260}
    ]
  },
  "remote": {
    "version": "1.4.0",
    "assets": [
      {"path": "assets/atlas/merge_tiles.png", "hash": "b1c9f0", "sizeKB": 15},
      {"path": "assets/atlas/ui_base.png", "hash": "a2d41b", "sizeKB": 22},
      {"path": "assets/atlas/decor.png", "hash": "aa7788", "sizeKB": 560},
      {"path": "assets/atlas/decor_xmas.png", "hash": "b20099", "sizeKB": 240},
      {"path": "assets/audio/bgm_island.aac", "hash": "a3e7c2", "sizeKB": 1153},
      {"path": "assets/audio/sfx_pack.adpcm", "hash": "b4b105", "sizeKB": 52},
      {"path": "assets/config/levels.json", "hash": "b5f6a3", "sizeKB": 104},
      {"path": "assets/config/items.json", "hash": "a6c2e8", "sizeKB": 12},
      {"path": "assets/fonts/ui.woff2", "hash": "a9330f", "sizeKB": 30},
      {"path": "js/main.js", "hash": "a7aa44", "sizeKB": 120},
      {"path": "js/game.js", "hash": "b8bb55", "sizeKB": 285}
    ]
  }
}

4.3.3 P 层:差异计算内核

下面这段是 P 层内核。它把版本号按 . 切成整数段做比较(注意是整数比较,不是字符串——1.10 大于 1.9),把清单按路径索引后逐条分类成新增/删除/变更/不变,再算出补丁体积与整包体积,最后按阈值在「差异下载」与「整包更新」之间决策。

// file: core/src/e04/hotupdate.ts
// tier: P
// verify: node --test 'core/test/e04_*.test.ts'
// 《合成小岛》资源热更新内核:版本号整数分段比较 + 清单差异计算 + 补丁/整包决策。
// 全整数运算,禁 enum / namespace / Math.random;哈希按字符串比较,版本段按整数比较。

export interface AssetEntry {
  readonly path: string;
  readonly hash: string;
  readonly sizeKB: number;
}
export interface Manifest {
  readonly version: string;
  readonly assets: AssetEntry[];
}
export interface HotUpdateConfig {
  readonly patchThresholdPct: number;
  readonly local: Manifest;
  readonly remote: Manifest;
}
export interface DiffResult {
  readonly added: string[];
  readonly removed: string[];
  readonly changed: string[];
  readonly unchanged: string[];
}
export interface UpdatePlan {
  readonly needed: boolean;
  readonly patchKB: number;
  readonly fullKB: number;
  readonly strategy: "none" | "patch" | "full";
  readonly reasons: string[];
}

/** 版本号按 `.` 切成整数段。非法段记为 0,保证比较不抛异常。 */
export function parseVersion(v: string): number[] {
  return v.split(".").map((seg) => {
    const n = Number.parseInt(seg, 10);
    return Number.isNaN(n) ? 0 : n;
  });
}

/** 整数分段比较:a>b 返回 1,a<b 返回 -1,相等 0。短版本号按缺位 0 补齐。 */
export function compareVersion(a: string, b: string): number {
  const pa = parseVersion(a), pb = parseVersion(b);
  const n = Math.max(pa.length, pb.length);
  for (let i = 0; i < n; i += 1) {
    const x = pa[i] ?? 0, y = pb[i] ?? 0;
    if (x > y) return 1;
    if (x < y) return -1;
  }
  return 0;
}

/** 远端是否比本地新(严格大于)。 */
export function isNewer(local: string, remote: string): boolean {
  return compareVersion(remote, local) > 0;
}

function indexOf(m: Manifest): Map<string, AssetEntry> {
  const map = new Map<string, AssetEntry>();
  for (const a of m.assets) map.set(a.path, a);
  return map;
}

/** 计算本地→远端的差异清单。分类结果各按路径字典序排列,保证可复现。 */
export function diffManifests(local: Manifest, remote: Manifest): DiffResult {
  const lm = indexOf(local), rm = indexOf(remote);
  const added: string[] = [], removed: string[] = [], changed: string[] = [], unchanged: string[] = [];
  for (const [path, ra] of rm) {
    const la = lm.get(path);
    if (!la) added.push(path);
    else if (la.hash !== ra.hash) changed.push(path);
    else unchanged.push(path);
  }
  for (const path of lm.keys()) if (!rm.has(path)) removed.push(path);
  const s = (arr: string[]) => arr.sort();
  return { added: s(added), removed: s(removed), changed: s(changed), unchanged: s(unchanged) };
}

/** 补丁体积 = 需要新增或替换的资产体积之和(远端口径)。 */
export function patchKB(diff: DiffResult, remote: Manifest): number {
  const rm = indexOf(remote);
  let kb = 0;
  for (const path of [...diff.added, ...diff.changed]) kb += rm.get(path)?.sizeKB ?? 0;
  return kb;
}

/** 整包体积 = 远端全部资产体积之和。 */
export function fullKB(remote: Manifest): number {
  let kb = 0;
  for (const a of remote.assets) kb += a.sizeKB;
  return kb;
}

/** 热更新决策:远端不更新则 none;补丁超过整包的阈值百分比则走整包,否则走补丁。 */
export function planUpdate(cfg: HotUpdateConfig): UpdatePlan {
  const { local, remote } = cfg;
  const full = fullKB(remote);
  if (!isNewer(local.version, remote.version)) {
    return { needed: false, patchKB: 0, fullKB: full, strategy: "none",
      reasons: [`本地 ${local.version} 不低于远端 ${remote.version}`] };
  }
  const diff = diffManifests(local, remote);
  const patch = patchKB(diff, remote);
  const limit = Math.floor((full * cfg.patchThresholdPct) / 100);
  const reasons = [
    `新增 ${diff.added.length} / 变更 ${diff.changed.length} / 删除 ${diff.removed.length}`,
    `补丁 ${patch}KB vs 整包 ${full}KB(阈值 ${cfg.patchThresholdPct}% = ${limit}KB)`,
  ];
  const strategy = patch > limit ? "full" : "patch";
  reasons.push(strategy === "full" ? "补丁逼近整包,直接下整包" : "补丁远小于整包,走差异下载");
  return { needed: true, patchKB: patch, fullKB: full, strategy, reasons };
}

4.3.4 P 层测试

下面这段是 P 层测试,读上面那份配置,验证版本比较、差异分类、体积核算与决策分支。

// file: core/test/e04_hotupdate.test.ts
// tier: P
// verify: node --test 'core/test/e04_*.test.ts'
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import {
  parseVersion, compareVersion, isNewer, diffManifests, patchKB, fullKB, planUpdate,
} from "../src/e04/hotupdate.ts";
import type { HotUpdateConfig, Manifest } from "../src/e04/hotupdate.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const cfg = JSON.parse(
  readFileSync(join(HERE, "..", "..", "config", "e04_hotupdate.json"), "utf-8"),
) as HotUpdateConfig;

test("版本号按整数分段比较,而非字符串比较", () => {
  assert.deepEqual(parseVersion("1.4.0"), [1, 4, 0]);
  assert.deepEqual(parseVersion("2.0.1"), [2, 0, 1]);
  assert.equal(compareVersion("1.4.0", "1.3.0"), 1);
  assert.equal(compareVersion("1.3.0", "1.4.0"), -1);
  assert.equal(compareVersion("1.3", "1.3.0"), 0);      // 缺位按 0 补齐
  assert.equal(compareVersion("1.10.0", "1.9.0"), 1);   // 10 > 9(字符串比较会判错)
  assert.equal(isNewer("1.3.0", "1.4.0"), true);
  assert.equal(isNewer("1.4.0", "1.3.0"), false);
  assert.equal(isNewer("1.4.0", "1.4.0"), false);
});

test("差异清单四分类精确,且各按路径字典序", () => {
  const diff = diffManifests(cfg.local, cfg.remote);
  assert.deepEqual(diff.added, ["assets/atlas/decor_xmas.png"]);
  assert.deepEqual(diff.removed, ["assets/atlas/old_event.png"]);
  assert.deepEqual(diff.changed, [
    "assets/atlas/merge_tiles.png", "assets/audio/sfx_pack.adpcm",
    "assets/config/levels.json", "js/game.js",
  ]);
  assert.deepEqual(diff.unchanged, [
    "assets/atlas/decor.png", "assets/atlas/ui_base.png",
    "assets/audio/bgm_island.aac", "assets/config/items.json",
    "assets/fonts/ui.woff2", "js/main.js",
  ]);
});

test("补丁体积 = 新增 + 变更之和;整包 = 远端全量", () => {
  const diff = diffManifests(cfg.local, cfg.remote);
  assert.equal(patchKB(diff, cfg.remote), 696);   // 240 + (15+52+104+285)
  assert.equal(fullKB(cfg.remote), 2593);
});

test("补丁远小于整包时走差异下载", () => {
  const plan = planUpdate(cfg);
  assert.equal(plan.needed, true);
  assert.equal(plan.strategy, "patch");
  assert.equal(plan.patchKB, 696);
  assert.equal(plan.fullKB, 2593);
  assert.equal(plan.reasons.length, 3);
});

test("版本相同则不更新;补丁超过阈值则回退整包", () => {
  const same = planUpdate({ ...cfg, remote: { ...cfg.remote, version: cfg.local.version } });
  assert.equal(same.needed, false);
  assert.equal(same.strategy, "none");

  const a = (hash: string): { path: string; hash: string; sizeKB: number } => ({ path: "a.bin", hash, sizeKB: 100 });
  const local: Manifest = { version: "1.0.0", assets: [a("h1"), { path: "b.bin", hash: "h2", sizeKB: 100 }] };
  const remote: Manifest = { version: "1.1.0", assets: [a("h9"), { path: "b.bin", hash: "h8", sizeKB: 100 }] };
  const big = planUpdate({ patchThresholdPct: 60, local, remote });
  assert.equal(big.patchKB, 200);   // 两项全变
  assert.equal(big.fullKB, 200);
  assert.equal(big.strategy, "full");   // 200 > floor(200*60/100)=120
});

本机实跑输出:

✔ 版本号按整数分段比较,而非字符串比较 (0.977ms)
✔ 差异清单四分类精确,且各按路径字典序 (0.191916ms)
✔ 补丁体积 = 新增 + 变更之和;整包 = 远端全量 (0.126166ms)
✔ 补丁远小于整包时走差异下载 (0.622667ms)
✔ 版本相同则不更新;补丁超过阈值则回退整包 (0.170625ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0

4.3.5 补丁 vs 整包的决策

逐条读这份结果:

  • 版本比较是整数比较。compareVersion("1.10.0", "1.9.0") 返回 1——按字符串比会得出 "1.10" < "1.9" 的错误结论,热更新会判成「不需要更新」。
  • 差异分类四类齐全。远端相对本地:新增 1 个(decor_xmas.png 240KB)、删除 1 个(old_event.png)、变更 4 个、不变 6 个。
  • 补丁 696KB vs 整包 2593KB。补丁体积是「新增 + 变更」之和(240 + 15 + 52 + 104 + 285)。阈值 60% 对应 1555KB,696 远低于它,所以走差异下载。
  • 决策不是拍脑袋。当补丁逼近整包(比如远端几乎全变),下补丁不如直接下整包——测试里用一个两项全变的例子验证了这个回退分支。

需要说明:这里的哈希是占位字符串,真实工程里用内容摘要(如 SHA-256 截断)。本节算的是版本模型的逻辑,不依赖哈希算法本身;换任何哈希,差异分类的结论都一样。

4.3.6 三条可复用判断

  1. 版本号分段整数比较。字符串比较会在 1.10 与 1.9 之间出错,这是热更新最容易踩的坑。
  2. 清单是唯一真源。路径 + 哈希 + 体积三样齐全,才能既算「要不要下」也算「要下多少」。
  3. 补丁要有回退线。差异下载不是永远最优;补丁接近整包时应直接下整包,避免拆包与校验的额外复杂度。

小结

  • 热更新要回答的是「本地已有的哪些不用再下」,靠的是一份按版本寻址的资源清单。
  • D 层配置给出本地/远端清单与补丁阈值;P 层内核做版本整数比较 + 差异四分类 + 补丁/整包决策。
  • 本机实跑:1.3.0 → 1.4.0 新增 1 / 删除 1 / 变更 4 / 不变 6,补丁 696KB、整包 2593KB,走差异下载;5 条测试全过。
  • 哈希口径可替换;本节验证的是版本模型的逻辑,与具体摘要算法无关。

资源管线到这里闭环了:图集打包、音频压缩、热更新版本。下一章转入游戏循环——固定步长、确定性与状态机,那是本卷的另一块核心。

阅读导航:上一节:音频格式与体积权衡 · 下一节:固定步长主循环与确定性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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