一个皮肤引发的体积争论
《潮汐消消》的一次版本评审上,美术同学提出把新皮肤放进首包,理由是「玩家一进主界面就能看到」。这个理由本身没错,但首包不是展示柜,它是玩家在商店里点下安装后必须完整下载的那一部分。皮肤放进首包,意味着每一个新增用户都要为它多付一次下载。
这个决定看起来很小。一套皮肤也许只有几 MB,评审时很容易被放行;但首包是所有新增用户都要付的成本,而皮肤的使用者只是其中一部分。首包的每一 MB 都乘以 42 万月新增,这个乘法关系在单次评审里几乎不会被说出来。
《潮汐消消》的首包是 120MB,总资源 480MB,另有 4 个分包,每个 90MB,合计 360MB。120 加 360 正好是 480MB,账面上没有冗余。真正的问题不是总量,而是那 120MB 里到底装了什么、为什么是这些。
反对把皮肤塞进首包的意见也不是「大一点没关系」。首包体积直接进入安装转化率和冷启动时间:这款游戏的冷启动 P50 是 1.8 秒、P95 是 3.2 秒,低端机占 35%。首包每多 10MB,安装等待、首次校验和索引建立都会变长,而这三件事都发生在玩家看到第一帧之前。
首包的定义:首次会话的最小闭环
按指标口径,首包是玩家首次安装时商店下载的体积,不含安装后按需下载。这个定义里有两个关键词:首次安装、按需之外。它不承诺「完整游戏」,只承诺「能走到需要下一份资源的地方」。
因此首包的正确目标不是「尽量小」,而是「首次会话的最小闭环」。闭环的意思是:从点击图标到完成第一局,所有必经路径上的资源都在包里,不需要停下来等下载。闭环之外的内容,哪怕它很常用、很好看,也应该交给分包或按需下载。
「最小」和「闭环」会互相拉扯。只追求小,会把新手引导的纹理也推迟,玩家第一次进入就卡在加载条;只追求闭环,会把所有可能用到的资源都塞进来,首包迅速膨胀。可执行的判据是:把首次会话拆成一条路径,逐个节点问「这里缺资源会不会停下来」,会停的进包,不会停的出去。
《潮汐消消》的资源结构可以作为参照:12,800 个资源文件,其中纹理 9,400、音频 1,600、配置 1,200、其它 600。文件数最多的纹理并不等于最该进首包,配置的体积小但决定启动流程,音频里只有一小部分出现在新手引导。按类型拍脑袋分配,几乎一定会把首包塞满不该进的东西。
| 资源类别 | 文件数 | 首包策略 | 理由 |
|---|---|---|---|
| 纹理 | 9,400 | 只保留主界面与新手引导必需项 | 场景、皮肤、活动纹理可延迟 |
| 音频 | 1,600 | 只保留点击反馈与引导语音 | 背景音乐可流式,语音可后补 |
| 配置 | 1,200 | 全部保留 | 体积小,决定启动与关卡流程 |
| 其它 | 600 | 按引用关系逐项判断 | 特效、字体、字体图集差异大 |
这张表只给出方向,不给出字节数。真正的准入要落到单个资源,而不是类别:同为纹理,主界面的按钮图集和新皮肤的立绘,命运完全不同。
作为对照,《潮汐方舟》的首包是 350MB,总资源 1,840MB,首包占比约 19.0%。它的占比比消消的 25% 更低,但这不代表它的边界更松,而是因为它用 6 个分包把 1,490MB 移出了安装路径,首包里留下的是强联网战斗启动必须的那一部分。占比是结果,不是标准。
三条准入标准
判断一个资源该不该进首包,只看三个问题,顺序不能颠倒。
- 冷启动路径是否需要它。 从进程启动到主界面可交互,这段路径上的资源没有替代方案,缺了就黑屏、卡加载或直接崩溃。这类资源标记为
boot,无论预算多紧都必须进包。 - 首次会话是否会用到它。 玩家在第一次会话里可能进入的第一个关卡、第一次抽卡、第一段引导,需要的资源标记为
first-session。它们可以降级,但不能让玩家卡住。 - 缺失时能否优雅降级。 有占位图、有默认配置、有静音兜底,就说明它可以被推迟;没有兜底又不在前两类里,说明产品设计本身有缺口,应该回去补降级方案,而不是塞进首包。
这三条对应代码里的三个层级:boot、first-session、deferred。deferred 不是「不重要」,而是「首次会话不出现」,它可能是后期最核心的玩法资源,只是不该占用安装体积。
| 层级 | 判定 | 预算紧张时 | 缺失后果 |
|---|---|---|---|
| boot | 冷启动必经 | 仍然进包,允许超预算并告警 | 无法启动或卡死 |
| first-session | 首次会话会用到 | 有兜底则推迟,无兜底则告警 | 体验受损或流程中断 |
| deferred | 首次会话不出现 | 一律推迟 | 首次使用时有等待 |
三层之外有一种常见误判:把「首次会话可能用到」当成「首次会话一定用到」。区别在于路径是否必经。第一次抽卡是引导强制流程,抽卡界面的资源属于必经;商城入口在首次会话里通常只是露出,点进去才需要资源,它属于可选。把入口可达当成必经,首包会在几个版本内缓慢膨胀,而且每次膨胀都有看似合理的理由。
层级必须由产品与客户端共同确认,不能由打包脚本推断。脚本能算引用次数,但算不出「这个界面是不是首次会话必经」;把产品判断写成配置,脚本才有稳定的输入。配置本身也要评审:层级从 first-session 调整为 boot,等于把一份资源永久加进所有新增用户的安装体积。
用引用密度排序,而不是用感觉排序
预算不够时,first-session 资源之间要排序。最直觉的排法是按体积从小到大,先装小的;这会让首包塞满大量低价值的小图,而真正影响体验的中等资源被挤出去。另一种排法是按引用次数从多到少,这又会让一张被很多界面引用、但每个界面都可选的背景图排在最前。
更稳的指标是引用密度:引用次数除以字节数,也就是「每字节换来的使用面」。密度高的资源先准入,密度相同的按字节升序,再按 id 字典序,保证同一份清单在两次构建中得到完全相同的顺序。
用一组示意数字走一遍。假设首包剩余 45 字节,候选有三项:A 是 40 字节、被 20 个界面引用,密度 0.5;B 是 50 字节、被 5 个界面引用,密度 0.1;C 是 10 字节、被 10 个界面引用,密度 1.0。排序结果是 C、A、B:C 先准入,剩 35 字节;A 放不下,有兜底则记为 fallback;B 密度最低,排在最后。
排序只在同一层级内生效。boot 永远排在 first-session 前面,不是因为它的密度更高,而是因为它不可延迟。把层级和密度混成一个分数,会让某个「又小又热」的可选资源挤掉启动配置,这是最危险的一类错误。
确定性同样重要。排序键里必须包含 id 作为最后一级,否则两个密度、体积都相同的资源会依赖输入顺序;输入顺序一变,首包内容就变,增量热更的差量也会跟着漂移。
引用次数通常来自依赖图的反向边计数:一个纹理被多少个界面、预制体或配置引用,就是它的引用次数。这个数字会随版本变化,所以准入必须在每次构建时重算,而不是沿用上一版的结果。
预算要能换算成失败安装数
120MB 不是拍出来的数字,它来自三条约束的交点:安装转化、下载成功率、首包月流量。
按 42 万新增安装、97.5% 的下载成功率折算,每月约有 1.05 万次安装在下完首包后校验失败。弱网环境下成功率是 92%,失败比例还会更高。首包变大,失败的分母不变、分子上升,绝对值会先变得难看。
流量账更直接。42 万新增乘以 120MB,约为 48.07TiB/月;这款游戏的 CDN 月下载量约 201TB,首包一项就占了约 23.9%。这不是精确的成本核算,因为 CDN 下载还包含增量热更和重复下载,但它足以说明首包不是一个可以随手加 10MB 的地方。
| 首包变化 | 月新增下载增量 | 说明 |
|---|---|---|
| +10MB | 约 4.0TiB | 按 42 万新增折算 |
| +20MB | 约 8.0TiB | 失败安装的绝对值同步上升 |
| 维持 120MB | 0 | 当前基线 |
这张表只算了新增用户的首次下载,没有算老用户的增量热更。消消的增量热更是双周一次、平均 12MB,它和首包是两条独立的流量曲线;把两者混在一起,会得到「首包变大但总流量没变」的错误结论。
预算的正确用法是硬门禁:计划阶段算出 admittedBytes,超过预算就阻止发布,而不是打印一行警告后继续。警告会被忽略,门禁不会。对于确实不可延迟的 boot 资源,允许超预算,但必须同时产出告警,让超出的原因进入发布评审。
门禁还要区分「计划体积」和「商店体积」。商店会对安装包做压缩和签名,最终下载体积与原始资源之和不完全相等;门禁应该使用与商店口径一致的估算值,而不是资源字节数的简单相加,否则会出现计划体积与商店展示体积的偏差。
首包与分包的接口
首包不是孤岛。分包的资源可以引用首包里的资源,反之则不成立。这条单向依赖是首包能够独立启动的前提:如果首包里的某个界面引用了分包里的纹理,玩家在分包下载完成前就会看到缺失。
《潮汐消消》的依赖图平均出度 3.2、最大深度 6,意味着一个资源平均牵着三个下游,最长的引用链有六层。首包准入必须做闭包检查:每个 boot 资源的完整依赖闭包都要在首包内,不能出现「资源在包里、它的依赖在包外」的情况。
- 首包内每个资源的依赖闭包都在首包内;
- 分包可以依赖首包,首包不得依赖任何分包;
- 按需资源不得被
boot资源直接引用; - 首包清单带独立版本号,增量热更不改变包边界。
最后一条容易被忽略。双周一次、平均 12MB 的增量热更如果频繁调整首包边界,差量会退化成接近整包的下载;商店整包每 8 周才发一次,整包差量 55 到 70MB,这个量级只有在边界稳定时才有意义。
闭包检查还可以反向使用:如果某个资源被大量 deferred 资源引用,说明它是一份共享底座,放进首包可能比放进任何一个分包都更划算。这时要比较的是「首包多一份」和「多个分包各带一份」的总体积,而不是只看首包预算。
实现
下面的文件把三条准入标准落成一次确定性计算。输入是资源清单,每项声明类型、字节数、层级、引用次数和是否有兜底;输出是准入列表、拒绝列表及原因、按类型的汇总和告警。
// file: src/resource/first-package.ts
// tier: L1
// verify: node --test test/resource/first-package.test.ts
export type AssetKind = "texture" | "audio" | "config" | "other";
export type Criticality = "boot" | "first-session" | "deferred";
export interface AssetEntry {
readonly id: string;
readonly kind: AssetKind;
readonly bytes: number;
readonly criticality: Criticality;
readonly references: number;
readonly hasFallback: boolean;
}
export interface RejectedAsset {
readonly id: string;
readonly kind: AssetKind;
readonly bytes: number;
readonly reason: "deferred" | "fallback" | "budget";
}
export interface KindBreakdown {
readonly kind: AssetKind;
readonly admittedCount: number;
readonly admittedBytes: number;
readonly rejectedCount: number;
readonly rejectedBytes: number;
}
export interface AdmissionPlan {
readonly admitted: readonly AssetEntry[];
readonly rejected: readonly RejectedAsset[];
readonly admittedBytes: number;
readonly rejectedBytes: number;
readonly budgetBytes: number;
readonly remainingBytes: number;
readonly overBudget: boolean;
readonly byKind: readonly KindBreakdown[];
readonly warnings: readonly string[];
}
export const MB = 1024 * 1024;
export const DEFAULT_FIRST_BUDGET = 120 * MB;
const KIND_ORDER: readonly AssetKind[] = ["texture", "audio", "config", "other"];
const CRITICALITY_RANK: Record<Criticality, number> = {
boot: 0,
"first-session": 1,
deferred: 2,
};
function density(asset: AssetEntry): number {
return asset.references / Math.max(asset.bytes, 1);
}
function validate(assets: readonly AssetEntry[]): void {
const seen = new Set<string>();
for (const asset of assets) {
if (seen.has(asset.id)) throw new Error(`duplicate asset: ${asset.id}`);
seen.add(asset.id);
if (!Number.isInteger(asset.bytes) || asset.bytes < 0) {
throw new Error(`invalid bytes for ${asset.id}: ${asset.bytes}`);
}
if (!Number.isInteger(asset.references) || asset.references < 0) {
throw new Error(`invalid references for ${asset.id}: ${asset.references}`);
}
}
}
export function admitFirstPackage(
assets: readonly AssetEntry[],
options: { readonly budgetBytes?: number } = {},
): AdmissionPlan {
const budget = options.budgetBytes ?? DEFAULT_FIRST_BUDGET;
if (!Number.isInteger(budget) || budget < 0) {
throw new Error(`invalid budget: ${budget}`);
}
validate(assets);
const ordered = [...assets].sort((a, b) => {
const rank = CRITICALITY_RANK[a.criticality] - CRITICALITY_RANK[b.criticality];
if (rank !== 0) return rank;
const spread = density(b) - density(a);
if (spread !== 0) return spread;
if (a.bytes !== b.bytes) return a.bytes - b.bytes;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
const admitted: AssetEntry[] = [];
const rejected: RejectedAsset[] = [];
const warnings: string[] = [];
let admittedBytes = 0;
let rejectedBytes = 0;
for (const asset of ordered) {
if (asset.criticality === "deferred") {
rejected.push({
id: asset.id,
kind: asset.kind,
bytes: asset.bytes,
reason: "deferred",
});
rejectedBytes += asset.bytes;
continue;
}
const fits = asset.bytes <= budget - admittedBytes;
if (asset.criticality === "first-session" && !fits) {
const reason = asset.hasFallback ? "fallback" : "budget";
rejected.push({
id: asset.id,
kind: asset.kind,
bytes: asset.bytes,
reason,
});
rejectedBytes += asset.bytes;
if (reason === "budget") {
warnings.push(
`${asset.id} (${asset.bytes}B) has no fallback and does not fit the budget`,
);
}
continue;
}
admitted.push(asset);
admittedBytes += asset.bytes;
}
const overBudget = admittedBytes > budget;
if (overBudget) {
warnings.push(
`first package ${(admittedBytes / MB).toFixed(2)}MB exceeds budget ${(budget / MB).toFixed(2)}MB`,
);
}
const byKind = KIND_ORDER.map((kind): KindBreakdown => {
let admittedCount = 0;
let admittedKindBytes = 0;
for (const asset of admitted) {
if (asset.kind !== kind) continue;
admittedCount += 1;
admittedKindBytes += asset.bytes;
}
let rejectedCount = 0;
let rejectedKindBytes = 0;
for (const item of rejected) {
if (item.kind !== kind) continue;
rejectedCount += 1;
rejectedKindBytes += item.bytes;
}
return {
kind,
admittedCount,
admittedBytes: admittedKindBytes,
rejectedCount,
rejectedBytes: rejectedKindBytes,
};
});
return {
admitted,
rejected,
admittedBytes,
rejectedBytes,
budgetBytes: budget,
remainingBytes: Math.max(0, budget - admittedBytes),
overBudget,
byKind,
warnings,
};
}
export function formatAdmission(plan: AdmissionPlan): string {
const lines = ["asset\tkind\tbytes\tdecision"];
for (const asset of plan.admitted) {
lines.push(`${asset.id}\t${asset.kind}\t${asset.bytes}\tadmit`);
}
for (const item of plan.rejected) {
lines.push(`${item.id}\t${item.kind}\t${item.bytes}\treject:${item.reason}`);
}
for (const item of plan.byKind) {
lines.push(
`kind:${item.kind}\t${item.kind}\t${item.admittedBytes}B\tadmit=${item.admittedCount},reject=${item.rejectedCount}`,
);
}
lines.push(
`summary\tfirst\t${plan.admittedBytes}B\tbudget=${plan.budgetBytes}B,over=${plan.overBudget ? "yes" : "no"}`,
);
return lines.join("\n");
}
admitFirstPackage 先做输入校验,重复 id、负数字节、非整数引用次数都会直接抛错,避免脏清单进入打包流程。排序键依次是层级、引用密度、字节数、id,四级键保证结果与输入顺序无关。
准入循环里,deferred 无条件拒绝;first-session 在剩余预算不足时,有兜底记为 fallback,无兜底记为 budget 并生成告警。boot 不参与预算判断,始终进包,因此它可能把 overBudget 置为真。这是有意的:启动资源没有替代方案,超预算应该暴露给发布评审,而不是被静默裁剪。
byKind 在准入结束后重新统计,拒绝项也进入统计,类型账能同时看到「进了多少」和「被挡了多少」。如果只统计准入侧,一个类型的资源全被拒绝时,报表上会完全消失,看起来像这一类资源不存在。
formatAdmission 输出逐项决策、按类型的汇总和一行总账。它适合直接贴进构建日志,也适合作为版本评审的附件,让「为什么这个资源没进首包」有一个可查的答案。实现刻意不读文件系统,也不解析真实资源格式,因此准入规则可以在评审阶段用表格推演。
运行结果与边界
用一组小样本运行,输入与输出如下。字节数是示意值,不是《潮汐消消》的真实单资源体积:
asset kind bytes decision
boot-ui texture 10 admit
boot-config config 5 admit
tutorial texture 40 admit
home-bg texture 50 reject:fallback
voice-intro audio 60 reject:deferred
kind:texture texture 50B admit=2,reject=1
kind:audio audio 0B admit=0,reject=1
kind:config config 5B admit=1,reject=0
kind:other other 0B admit=0,reject=0
summary first 55B budget=60B,over=no
这张表能回答三个问题:哪些资源进了首包、没进的分别因为什么、各类型占了多少。真实接入时,字节数会放大到 MB 量级,结构不变。
第一行到第五行是逐项决策,admit 与 reject:<原因> 是仅有的两种状态。原因只有三种:deferred 表示首次会话不出现,fallback 表示有兜底所以可以推迟,budget 表示没有兜底且放不下。第三种是唯一需要人工介入的,它会同时出现在告警里。
中间四行是按类型的汇总,最后一行是总账。over=no 只说明准入体积没有超过预算,不说明清单是完整的;完整性由闭包检查和产品评审保证,不由这个函数保证。
第一条边界是压缩与格式转换。准入只比较字节数,不负责纹理压缩、音频编码和字体裁剪;这些发生在打包之前,改变的是每个资源的体积,而不是它该不该进首包。第二条边界是下载与重试:准入不处理分包下载、断点续传和失败重试,下载调度属于 9.2。第三条边界是产品判断:脚本无法知道某个界面是否属于首次会话,层级必须来自人工确认的配置。
第四条边界是跨平台差异。Android 60%、iOS 40% 的平台分布意味着同一份首包在两个商店的压缩与审核规则下体积不同;准入给出的是资源侧预算,商店侧还需要按平台各留一份余量。
与相邻章节的关系
8.1 给出了依赖图与闭包,8.3 给出了包的分层、阶段与唯一归属;本节把「首包」这一层单独拿出来,回答它的准入标准。9.2 会把视角移到首包之外,讨论分包与按需下载的调度;9.3 则把两款游戏的首包、分包、更新与流量放在同一张账上对照。
本节不讨论热更内容边界和商店政策,那属于 10.x;也不讨论资源加载器的引用计数与卸载,那属于运行时资源管理。首包准入只回答一个问题:首次安装时,哪些资源必须在。
小结
- 首包的目标不是尽量小,而是首次会话的最小闭环:必经路径全在包里,其余交给分包或按需。
- 准入按三条标准排序:冷启动必经、首次会话会用到、缺失能否降级,对应 boot、first-session、deferred 三层。
- 同层资源用引用密度排序,并以字节数和 id 兜底,保证同一份清单在两次构建中结果一致。
- 120MB 预算要能折算成失败安装数与月流量,超预算应阻断发布,boot 资源例外但必须告警。
- 首包依赖必须闭包自足,分包可以依赖首包,首包不得反向依赖分包。
阅读导航:上一节:8.3 打包策略与冗余控制 · 下一节:9.2 分包与按需下载的调度 。
常见错误与边界
- 把首包当成展示柜。主界面能看到不等于首次会话必需,皮肤、活动背景应进分包或按需下载。
- 按资源类型拍脑袋分配。纹理文件最多,但配置才决定启动流程;按类型切会把首包塞满低价值大图。
- 只按体积从小到大排序。小图会挤掉真正影响体验的中等资源,首包看起来不大,体验却更差。
- 把层级和密度混成一个分数。又小又热的可选资源会挤掉启动配置,这是最危险的一类排序错误。
- 排序键缺少 id 兜底。密度和体积都相同的资源依赖输入顺序,首包内容会随遍历顺序漂移。
- 首包依赖分包。玩家在分包下载完成前就会看到缺失,首包不再能独立启动。
- 超预算只告警不阻断。警告会被忽略,门禁不会;超预算必须进入发布评审。
- boot 资源被预算裁掉。启动资源没有替代方案,裁掉它的后果是黑屏或崩溃,而不是体验下降。
- 把压缩参数带进准入判断。换一次压缩就会得到不同的准入结果,清单不可复现。
- 边界:准入不负责下载与重试。它只回答首次安装带什么,下载调度由 9.2 处理。
- 边界:准入不替代产品判断。层级来自人工确认的配置,脚本只做确定性取舍。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。