同一个角色,出现在两个包里
《潮汐方舟》的首包是 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 | 基础包 | 无前置,安装时已就位 |
| 1 | 4 个战斗场景包、角色包 | 都只依赖基础包,可并发预下载 |
| 2 | 语音包 | 依赖角色包,角色资源就位后才能校验语音引用 |
阶段划分的价值在于把「串行等待」压缩到最少。如果所有包排成一条链,玩家要等 6 个包全部下完才能进战斗;按阶段展开后,第 1 阶段的 5 个包可以并发,只有真正有依赖的语音包需要等待。并发不是无限的:同时下载太多包会争抢带宽,反而拉长首个可用包的就绪时间,所以阶段内还需要一个并发上限。
依赖图必须拒绝环。两个包互相 requires,下载器无法决定谁先开始;这类环通常来自「A 包补了 B 包的资源、B 包又补了 A 包」的临时补丁,必须在打包期失败,而不是在客户端死锁。
冗余控制:每份资源只能有一个持有者
冗余的判定单位是资源 id,不是文件路径。同一份资源被两个包声明,无论路径是否相同,都算重复。控制冗余有三种策略,代价各不相同。
| 策略 | 做法 | 适用场景 | 代价 |
|---|---|---|---|
| 唯一归属 | 只留一个包持有,其它包依赖它 | 资源小、依赖关系清晰 | 引入包间依赖,下载路径变长 |
| 提升共享包 | 抽到公共包,所有包引用 | 多包高频共用 | 公共包变大,人人下载 |
| 运行时按需 | 不预置,使用时单独拉取 | 低频、非核心资源 | 首次使用有等待 |
唯一归属是最常用的策略。规则要确定:按层级优先,首包高于必需包,必需包高于可选包;同层级按包 id 字典序。这样同一份资源在两次构建中会落到同一个包,增量清单不会因为遍历顺序变化而整体失效。
归属确定后,其它包里的同 id 条目会被移除,并记录成一条重复项:资源 id、涉及哪些包、单份体积、浪费字节。浪费字节等于 体积 × (出现次数 - 1)。这个数字要单独进报表,而不是混进总资源;总资源是内容的真实体积,浪费字节是组织方式带来的额外下载量。
提升共享包适用于「被三个以上包引用」的资源,判断标准与 8.2 的共享阈值一致。它的代价是把体积转移给所有玩家,所以只对高频共用资源使用。运行时按需适用于语音、过场动画这类低频内容,代价是首次使用时有一次明显的等待,需要 UI 提前提示。
三种策略可以组合,但必须有优先级:先尝试唯一归属,因为它的额外下载量最小;归属会导致包间强依赖且依赖链过长时,再考虑提升共享;只有低频、可等待的资源才走运行时按需。
包边界要稳定,增量更新才便宜
方舟每周下发平均 45MB 的增量热更,每 6 周发一次商店整包,整包差量 60 到 90MB。这些数字成立的前提是包边界稳定:如果这次把一批资源从角色包挪到战斗包,下次又挪回来,两次构建的差量都会接近整包体积。
包边界稳定的三个条件:
- 归属规则确定。同一份资源在任何一次构建中都落到同一个包,不依赖文件遍历顺序或哈希随机种子。
- 组键稳定。包的 id 不随版本号、日期或构建号变化,否则下载器会把整个包当成新包。
- 切分依据稳定。按闭包和引用次数切分,而不是按本次提交的文件列表切分;后者会让包内容随提交顺序漂移。
稳定性不是拒绝变化,而是让变化可解释。资源从场景包提升为共享包,是一次显式的归属变更,应该在构建报告里单独列出;因为新增引用而自动改变归属,则是规则的自然结果,也应该能追溯到具体资源。两种情况都不应该表现为「差量包突然变大」这种事后现象。
首包预算同样是硬约束。方舟首包 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 首包边界:什么必须进包 。
常见错误与边界
- 按资源类型切包。类型相同的资源未必同时需要,按类型切会把战斗模型塞进角色包,造成跨包重复。
- 没有唯一归属。同一份资源被两个包声明,下载量翻倍,增量补丁也要下发两次。
- 归属规则不确定。依赖遍历顺序或哈希随机种子选持有者,两次构建得到不同结果,差量整体失效。
- 把层级当依赖。可选包也可能依赖必需包,只按层级排序会让下载器在缺资源时启动。
- 允许包依赖成环。两个包互相
requires,下载器无法决定先后,最终表现为永久等待。 - 阶段内无限并发。同时下载太多包会争抢带宽,首个可用包的就绪时间反而变长。
- 包 id 带版本号或日期。下载器会把每次发布都当成新包,增量更新退化成整包下载。
- 只统计总资源,不统计浪费字节。总资源没变不代表下载量没变,重复项必须单独计量。
- 首包超限只告警不阻断。超出 350MB 的预算继续发布,下载成功率和安装转化率会一起下降。
- 边界:包计划不负责断点续传和重试。它只给顺序和阶段,传输层的策略由下载器决定。
- 边界:包计划不保证跨版本兼容。旧客户端可能没有新包依赖的资源,这需要清单版本号和最小客户端版本约束。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。