一次皮肤更新带来的包体膨胀
《潮汐消消》的总资源是 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 打包策略与冗余控制 。
常见错误与边界
- 按目录分组。目录只表达物理位置,既会带上孤儿资源,也会漏掉跨目录引用。
- 谁用到就复制一份。同一资源被多个场景引用时无限复制,480MB 的总预算很快被击穿。
- 共享阈值拍脑袋。阈值过低会让共享包膨胀,过高会让冗余失控,必须能按产品调整并观测效果。
- 把提升为共享当成免费操作。提升减少复制,但把体积转移给了所有玩家,首包和公共包都会变大。
- 产品组之间也计冗余。不同产品线不会同时出现在一台设备上,跨线累加会产生大量假阳性。
- 组键随意改名。组键变化会让增量打包把整组视为新增,差量包失去意义。
- 用文件名而不是全局 id 做键。不同目录下的同名资源会被错误合并,归属和统计都会失真。
- 只报总体积,不报超限组。总量没超不代表每个包都没超,单包上限必须单独检查。
- 边界:分组不负责下载顺序。组只表达归属,先下哪个包由 8.3 的打包计划决定。
- 边界:分组不负责资源回收。下线产品要移除清单条目,空组不会自动清理历史缓存。
- 边界:归属推断规则需要版本化。同一份资源在两次构建中得到不同归属,会让增量清单整体失效。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。