《移动游戏产品的架构·网络、资源与热更新》8.2 资源分组:共享、场景与产品

消消的首包 120MB 加 4 个 90MB 分包正好等于 480MB 总资源,分组预算没有任何余量。本节把资源分成共享、场景、产品三类归属,说明共享阈值如何把跨场景资源提升到公共包、场景包如何按闭包切分,以及重复拷贝的字节怎样被单独计量并卡住。

一次皮肤更新带来的包体膨胀

《潮汐消消》的总资源是 480MB,资源文件数 12,800,首包 120MB,另有 4 个分包,每个 90MB,合计 360MB。120 加 360 正好是 480,这意味着资源分组在案例设定里没有任何余量:只要有一份资源被重复放进两个包,总和就会超过总资源,打包清单就必须解释多出来的那部分从哪里来。

一次皮肤更新把这个矛盾暴露了出来。新皮肤包含 6 个界面元素,被主界面、商店、活动页、结算页等 6 个场景引用。负责切包的同学按「资源出现在哪个场景就放进哪个场景包」的规则处理,于是同一套皮肤被复制进了 6 个包。单个元素不大,但图集和特效加起来,每个包多出十几 MB,4 个包的预算立刻被挤爆。

问题不在皮肤,而在分组规则缺少「归属」这一层。资源出现在哪些场景,是使用关系;资源应该被哪个包持有,是归属决策。把两者混为一谈,就会得到「谁用到就复制一份」的结果。分组要做的,是在使用关系之上定义一套稳定的归属规则,并让重复拷贝的成本显式可见。

三种归属:共享、场景与产品

我们只定义三种归属,每一种都有明确的判定依据和组键。归属一旦确定,资源在包里的位置就确定了,不再受「哪个场景先加载」影响。

归属判定依据组键多场景时的行为
共享被不少于阈值个场景引用,或显式声明shared只存一份
场景只被少数场景引用scene:<id>每个场景各存一份,多出的计入冗余
产品活动、皮肤、渠道等产品线专属product:<id>按产品线各存一份,不跨线计冗余

共享归属解决「一份资源被很多地方用」的问题。它只存一份,所有场景通过同一个组键找到它;代价是这个包会变大,而且所有玩家都要下载,哪怕其中一部分人从不进入某个场景。

场景归属解决「包与包之间不要互相依赖」的问题。一个场景包应该能独立下载、独立校验、独立卸载,不需要先拉另一个场景包。为了这个独立性,少量跨场景资源宁可在每个场景各存一份。它不是浪费,而是用可计量的字节换取包边界。

产品归属解决「生命周期不同」的问题。活动皮肤、渠道素材、节日 UI 只在特定时间段或特定渠道存在,它们的下架不应该影响共享包,也不应该改动场景包。单独成组后,产品下线只需要删除对应的组,版本回滚也只需要回滚这一组。

三种归属不是互斥的物理分类,而是三种决策。同一个资源可以从场景归属被提升为共享归属,也可以在活动结束后从产品归属退回共享或删除。分组实现要能表达提升和删除,而不是把归属写死在资源文件里。

共享阈值:三是个可以被检验的数字

「被多少个场景引用才算共享」不能拍脑袋。阈值太低,几乎所有资源都被提升到共享包,共享包膨胀,场景包失去独立性;阈值太高,资源在多个场景里反复复制,冗余字节迅速吃掉预算。

我们采用的默认阈值是 3:被 3 个及以上场景引用的资源提升为共享,被 1 到 2 个场景引用的资源留在场景包。这个数字来自一个简单的成本比较。设资源大小为 B,被 n 个场景引用:

  • 放进共享包,所有玩家下载 B 一次;
  • 留在场景包,玩过 k 个相关场景的玩家下载 k 份,最坏情况是 n 份。

当 n 等于 2 时,最坏只多一份,换来的是两个场景包互不依赖;当 n 达到 3 时,最坏多两份,而且三个场景包都要为它增加体积,共享包的固定成本开始划算。阈值取 3,本质是「最多容忍一份复制」的规则。

资源特征建议归属理由
启动即用、所有界面依赖共享复制没有意义,只会放大首包
2 个场景共用、体积很小场景用一份复制换包边界,成本可接受
3 个及以上场景共用共享复制份数超过容忍上限
只在一个活动出现产品生命周期短,不应污染共享包
版本间频繁替换产品替换范围可控,回滚只动一组

