《移动游戏产品的架构·网络、资源与热更新》8.3 打包策略与冗余控制

方舟的首包 350MB,另有 6 个分包共 1,490MB,每周还要下发平均 45MB 的热更。本节把包分成首包、必需、可选三层,用依赖关系排出下载阶段,并给每份重复资源指定唯一归属,让 1,840MB 的总资源在增量更新时保持稳定的包边界。

同一个角色,出现在两个包里

《潮汐方舟》的首包是 350MB,另有 6 个分包:4 个战斗场景各 260MB、角色 260MB、语音 190MB,合计 1,490MB,加上首包正好是 1,840MB 总资源。资源文件数 86,000,依赖图平均出度 4.1、最大深度 9。版本节奏是每周一次平均 45MB 的增量热更,每 6 周发一次商店整包,整包差量在 60 到 90MB 之间。

一次版本合并后,下载报表出现了异常:进入战斗场景的玩家,在下载战斗包之后又被要求下载角色包,两个包都包含同一批角色模型。原因不复杂——战斗包按场景闭包切,角色包按资源类型切,两套切法都认为自己需要这批模型,于是各放了一份。

这不是资源缺失,而是归属缺失。每份资源都必须有且只有一个持有者;其它包如果要用,要么依赖持有者,要么在运行时按需获取。没有唯一归属,包与包之间就会出现看不见的重复,而重复在增量更新时会被放大:同一份资源在两个包里各改一次,差量补丁就要下发两次。

三层包:首包、必需分包与可选分包

包的分层不看资源类型,而看「玩家什么时候必须拿到它」。方舟的 1,840MB 资源被分成三层,每一层对应一种下载时机。

层级判定下载时机方舟对应
首包 first冷启动到主界面必需,无法延迟商店安装350MB 基础包
必需 required进入核心玩法前必需首次进入前,可预下载4 个战斗场景各 260MB、角色 260MB
可选 optional非核心,缺失不影响进入使用时或空闲时语音 190MB

首包的边界最严格。它决定商店页的下载体积,也决定冷启动时能直接读到哪些资源。方舟的冷启动 P50 是 3.5 秒、P95 是 6.8 秒,首包里的资源越多,安装后的首次校验和索引建立越慢;把可以延迟的资源塞进首包,换来的往往不是体验提升,而是安装转化率下降。

必需分包的核心约束是「进入玩法前必须完整」。战斗场景包缺一张图,玩家可能已经匹配成功、进入加载界面才发现,这时退出会让队友等待。必需包因此要支持预下载,并且在进入队列前完成校验。

可选分包承担「有更好、没有也能玩」的内容。语音是典型例子:190MB 的语音包不进入核心路径,玩家可以在设置里选择下载,或者由系统在 Wi-Fi 空闲时拉取。可选包的设计目标不是省流量,而是把「必须等待」的时间从核心路径上移走。

分层还决定了失败处理。首包失败只能重装;必需包失败可以重试并阻止进入玩法;可选包失败应该静默降级,不能弹一个阻断式对话框。三种层级对应三套错误策略,不能共用一套重试逻辑。

下载阶段:依赖关系决定先后,层级决定并发

包与包之间有两种关系:层级关系和依赖关系。层级回答「重不重要」,依赖回答「能不能先下」。两者不能混用:一个可选包也可能依赖某个必需包里的资源,这时它必须等必需包就位。

依赖关系用 requires 表达,形成一个有向无环图。没有依赖的包处于第 0 阶段,可以同时开始下载;依赖第 0 阶段的包处于第 1 阶段,必须等所有前置包完成校验后才能开始。阶段内部再按层级和包 id 排序,保证下载顺序稳定。

阶段包说明
0基础包无前置,安装时已就位
14 个战斗场景包、角色包都只依赖基础包,可并发预下载
2语音包依赖角色包,角色资源就位后才能校验语音引用

