《移动游戏产品的架构·网络、资源与热更新》11.1 差量算法与补丁格式

方舟的总资源 1,840MB、86,000 个文件,每周却只下发平均 45MB 的增量。本节把差量拆成清单、文件、块三个层级,给出补丁格式的字段设计、应用时的原子性与空间约束,并用可运行的 TypeScript 计算补丁体积、节省比例与回退路径。

一次只改了一个数值,为什么要下四十五兆

《潮汐方舟》的总资源是 1,840MB,资源文件数 86,000,分成首包 350MB 和 6 个分包。版本节奏是每周一次增量热更,平均 45MB;每 6 周发一次商店整包,整包差量在 60 到 90MB 之间。下载成功率 98.6%,低端机占 15%,Android 65%、iOS 35%,其中代码热更只走 Android,iOS 只接受资源与配置。

某个周三的战斗数值调整只动了几份文件:一份战斗配置、一张新图标,以及删掉一张过期的活动图。如果客户端把「热更」理解成「重新下载一份完整资源清单对应的内容」,这次更新会被放大成几十兆;更糟的是,86,000 个文件的清单本身就要遍历和校验,低端机上这段时间足够触发一次 ANR。

差量更新要解决的不是「怎么压缩」,而是「怎么只传变化的部分,并且让客户端确信自己拼出来的结果和服务器一致」。这需要回答三个问题:哪些文件变了、文件内部哪些字节变了、拼装后的结果怎么校验。

差量发生在三个层级

差量不是单一算法,而是三层筛选。越往下越省流量,也越依赖内容;越往上越稳,但只能省到文件粒度。

层级输入输出省下的部分代价
清单差量旧、新两份资源清单keep / add / replace / remove未变化文件的全部字节需要可信的清单与哈希
文件差量变化文件的旧、新内容块复制与字面量操作流文件内未变化的块需要读取旧文件内容
传输压缩补丁字节流压缩后的补丁操作流里的冗余CPU 与解压内存

清单差量是入口。它先回答「86,000 个文件里哪些需要动」,把绝大多数文件标记为 keep,客户端只需要保留本地副本。文件差量只在 replace 的文件上继续做,把「整文件替换」变成「复制旧块 + 写入新块」。传输压缩是最后一步,作用对象是补丁本身,不改变差量语义。

对照《潮汐消消》:总资源 480MB、12,800 个文件,双周一次增量热更平均 12MB。文件数少一个数量级,清单差量的收益更明显;但它的资源以纹理和音频为主,文件内部可复用的块反而更少。两个案例的共同结论是:先做清单差量,再决定是否值得做文件差量。

清单差量决定哪些文件要动

清单是路径到哈希的映射。比较两份清单时,只有四种结果,没有第五种。

操作判定客户端动作常见错误
keep路径存在且哈希相同保留本地文件用修改时间或体积代替哈希
add新清单有、旧清单没有下载完整文件忘了写入清单,下次又当成新增
replace路径相同但哈希不同下载块补丁或完整文件只比体积,漏掉同体积改动
remove旧清单有、新清单没有删除本地文件并记墓碑不删旧文件,运行时仍能读到

判定必须用内容哈希,不能用修改时间、文件大小或构建序号。构建机的时间戳会漂移,同体积改动在配置和压缩纹理里都很常见;一旦判定失真,keep 的文件实际上已经变了,客户端会在运行时报资源损坏。

排序也必须确定。按路径字典序遍历,输出顺序在两次构建中保持一致,补丁才可复现、可对比。删除操作要留下墓碑记录:文件从清单里消失,不等于客户端可以立刻忘记它;否则旧版本残留的文件仍可能被按路径加载,形成「服务器认为已删除、客户端还能读到」的幽灵资源。

清单本身要带版本号和目标清单哈希。版本号用于拒绝过期补丁,目标哈希用于在应用完成后确认「拼出来的清单就是服务器签发的那一份」。