阈值必须写进配置,而不是散落在打包脚本里。不同产品可以有不同阈值:方舟的场景更大、共享资源更多,阈值可能需要单独评估;消消的 4 个 90MB 分包边界清楚,默认 3 就够用。把阈值参数化,才能在不改代码的情况下做一次 A/B 预算。

场景包:按闭包切,不按目录切

场景包的内容应该来自 8.1 的依赖闭包,而不是场景目录。目录会包含历史遗留的孤儿资源,也会漏掉目录外的共享依赖;闭包只包含从场景根可达的资源,边界和运行时需求一致。

按闭包切包还有一个好处:重复计算可以被提前发现。两个场景的闭包如果有交集,交集部分就是分组的候选。交集小,留在各自场景包;交集达到阈值,提升为共享。这个判断不需要人工盘点,直接用闭包集合做交并差即可。

消消的分包是 4 个、每个 90MB。90MB 不只是一个平均数,它同时是单包的容量上限:超过这个值,低端机在下载和解压时的失败率会上升,4GB 以下机型占消消用户的 35%,这个比例决定了不能把包做得更大。分组实现里因此需要一个 maxGroupBytes,超过就报 oversized,而不是等到构建末尾才由总账发现。

场景包还要保持「可独立下载」。如果一个场景包依赖另一个场景包的资源,玩家进入场景时就必须先下载另一个包,下载路径变长,失败点变多。分组规则因此倾向于把跨场景资源提升为共享,而不是让场景包之间互相引用。

产品维度:生命周期比体积更重要

产品归属的判定不看引用次数,而看生命周期。一个春节活动皮肤可能只被 2 个场景引用,按阈值应该留在场景包;但它只在两个月内存在,放进场景包会让场景包在活动结束后仍然背着它,除非再做一次资源回收。

产品组把这些资源隔离出来,带来三个好处。第一,下架即删除整组,不需要逐条比对场景包。第二,回滚只影响这一组,不会把场景包一起回退。第三,渠道差异化变得简单:同一个活动在不同渠道可以有不同素材,组键按产品线区分,互不干扰。

产品组之间不做冗余计量。一个皮肤同时属于「春节」和「渠道 A」两个产品组,看起来像复制了两份,但这两个组不会同时出现在同一个玩家的设备上:渠道包在发布时就确定了产品线组合。把跨产品线的同一资源计为冗余,会让报表出现大量假阳性,掩盖真正的场景内复制。

真正需要计量的是「同一个产品线内部,同一资源被放进多个组」。这类复制会同时出现在一台设备上,是实打实的下载量。分组实现只对场景归属的多组情况计冗余,对产品归属不做跨组累加,就是基于这个判断。

实现

下面的文件把三种归属落成一次分组计算。输入是资源条目,每条声明自己的归属和引用目标;输出是组、每个资源的归属列表、重复项、冗余字节以及超限组。它不读取文件系统,也不依赖其它模块。

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

export type ResourceKind =
  | "texture"
  | "model"
  | "animation"
  | "audio"
  | "config"
  | "other";

export type ResourceScope = "shared" | "scene" | "product";

export interface ResourceEntry {
  readonly id: string;
  readonly kind: ResourceKind;
  readonly bytes: number;
  readonly scope: ResourceScope;
  readonly scenes?: readonly string[];
  readonly products?: readonly string[];
}

export interface GroupOptions {
  readonly sharedThreshold: number;
  readonly maxGroupBytes: number;
}

export const DEFAULT_GROUP_OPTIONS: GroupOptions = {
  sharedThreshold: 3,
  maxGroupBytes: 90 * 1024 * 1024,
};

export interface GroupPlan {
  readonly id: string;
  readonly resources: readonly string[];
  readonly bytes: number;
}

export interface Duplication {
  readonly id: string;
  readonly groups: readonly string[];
  readonly extraBytes: number;
}

export interface GroupPlanResult {
  readonly groups: readonly GroupPlan[];
  readonly assignments: ReadonlyMap<string, readonly string[]>;
  readonly duplicated: readonly Duplication[];
  readonly promoted: readonly string[];
  readonly totalBytes: number;
  readonly sharedBytes: number;
  readonly redundancyBytes: number;
  readonly oversized: readonly string[];
}

interface Bucket {
  readonly resources: string[];
  bytes: number;
}