阶段划分的价值在于把「串行等待」压缩到最少。如果所有包排成一条链,玩家要等 6 个包全部下完才能进战斗;按阶段展开后,第 1 阶段的 5 个包可以并发,只有真正有依赖的语音包需要等待。并发不是无限的:同时下载太多包会争抢带宽,反而拉长首个可用包的就绪时间,所以阶段内还需要一个并发上限。

依赖图必须拒绝环。两个包互相 requires,下载器无法决定谁先开始;这类环通常来自「A 包补了 B 包的资源、B 包又补了 A 包」的临时补丁,必须在打包期失败,而不是在客户端死锁。

冗余控制:每份资源只能有一个持有者

冗余的判定单位是资源 id,不是文件路径。同一份资源被两个包声明,无论路径是否相同,都算重复。控制冗余有三种策略,代价各不相同。

策略做法适用场景代价
唯一归属只留一个包持有,其它包依赖它资源小、依赖关系清晰引入包间依赖,下载路径变长
提升共享包抽到公共包,所有包引用多包高频共用公共包变大,人人下载
运行时按需不预置,使用时单独拉取低频、非核心资源首次使用有等待

唯一归属是最常用的策略。规则要确定:按层级优先,首包高于必需包,必需包高于可选包;同层级按包 id 字典序。这样同一份资源在两次构建中会落到同一个包,增量清单不会因为遍历顺序变化而整体失效。

归属确定后,其它包里的同 id 条目会被移除,并记录成一条重复项:资源 id、涉及哪些包、单份体积、浪费字节。浪费字节等于 体积 × (出现次数 - 1)。这个数字要单独进报表,而不是混进总资源;总资源是内容的真实体积,浪费字节是组织方式带来的额外下载量。

提升共享包适用于「被三个以上包引用」的资源,判断标准与 8.2 的共享阈值一致。它的代价是把体积转移给所有玩家,所以只对高频共用资源使用。运行时按需适用于语音、过场动画这类低频内容,代价是首次使用时有一次明显的等待,需要 UI 提前提示。

三种策略可以组合,但必须有优先级:先尝试唯一归属,因为它的额外下载量最小;归属会导致包间强依赖且依赖链过长时,再考虑提升共享;只有低频、可等待的资源才走运行时按需。

包边界要稳定,增量更新才便宜

方舟每周下发平均 45MB 的增量热更,每 6 周发一次商店整包,整包差量 60 到 90MB。这些数字成立的前提是包边界稳定:如果这次把一批资源从角色包挪到战斗包,下次又挪回来,两次构建的差量都会接近整包体积。

包边界稳定的三个条件:

  1. 归属规则确定。同一份资源在任何一次构建中都落到同一个包,不依赖文件遍历顺序或哈希随机种子。
  2. 组键稳定。包的 id 不随版本号、日期或构建号变化,否则下载器会把整个包当成新包。
  3. 切分依据稳定。按闭包和引用次数切分,而不是按本次提交的文件列表切分;后者会让包内容随提交顺序漂移。

稳定性不是拒绝变化,而是让变化可解释。资源从场景包提升为共享包,是一次显式的归属变更,应该在构建报告里单独列出;因为新增引用而自动改变归属,则是规则的自然结果,也应该能追溯到具体资源。两种情况都不应该表现为「差量包突然变大」这种事后现象。

首包预算同样是硬约束。方舟首包 350MB,下载成功率 98.6%,这个成功率是在当前体积下得到的;首包继续膨胀,失败率会先上升,然后才是安装转化率下降。打包计划必须把首包总量和预算做比较,超过就阻止发布,而不是只打印一行警告。

实现

下面的文件把分层、依赖阶段和唯一归属落成一次计划计算。输入是包列表,每个包声明层级、前置包和资源;输出是下载顺序、阶段、各层字节数、重复项和预算告警。

// file: src/resource/pack-plan.ts
// tier: L1
// verify: node --test test/resource/pack-plan.test.ts

export type PackTier = "first" | "required" | "optional";

export interface AssetRef {
  readonly id: string;
  readonly bytes: number;
}

export interface PackSpec {
  readonly id: string;
  readonly tier: PackTier;
  readonly requires: readonly string[];
  readonly assets: readonly AssetRef[];
}