块级差量决定文件内部还能省多少

清单说「这个文件变了」,块级差量继续问「变了多少」。最直接的做法是把旧文件切成固定大小的块,建立「块哈希到偏移」的索引,再扫描新文件的每个块:命中就写一条复制操作,未命中就写字面量。

固定块实现简单,但有对齐问题。如果新内容在文件开头插入了一个字节,后面所有块都会错位,命中率会从接近 100% 掉到接近 0。生产环境通常改用内容定义分块,用滚动哈希在内容边界切块,插入和删除只影响局部;代价是切块逻辑更复杂,索引也更大。

不同资源适合不同策略:

资源类型建议策略原因
配置、脚本、文本块级差量改动局部、重复片段多
未压缩纹理、模型块级差量数据量大,局部改动可复用
已压缩图集、音频、视频整文件替换压缩后字节完全重排,块几乎不命中
小文件(小于一个块)整文件替换块头开销接近文件本身

块大小是收益与开销的平衡。块太小,操作流里全是复制头,补丁体积反而变大;块太大,局部改动会放大成整块字面量。4KB 是一个常见起点,但必须用真实资源回放验证,不能凭直觉定。

对于已经压缩的资源,强行做块差量往往得到「补丁比整文件还大」的结果。此时正确做法是回退到整文件替换,并在计划里显式记录回退数量,让发布单能解释补丁为什么没有变小。

补丁格式要能自校验、能重放

补丁不是一个压缩包,而是一段带元数据的操作流。客户端拿到它之后,要在不信任网络的前提下完成校验、应用和回退。

字段作用校验点
magic / format标识补丁格式与版本不认识的版本直接拒绝
base manifest旧清单的版本与哈希与本地清单不一致则拒绝
target manifest新清单的版本与哈希应用完成后必须完全匹配
op streamkeep / add / replace / remove 操作路径必须存在于目标清单
file hash每个新文件的哈希写入后逐文件校验
signature发布方签名在应用前验证来源

应用顺序决定了失败时会不会留下半成品。推荐顺序是:先把补丁和新增内容写到临时目录,逐文件校验哈希;全部通过后,再原子地切换清单指针;最后清理旧文件。清单指针是唯一的提交点,切换之前任何失败都可以丢弃临时目录,客户端仍然停留在旧版本。

空间是硬约束。应用补丁时,旧文件、新文件和补丁本身会同时存在,峰值空间接近「旧资源 + 新资源 + 补丁」。方舟的稳态内存是 1,150MB,水位 1,450MB,低端机上磁盘和内存都紧张;补丁应用必须分片进行,不能把整个补丁读进内存。卷一讨论过内存水位的控制方式,见 6.2 内存水位与低端机策略 。

还有一个平台边界:方舟的代码热更只走 Android,iOS 只接受资源与配置。补丁格式因此要能表达「这类文件不允许热更」,在生成阶段就拒绝,而不是等到客户端应用时才失败。

实现

下面的文件把清单差量、块级差量和补丁体积估算落成一次计算。输入是旧、新两份清单,文件可以携带内容;输出是操作列表、各操作字节数、补丁体积、整包体积、节省比例和告警。

// file: src/hotfix/delta.ts
// tier: L1
// verify: node --test test/hotfix/delta.test.ts

export interface FileEntry {
  readonly path: string;
  readonly bytes: number;
  readonly hash: string;
  readonly content?: string;
}

export interface Manifest {
  readonly version: string;
  readonly files: readonly FileEntry[];
}

export type ChangeKind = "keep" | "add" | "replace" | "remove";

export interface FileChange {
  readonly kind: ChangeKind;
  readonly path: string;
  readonly bytes: number;
  readonly fromHash?: string;
  readonly toHash?: string;
}

export interface ManifestDiff {
  readonly changes: readonly FileChange[];
  readonly keepBytes: number;
  readonly addBytes: number;
  readonly removeBytes: number;
  readonly replaceBytes: number;
}