export function planGroups(
  entries: readonly ResourceEntry[],
  options: GroupOptions = DEFAULT_GROUP_OPTIONS,
): GroupPlanResult {
  if (!Number.isInteger(options.sharedThreshold) || options.sharedThreshold < 1) {
    throw new Error(`sharedThreshold must be >= 1: ${options.sharedThreshold}`);
  }
  if (!Number.isInteger(options.maxGroupBytes) || options.maxGroupBytes < 1) {
    throw new Error(`maxGroupBytes must be >= 1: ${options.maxGroupBytes}`);
  }

  const seen = new Set<string>();
  const buckets = new Map<string, Bucket>();
  const assignments = new Map<string, readonly string[]>();
  const duplicated: Duplication[] = [];
  const promoted: string[] = [];
  let totalBytes = 0;
  let sharedBytes = 0;
  let redundancyBytes = 0;

  const add = (group: string, entry: ResourceEntry): void => {
    const bucket = buckets.get(group);
    if (bucket === undefined) {
      buckets.set(group, { resources: [entry.id], bytes: entry.bytes });
      return;
    }
    bucket.resources.push(entry.id);
    bucket.bytes += entry.bytes;
  };

  const sorted = [...entries].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
  for (const entry of sorted) {
    if (entry.id.length === 0) throw new Error("resource id must not be empty");
    if (seen.has(entry.id)) throw new Error(`duplicate resource: ${entry.id}`);
    seen.add(entry.id);
    if (!Number.isInteger(entry.bytes) || entry.bytes < 0) {
      throw new Error(`invalid bytes for ${entry.id}: ${entry.bytes}`);
    }

    let groups: string[];
    if (entry.scope === "shared") {
      groups = ["shared"];
    } else if (entry.scope === "product") {
      const products = [...new Set(entry.products ?? [])].sort();
      if (products.length === 0) {
        throw new Error(`product resource needs at least one product: ${entry.id}`);
      }
      groups = products.map((product) => `product:${product}`);
    } else {
      const scenes = [...new Set(entry.scenes ?? [])].sort();
      if (scenes.length === 0) {
        throw new Error(`scene resource needs at least one scene: ${entry.id}`);
      }
      if (scenes.length >= options.sharedThreshold) {
        groups = ["shared"];
        promoted.push(entry.id);
      } else {
        groups = scenes.map((scene) => `scene:${scene}`);
      }
    }

    for (const group of groups) add(group, entry);
    assignments.set(entry.id, groups);
    totalBytes += entry.bytes;

    if (groups.includes("shared")) sharedBytes += entry.bytes;
    if (entry.scope === "scene" && groups.length > 1) {
      const extraBytes = entry.bytes * (groups.length - 1);
      redundancyBytes += extraBytes;
      duplicated.push({ id: entry.id, groups, extraBytes });
    }
  }

  const groups: GroupPlan[] = [...buckets.entries()]
    .map(([id, bucket]) => ({
      id,
      resources: [...bucket.resources].sort(),
      bytes: bucket.bytes,
    }))
    .sort((a, b) => {
      if (a.id === "shared") return -1;
      if (b.id === "shared") return 1;
      return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
    });

  const oversized = groups
    .filter((group) => group.bytes > options.maxGroupBytes)
    .map((group) => group.id);

  return {
    groups,
    assignments,
    duplicated,
    promoted,
    totalBytes,
    sharedBytes,
    redundancyBytes,
    oversized,
  };
}

export function formatGroupPlan(result: GroupPlanResult): string {
  const lines = ["group\tfiles\tbytes"];
  for (const group of result.groups) {
    lines.push(`${group.id}\t${group.resources.length}\t${group.bytes}`);
  }
  lines.push(
    `total=${result.totalBytes}B shared=${result.sharedBytes}B redundancy=${result.redundancyBytes}B promoted=${result.promoted.length} oversized=${result.oversized.length}`,
  );
  return lines.join("\n");
}

实现先做输入校验,再按 id 排序,保证同样的输入得到同样的组顺序。共享归属直接进 shared;产品归属按产品 id 展开成多个 product: 组;场景归属先去重再排序,引用数达到阈值就提升为共享,否则每个场景一个 scene: 组。

提升是一个容易被忽略的动作。资源从场景归属变成共享归属后,它不再计入场景包的体积,但会进入共享包的统计。实现把被提升的 id 单独记录在 promoted 里,打包报表可以直接显示「本次有多少资源因为跨场景引用被提升」,让阈值调整的效果可观测。