export interface DuplicateAsset {
  readonly id: string;
  readonly packs: readonly string[];
  readonly bytes: number;
  readonly wastedBytes: number;
}

export interface NormalizedPacks {
  readonly packs: readonly PackSpec[];
  readonly duplicates: readonly DuplicateAsset[];
  readonly redundancyBytes: number;
}

export interface PackPlan {
  readonly packs: readonly PackSpec[];
  readonly order: readonly string[];
  readonly stages: readonly (readonly string[])[];
  readonly firstBytes: number;
  readonly requiredBytes: number;
  readonly optionalBytes: number;
  readonly duplicates: readonly DuplicateAsset[];
  readonly redundancyBytes: number;
  readonly budgetExceeded: boolean;
  readonly warnings: readonly string[];
}

export const DEFAULT_FIRST_BUDGET = 350 * 1024 * 1024;
export const MB = 1024 * 1024;

const TIER_RANK: Record<PackTier, number> = {
  first: 0,
  required: 1,
  optional: 2,
};

function comparePacks(a: PackSpec, b: PackSpec): number {
  const rank = TIER_RANK[a.tier] - TIER_RANK[b.tier];
  if (rank !== 0) return rank;
  return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
}

interface Occurrence {
  readonly pack: string;
  readonly tier: PackTier;
  readonly bytes: number;
}

export function applyOwnership(packs: readonly PackSpec[]): NormalizedPacks {
  const byId = new Map<string, PackSpec>();
  for (const pack of packs) {
    if (byId.has(pack.id)) throw new Error(`duplicate pack: ${pack.id}`);
    byId.set(pack.id, pack);
  }

  const occurrences = new Map<string, Occurrence[]>();
  for (const pack of packs) {
    const local = new Set<string>();
    for (const asset of pack.assets) {
      if (local.has(asset.id)) {
        throw new Error(`asset listed twice in ${pack.id}: ${asset.id}`);
      }
      local.add(asset.id);
      if (!Number.isInteger(asset.bytes) || asset.bytes < 0) {
        throw new Error(`invalid bytes for ${asset.id}: ${asset.bytes}`);
      }
      const list = occurrences.get(asset.id) ?? [];
      list.push({ pack: pack.id, tier: pack.tier, bytes: asset.bytes });
      occurrences.set(asset.id, list);
    }
  }

  const owner = new Map<string, string>();
  const duplicates: DuplicateAsset[] = [];
  let redundancyBytes = 0;

  for (const [id, list] of [...occurrences.entries()].sort()) {
    const sizes = new Set(list.map((item) => item.bytes));
    if (sizes.size > 1) {
      throw new Error(`asset size mismatch: ${id}`);
    }
    const sorted = [...list].sort((a, b) => {
      const rank = TIER_RANK[a.tier] - TIER_RANK[b.tier];
      if (rank !== 0) return rank;
      return a.pack < b.pack ? -1 : a.pack > b.pack ? 1 : 0;
    });
    owner.set(id, sorted[0].pack);
    if (sorted.length > 1) {
      const bytes = sorted[0].bytes;
      const wastedBytes = bytes * (sorted.length - 1);
      duplicates.push({
        id,
        packs: sorted.map((item) => item.pack).sort(),
        bytes,
        wastedBytes,
      });
      redundancyBytes += wastedBytes;
    }
  }

  const normalized = packs.map((pack) => ({
    ...pack,
    assets: pack.assets.filter((asset) => owner.get(asset.id) === pack.id),
  }));

  return { packs: normalized, duplicates, redundancyBytes };
}