export interface ContentDiff {
  readonly copyBlocks: number;
  readonly literalBytes: number;
  readonly copyBytes: number;
}

export interface DeltaOptions {
  readonly blockSize?: number;
}

export interface DeltaPlan extends ManifestDiff {
  readonly baseVersion: string;
  readonly targetVersion: string;
  readonly patchBytes: number;
  readonly fullBytes: number;
  readonly savingRatio: number;
  readonly warnings: readonly string[];
}

export const DEFAULT_BLOCK_SIZE = 4096;
const RECORD_OVERHEAD = 48;
const COPY_OP_OVERHEAD = 12;

function indexManifest(manifest: Manifest): Map<string, FileEntry> {
  if (manifest.version.trim() === "") {
    throw new Error("manifest version is empty");
  }
  const index = new Map<string, FileEntry>();
  for (const file of manifest.files) {
    if (file.path.trim() === "") {
      throw new Error("file path is empty");
    }
    if (!Number.isInteger(file.bytes) || file.bytes < 0) {
      throw new Error(`invalid bytes for ${file.path}: ${file.bytes}`);
    }
    if (file.hash.trim() === "") {
      throw new Error(`missing hash for ${file.path}`);
    }
    if (index.has(file.path)) {
      throw new Error(`duplicate path: ${file.path}`);
    }
    if (file.content !== undefined && Buffer.byteLength(file.content, "utf8") !== file.bytes) {
      throw new Error(`content size mismatch for ${file.path}`);
    }
    index.set(file.path, file);
  }
  return index;
}

export function diffContent(
  oldContent: string,
  newContent: string,
  blockSize: number = DEFAULT_BLOCK_SIZE,
): ContentDiff {
  if (!Number.isInteger(blockSize) || blockSize <= 0) {
    throw new Error(`invalid block size: ${blockSize}`);
  }
  const oldBuffer = Buffer.from(oldContent, "utf8");
  const newBuffer = Buffer.from(newContent, "utf8");
  const known = new Map<string, number>();
  for (let offset = 0; offset < oldBuffer.length; offset += blockSize) {
    const key = oldBuffer.subarray(offset, offset + blockSize).toString("base64");
    if (!known.has(key)) known.set(key, offset);
  }

  let copyBlocks = 0;
  let literalBytes = 0;
  let copyBytes = 0;
  for (let offset = 0; offset < newBuffer.length; offset += blockSize) {
    const block = newBuffer.subarray(offset, offset + blockSize);
    if (known.has(block.toString("base64"))) {
      copyBlocks += 1;
      copyBytes += block.length;
    } else {
      literalBytes += block.length;
    }
  }
  return { copyBlocks, literalBytes, copyBytes };
}

export function diffManifests(base: Manifest, target: Manifest): ManifestDiff {
  const from = indexManifest(base);
  const to = indexManifest(target);
  const paths = [...new Set([...from.keys(), ...to.keys()])].sort();

  const changes: FileChange[] = [];
  let keepBytes = 0;
  let addBytes = 0;
  let removeBytes = 0;
  let replaceBytes = 0;

  for (const path of paths) {
    const before = from.get(path);
    const after = to.get(path);
    if (before !== undefined && after !== undefined) {
      if (before.hash === after.hash) {
        changes.push({
          kind: "keep",
          path,
          bytes: after.bytes,
          fromHash: before.hash,
          toHash: after.hash,
        });
        keepBytes += after.bytes;
      } else {
        changes.push({
          kind: "replace",
          path,
          bytes: after.bytes,
          fromHash: before.hash,
          toHash: after.hash,
        });
        replaceBytes += after.bytes;
      }
    } else if (after !== undefined) {
      changes.push({ kind: "add", path, bytes: after.bytes, toHash: after.hash });
      addBytes += after.bytes;
    } else if (before !== undefined) {
      changes.push({ kind: "remove", path, bytes: before.bytes, fromHash: before.hash });
      removeBytes += before.bytes;
    }
  }

  return { changes, keepBytes, addBytes, removeBytes, replaceBytes };
}