冗余只对场景归属计算。一个资源被 n 个场景引用且没有提升,就会产生 n 份拷贝,多出来的 bytes * (n - 1) 计入 redundancyBytes。这个数字不是错误,而是预算里必须预留的一行;把它显式打印出来,团队才能在「多切一个包」和「提升为共享」之间做选择。

oversized 记录超过 maxGroupBytes 的组。它不直接抛错,因为超限有时是已知的取舍;但构建报告必须列出,发布前要有人签字。把「超限」和「非法」分开,是分组工具和校验工具的区别。

运行结果与边界

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

group  files  bytes
shared  2  150
product:cn  2  50
product:global  1  40
scene:s1  1  30
scene:s2  2  50
total=250B shared=150B redundancy=30B promoted=1 oversized=1

第一行之后的每一行是一个组:组键、文件数、字节数。最后一行给出总资源、共享部分、冗余部分、被提升数量和超限组数量。把这张表和上一节的闭包报告放在一起,就能回答「哪个包超了、超的部分是共享还是复制」。

边界也要说清楚。组键是字符串,大小写和分隔符一旦确定就不能随意改,否则增量打包会把整个组当成新增。资源 id 必须在全局唯一,不能靠「同名的在不同目录」来区分,因为组内只记录 id。归属信息可以来自资源清单,也可以来自构建期推断,但推断规则必须版本化,否则两次构建可能得到不同的分组。

还有一个边界是「空组」。一个产品下线后,组里可能一个资源都不剩;实现会保留空组还是删除空组,要看发布系统是否需要保留占位。我们的实现只在有资源时创建组,因此空组不会出现在结果里,下线产品需要在清单层显式移除。

与相邻章节的关系

8.1 提供了依赖图和闭包,本节用闭包决定场景包内容、用引用次数决定是否提升为共享。8.3 会把分组结果接到打包顺序和首包预算上,讨论方舟的 6 个包如何排序、重复资源如何在打包前被认领。9.1 会从产品角度重新审视首包边界,回答「哪些共享资源必须随安装包一起下发」。

本节不讨论下载协议、断点续传和校验,那些属于资源下载链路;也不讨论热更清单的生成,那属于 10.x。分组只回答「资源归谁持有」,不回答「资源怎么送到设备上」。

小结

  • 资源出现在哪些场景是使用关系,资源由哪个包持有是归属决策,两者必须分开。
  • 共享、场景、产品三种归属分别解决复用、包独立性和生命周期问题。
  • 默认阈值 3 的含义是最多容忍一份复制;阈值要参数化,不能散落在脚本里。
  • 场景包按闭包切,不按目录切;单包上限要显式检查,超限和非法要分开报告。
  • 冗余只对同一产品线内、会被同一台设备下载的复制计量,跨产品线不累加。

阅读导航:上一节:8.1 资源依赖图与循环检测 · 下一节:8.3 打包策略与冗余控制 。

常见错误与边界

  1. 按目录分组。目录只表达物理位置,既会带上孤儿资源,也会漏掉跨目录引用。
  2. 谁用到就复制一份。同一资源被多个场景引用时无限复制,480MB 的总预算很快被击穿。
  3. 共享阈值拍脑袋。阈值过低会让共享包膨胀,过高会让冗余失控,必须能按产品调整并观测效果。
  4. 把提升为共享当成免费操作。提升减少复制,但把体积转移给了所有玩家,首包和公共包都会变大。
  5. 产品组之间也计冗余。不同产品线不会同时出现在一台设备上,跨线累加会产生大量假阳性。
  6. 组键随意改名。组键变化会让增量打包把整组视为新增,差量包失去意义。
  7. 用文件名而不是全局 id 做键。不同目录下的同名资源会被错误合并,归属和统计都会失真。
  8. 只报总体积,不报超限组。总量没超不代表每个包都没超,单包上限必须单独检查。
  9. 边界:分组不负责下载顺序。组只表达归属,先下哪个包由 8.3 的打包计划决定。
  10. 边界:分组不负责资源回收。下线产品要移除清单条目,空组不会自动清理历史缓存。
  11. 边界:归属推断规则需要版本化。同一份资源在两次构建中得到不同归属,会让增量清单整体失效。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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