function computeStages(packs: readonly PackSpec[]): {
  stages: string[][];
  order: string[];
} {
  if (packs.length === 0) return { stages: [], order: [] };

  const byId = new Map(packs.map((pack) => [pack.id, pack]));
  const stage = new Map<string, number>();
  const visiting = new Set<string>();

  const stageOf = (id: string): number => {
    const cached = stage.get(id);
    if (cached !== undefined) return cached;
    if (visiting.has(id)) {
      throw new Error(`cycle detected: pack requirements loop at ${id}`);
    }
    visiting.add(id);
    const pack = byId.get(id);
    if (pack === undefined) throw new Error(`unknown pack: ${id}`);
    let value = 0;
    for (const requirement of pack.requires) {
      if (!byId.has(requirement)) {
        throw new Error(`unknown requirement: ${id} -> ${requirement}`);
      }
      value = Math.max(value, stageOf(requirement) + 1);
    }
    visiting.delete(id);
    stage.set(id, value);
    return value;
  };

  for (const pack of packs) stageOf(pack.id);

  const maxStage = Math.max(...stage.values());
  const stages: string[][] = [];
  for (let index = 0; index <= maxStage; index += 1) stages.push([]);
  for (const pack of [...packs].sort(comparePacks)) {
    stages[stage.get(pack.id) ?? 0].push(pack.id);
  }
  return { stages, order: stages.flat() };
}

export function planPacks(
  packs: readonly PackSpec[],
  options: { readonly firstBudgetBytes?: number } = {},
): PackPlan {
  const budget = options.firstBudgetBytes ?? DEFAULT_FIRST_BUDGET;
  if (!Number.isInteger(budget) || budget < 0) {
    throw new Error(`invalid first budget: ${budget}`);
  }

  const normalized = applyOwnership(packs);
  const { stages, order } = computeStages(normalized.packs);

  let firstBytes = 0;
  let requiredBytes = 0;
  let optionalBytes = 0;
  for (const pack of normalized.packs) {
    const bytes = pack.assets.reduce((sum, asset) => sum + asset.bytes, 0);
    if (pack.tier === "first") firstBytes += bytes;
    else if (pack.tier === "required") requiredBytes += bytes;
    else optionalBytes += bytes;
  }

  const warnings: string[] = [];
  if (firstBytes > budget) {
    warnings.push(
      `first package ${(firstBytes / MB).toFixed(2)}MB exceeds budget ${(budget / MB).toFixed(2)}MB`,
    );
  }
  if (normalized.redundancyBytes > 0) {
    warnings.push(
      `${normalized.duplicates.length} duplicated assets waste ${(normalized.redundancyBytes / MB).toFixed(2)}MB`,
    );
  }

  return {
    packs: normalized.packs,
    order,
    stages,
    firstBytes,
    requiredBytes,
    optionalBytes,
    duplicates: normalized.duplicates,
    redundancyBytes: normalized.redundancyBytes,
    budgetExceeded: firstBytes > budget,
    warnings,
  };
}

export function formatPackPlan(plan: PackPlan): string {
  const stageOf = new Map<string, number>();
  for (let index = 0; index < plan.stages.length; index += 1) {
    for (const id of plan.stages[index]) stageOf.set(id, index);
  }
  const lines = ["pack\ttier\tstage\tbytes"];
  for (const pack of plan.packs) {
    const bytes = pack.assets.reduce((sum, asset) => sum + asset.bytes, 0);
    lines.push(`${pack.id}\t${pack.tier}\t${stageOf.get(pack.id) ?? 0}\t${bytes}`);
  }
  lines.push(
    `summary\tfirst=${plan.firstBytes}B\trequired=${plan.requiredBytes}B\toptional=${plan.optionalBytes}B\tredundancy=${plan.redundancyBytes}B\tbudget=${plan.budgetExceeded ? "exceeded" : "ok"}`,
  );
  return lines.join("\n");
}

applyOwnership 先扫描所有包的资源声明,按「层级优先、同层按包 id」选出唯一持有者,再把其它包里的同 id 条目移除。重复项记录涉及的所有包和浪费字节;如果同一个资源在不同包里声明了不同体积,直接抛错,因为这说明资源清单本身不一致。

computeStages 用带访问状态的深度优先遍历计算每个包所处的阶段。阶段值等于所有前置包阶段的最大值加一;遇到正在访问的包就说明存在环,立即抛出。阶段内按层级和 id 排序,保证输出确定。