export function planDelta(
  base: Manifest,
  target: Manifest,
  options: DeltaOptions = {},
): DeltaPlan {
  const diff = diffManifests(base, target);
  const from = indexManifest(base);
  const to = indexManifest(target);
  const blockSize = options.blockSize ?? DEFAULT_BLOCK_SIZE;
  if (!Number.isInteger(blockSize) || blockSize <= 0) {
    throw new Error(`invalid block size: ${blockSize}`);
  }

  let patchBytes = 0;
  let replacedWithoutContent = 0;
  let fullBytes = 0;
  for (const file of target.files) fullBytes += file.bytes;

  for (const change of diff.changes) {
    patchBytes += RECORD_OVERHEAD;
    if (change.kind === "add") {
      patchBytes += change.bytes;
    } else if (change.kind === "replace") {
      const before = from.get(change.path);
      const after = to.get(change.path);
      if (before?.content !== undefined && after?.content !== undefined) {
        const contentDiff = diffContent(before.content, after.content, blockSize);
        patchBytes += contentDiff.literalBytes + contentDiff.copyBlocks * COPY_OP_OVERHEAD;
      } else {
        patchBytes += change.bytes;
        replacedWithoutContent += 1;
      }
    }
  }

  const warnings: string[] = [];
  if (replacedWithoutContent > 0) {
    warnings.push(
      `${replacedWithoutContent} replaced files lack content; counted as full replacement`,
    );
  }
  if (fullBytes > 0 && patchBytes >= fullBytes) {
    warnings.push("delta patch is not smaller than the full package");
  }

  const savingRatio = fullBytes === 0 ? 0 : Number((1 - patchBytes / fullBytes).toFixed(4));

  return {
    ...diff,
    baseVersion: base.version,
    targetVersion: target.version,
    patchBytes,
    fullBytes,
    savingRatio,
    warnings,
  };
}

export function formatDeltaPlan(plan: DeltaPlan): string {
  const counts: Record<ChangeKind, number> = { keep: 0, add: 0, replace: 0, remove: 0 };
  const lines = [`delta ${plan.baseVersion} -> ${plan.targetVersion}`];
  for (const change of plan.changes) {
    counts[change.kind] += 1;
    lines.push(`${change.kind}\t${change.path}\t${change.bytes}`);
  }
  lines.push(
    `summary\tfull=${plan.fullBytes}B\tpatch=${plan.patchBytes}B\tsaving=${(plan.savingRatio * 100).toFixed(1)}%\tkeep=${counts.keep}\tadd=${counts.add}\treplace=${counts.replace}\tremove=${counts.remove}`,
  );
  return lines.join("\n");
}

diffContent 把旧内容按固定块切开,建立块哈希到偏移的索引,再扫描新内容;命中块只记复制操作,未命中块计入字面量。它返回复制块数、复制字节和字面量字节,供上层估算补丁体积。

diffManifests 先校验两份清单:路径不能为空、体积不能为负、哈希不能缺失、路径不能重复、声明体积必须与内容一致。随后按路径排序,把每个文件分类为 keep、add、replace 或 remove,并累计各类字节数。

planDelta 在清单差量之上估算补丁体积。每个操作都有固定记录开销;新增文件按完整字节计;替换文件如果有新旧内容,就调用 diffContent,按「字面量 + 复制操作头」计;没有内容时退回整文件体积,并记录回退数量。最后给出相对整包的节省比例和告警。

formatDeltaPlan 输出逐操作明细和一行汇总,适合直接贴进构建日志或发布单。

运行结果与边界

用一组小样本运行,输出如下。字节数是示例输入,不是方舟的真实体积:

delta 2026.10.28 -> 2026.11.05
replace	config/battle.json	8192
keep	ui/atlas/hud.png	40960
add	ui/atlas/new-mode.png	2048
remove	ui/atlas/retired.png	4096
summary	full=51200B	patch=4360B	saving=91.5%	keep=1	add=1	replace=1	remove=1

这张表能回答四个问题:哪些文件要动、补丁多大、相对整包省了多少、有没有回退。真实接入时,路径会变成 86,000 个资源路径,字节数会变成 MB 量级,结构不变。

边界一:固定块差量对插入和删除敏感。样本里新配置只是尾部替换,所以命中 6 个块;如果在文件开头插入一个字节,命中率会大幅下降。生产环境应换成内容定义分块,或者对这类文件直接整文件替换。

边界二:没有内容就只能按整文件估算。清单通常只带哈希和体积,构建系统必须额外提供变化文件的内容,块级差量才算得准;否则计划会偏保守,补丁体积被高估。

边界三:补丁不一定更小。小文件、已压缩资源、块头开销过大的场景都会让补丁超过整包,此时应该回退到整文件替换,并把「回退数量」作为构建指标,而不是假装差量总是有效。

边界四:本文只算体积,不负责签名、分发和灰度。补丁的真实下载成功率、断点续传和 CDN 命中由 12.x 讨论;补丁发给谁、按什么比例放量由 11.2 讨论。

与相邻章节的关系

10.3 讨论了热更失败与回滚兜底,回答「补丁应用失败之后怎么办」;本节回答「补丁长什么样、怎么算出来」。11.2 会把补丁放进灰度发布流程,决定哪些账号先拿到它;11.3 会从版本收敛角度讨论回滚时限。

本节不讨论资源分组和包边界,那属于 8.x;也不讨论下载器的分片与并发,那属于 12.1。差量算法只对「已经确定要更新的文件集合」负责,不改变包结构。

小结

  • 差量分三层:清单差量决定哪些文件要动,块级差量决定文件内部还能省多少,传输压缩只作用于补丁本身。
  • 清单判定必须用内容哈希,排序必须确定,删除必须留墓碑。
  • 固定块差量简单但对齐敏感;已压缩资源和小文件通常应回退到整文件替换。
  • 补丁格式要带基线清单、目标清单、逐文件哈希和签名,清单指针是唯一提交点。
  • 补丁应用受磁盘与内存双重约束,低端机上必须分片处理。

阅读导航:上一节:10.3 热更失败与回滚兜底 · 下一节:11.2 灰度发布:分组、比例与观测 。

常见错误与边界

  1. 用修改时间判断文件是否变化。构建机时间戳会漂移,keep 的文件可能已经变了,运行时报资源损坏。
  2. 只比较文件体积。同体积改动在配置和压缩纹理里很常见,必须比较内容哈希。
  3. 清单输出顺序不稳定。两次构建的操作流不同,补丁无法复现,也无法做基线对比。
  4. 删除文件不留墓碑。服务器认为已删除,客户端仍能按路径读到旧文件,形成幽灵资源。
  5. 对所有资源都做块差量。已压缩图集和音频的块几乎不命中,补丁会比整文件还大。
  6. 块大小凭直觉定。块太小操作头膨胀,块太大局部改动放大,必须用真实资源回放验证。
  7. 忽略固定块的对齐问题。文件开头插入一个字节,后续块全部错位,命中率骤降。
  8. 把整个补丁读进内存。低端机内存水位本来就紧,补丁应用必须分片。
  9. 先切换清单再校验文件。校验失败时客户端已经指向新版本,只能整包重装。
  10. 认为补丁一定更小。小文件、压缩资源、块头开销都可能让补丁超过整包,必须有回退路径。
  11. 边界:本节不负责签名分发与灰度。补丁怎么传、发给谁、放量多少,分别属于 12.x 和 11.2。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查