planPacks 在归属归一之后统计首包、必需包和可选包的字节数,并与首包预算比较。超预算时 budgetExceeded 为真,同时生成一条带 MB 数值的告警;重复资源也会生成一条告警。告警不是异常,构建可以继续,但发布门禁应该读取这两个字段。

formatPackPlan 输出每个包的层级、阶段和字节数,最后一行是汇总。它适合直接贴进构建日志,也适合作为发布单的附件。

运行结果与边界

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

pack  tier  stage  bytes
base  first  0  10
battle  required  1  120
character  required  1  30
voice  optional  2  50
summary  first=10B  required=150B  optional=50B  redundancy=40B  budget=ok

这张表能直接回答三个问题:首包超没超预算、每个包在第几阶段、重复资源浪费了多少。真实接入时,包 id 会变成战斗场景、角色、语音,字节数会变成 260MB、260MB、190MB 这一量级,结构不变。

边界要写清楚。包计划不负责下载协议和断点续传,它只给出顺序和阶段;真正的并发数、超时和重试由下载器决定。计划也不负责资源压缩和纹理格式转换,那些发生在打包之前,改变的是每个资源的字节数,而不是归属。

另一个边界是「跨版本兼容」。热更可能只更新部分包,旧版本客户端仍然持有旧包;如果新包依赖了旧客户端没有的资源,运行时就会缺资源。这类兼容性由资源清单的版本号和最小客户端版本约束,不属于包计划本身,但包计划必须把包 id 和版本号一起输出,供清单层使用。

与相邻章节的关系

8.1 给出了依赖图和闭包,8.2 给出了共享、场景、产品三种归属;本节把两者接到下载顺序和首包预算上。9.1 会从产品侧讨论首包边界,回答「350MB 里到底应该放什么」;9.2 会展开首包之外的按需下载策略。

本节不讨论热更差量算法和 CDN 分发,那属于 10.x;也不讨论资源加载器的引用计数,那属于运行时资源管理。打包计划只回答「资源放在哪个包、包按什么顺序下载、重复资源归谁」,不回答「下载失败后怎么重试」。

小结

  • 包按「玩家什么时候必须拿到」分层:首包、必需分包、可选分包,对应三套失败策略。
  • 层级回答重不重要,依赖回答能不能先下;下载阶段由 requires 决定,阶段内再按层级排序。
  • 每份资源只能有一个持有者,归属按层级优先、同层按包 id 确定,保证构建可复现。
  • 冗余浪费等于体积乘出现次数减一,要单独进报表,不能混进总资源。
  • 包边界稳定是增量更新的前提;首包预算超限应阻止发布,而不是只打印警告。

阅读导航:上一节:8.2 资源分组:共享、场景与产品 · 下一节:9.1 首包边界:什么必须进包 。

常见错误与边界

  1. 按资源类型切包。类型相同的资源未必同时需要,按类型切会把战斗模型塞进角色包,造成跨包重复。
  2. 没有唯一归属。同一份资源被两个包声明,下载量翻倍,增量补丁也要下发两次。
  3. 归属规则不确定。依赖遍历顺序或哈希随机种子选持有者,两次构建得到不同结果,差量整体失效。
  4. 把层级当依赖。可选包也可能依赖必需包,只按层级排序会让下载器在缺资源时启动。
  5. 允许包依赖成环。两个包互相 requires,下载器无法决定先后,最终表现为永久等待。
  6. 阶段内无限并发。同时下载太多包会争抢带宽,首个可用包的就绪时间反而变长。
  7. 包 id 带版本号或日期。下载器会把每次发布都当成新包,增量更新退化成整包下载。
  8. 只统计总资源,不统计浪费字节。总资源没变不代表下载量没变,重复项必须单独计量。
  9. 首包超限只告警不阻断。超出 350MB 的预算继续发布,下载成功率和安装转化率会一起下降。
  10. 边界:包计划不负责断点续传和重试。它只给顺序和阶段,传输层的策略由下载器决定。
  11. 边界:包计划不保证跨版本兼容。旧客户端可能没有新包依赖的资源,这需要清单版本号和最小客户端版本约束。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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