一次跑不完的打包
《潮汐方舟》的资源总量是 1,840MB,资源文件数 86,000,分成 6 个包:4 个战斗场景各 260MB、角色 260MB、语音 190MB。打包机每晚把美术提交的资源展开成依赖清单,再按清单切包。某天晚上,打包任务从凌晨跑到早上还没有结束,日志停在同一个目录上反复打印资源名。
排查后发现是一条三方都觉得自己无辜的引用链:新上的活动头像引用了角色立绘,角色立绘引用了通用 UI 图集,而通用 UI 图集为了让活动入口有一个默认角标,又引用了活动头像。打包脚本用递归展开依赖,没有记录「当前正在展开谁」,于是沿着这个环一直走下去。资源总量没有变化,文件数也没有变化,变的是图的形状。
资源依赖天然是图,不是树。树的分支不会重新汇合,而游戏资源大量共享:一张图集被十几个界面引用,一套骨骼被多个角色复用,一份配置表被多个关卡读取。只要承认共享,就必须承认汇合;只要承认汇合,就必须承认可能出现的环。依赖图的第一项工程任务,不是算体积,而是把形状说清楚并拒绝病态形状。
节点与边:先把方向约定死
在讨论算法之前,必须先约定边的方向。我们采用「持有者指向被依赖者」:一个模型节点有一条边指向它使用的材质,材质再指向贴图。这个方向与「加载时先加载谁」相反,但与「我缺什么」一致,排查时最直观。
| 资源类型 | 节点含义 | 出边指向 |
|---|---|---|
| 纹理图集 | 一张合图 | 图集内的精灵、材质参数 |
| 模型 | 网格与材质槽 | 网格、材质、贴图 |
| 动画 | 动画片段 | 骨骼、曲线、事件表 |
| 配置 | 表或关卡数据 | 引用的资源 id、子表 |
| 音频 | 音频事件 | 音频文件、混音总线 |
方向一旦固定,三个概念就能精确定义。出度是「这个资源直接需要多少东西」;入度是「有多少资源需要它」;根是没有入边的资源,通常对应一个场景入口、一个界面预制体或一份启动配置;叶子是出度为零的资源,通常是真正落盘的贴图、音频和表格。
《潮汐消消》的资源文件数 12,800,依赖图平均出度 3.2,最大深度 6;《潮汐方舟》是 86,000 个文件,平均出度 4.1,最大深度 9。这两个数字是案例设定值,不是线上实测。它们说明的是两种产品的资源组织差异:休闲产品的资源更扁平,单个界面拉起的链条短;中重度产品的角色、场景、特效层级更深,一次加载可能穿过九层依赖。
平均出度只描述「平均每个节点连几条边」,不描述形状。两个图可以有相同的平均出度,一个是宽而浅的扇出,一个是窄而深的链。打包和加载对这两种形状的敏感点完全不同:扇出影响单次闭包的体积,深度影响加载链的长度和失败暴露的时间。因此统计必须同时给出出度和深度,缺一不可。
循环检测:三色标记而不是已访问集合
最容易被写错的循环检测,是用一个 visited 集合做深度优先遍历:访问过的节点不再进入。这个做法能判断「有没有环」,却说不清「环在哪里」,而且会把「从另一条路径再次到达」误判成环。
正确的做法是三色标记。白色表示尚未访问,灰色表示正在当前递归栈上,黑色表示已经完成。遍历到一个灰色节点,说明存在一条从它出发又回到它的路径,这就是环。灰色集合本身就是环的候选路径,从栈里截出对应的一段即可。
| 状态 | 含义 | 遇到时的动作 |
|---|---|---|
| 白色 | 还没进入 | 递归进入,标记为灰 |
| 灰色 | 在当前递归栈上 | 发现回边,截取栈片段作为环 |
| 黑色 | 子树已全部完成 | 直接跳过,不重复遍历 |
这个区分解决了一个常见误报:菱形依赖。场景 A 同时依赖 B 和 C,B 和 C 都依赖 D。D 会被访问两次,但第二次遇到时它已经是黑色,不是灰色,因此不会被当成环。用 visited 集合的实现如果在第一次访问 D 时就标记「已访问」,第二次会直接跳过,结果虽然碰巧不错;但如果在 D 尚未完成时从另一条路径到达,就会把正常的汇合误报成环。
环的路径还需要规范化。同一个环从不同起点出发会得到不同的字符串,例如 a -> b -> c -> a 和 b -> c -> a -> b 是同一个环。做法是去掉路径末尾重复的起点,旋转到字典序最小的节点开头,再补回起点。这样去重后的环集合是稳定的,CI 里的失败信息也不会因为遍历顺序变化而漂移。
自引用是长度为 1 的环,必须单独覆盖。一个图集把自己列为依赖,往往来自导出工具的默认值或复制粘贴,肉眼很难发现;如果检测器只处理两个以上节点的环,它就会漏过去。
拓扑序与深度:打包顺序和加载顺序共用一份结果
拓扑序回答「先处理谁」。打包时,依赖必须先于持有者写入清单,否则切包阶段拿不到被依赖资源的归属;卸载时顺序相反,先释放持有者再释放被依赖者,避免出现悬空引用。两者用的是同一份拓扑序,只是一个正着读、一个倒着读。
我们实现的是带确定性排序的深度优先后序:先按资源 id 排序,再依次访问未完成的节点,递归完成所有依赖后才把当前节点加入结果。这样得到的顺序满足「依赖在前」,并且在输入相同的情况下逐字节稳定。稳定性不是洁癖,而是增量打包的前提:顺序一变,清单的哈希就变,整包差量会被误判成全部更新。
最大深度则用记忆化计算。对每个节点,深度定义为「到最远叶子的边数」,叶子的深度是 0。直接递归会把共享子图重复计算很多次,规模到 86,000 个节点时可能退化成指数级;记录每个节点已经算出的深度,整体复杂度回到线性。
深度是有业务含义的。它决定一次加载要串多少次 I/O,也决定失败会在第几层才暴露:如果第一层就缺失,玩家立刻看到错误;如果第九层才缺失,可能已经进入了战斗。方舟的最大深度是 9,意味着从场景入口到最深的动画依赖要穿过九层;消消是 6,链条短,问题更容易在加载页内暴露。
闭包:一个场景真正需要多少资源
有了图和拓扑序,就能回答打包最关心的问题:从某个场景入口出发,到底需要哪些资源。这就是依赖闭包。实现上是一次带去重的深度或广度遍历,从根集合出发收集所有可达节点,最后排序输出。
闭包和「场景目录下有哪些文件」不是一回事。目录是物理组织,闭包是逻辑需求。一个场景目录里可能躺着上一版遗留的资源,它们不在任何根的可达集合里,属于孤儿;反过来,场景目录之外也可能有被引用的共享资源,比如通用图集和公共配置。只按目录切包,前者会白白占体积,后者会在运行时缺资源。
| 检查项 | 用图能回答的问题 | 用目录回答不了的原因 |
|---|---|---|
| 场景体积 | 该场景闭包内资源的字节总和 | 目录可能含孤儿资源 |
| 缺失依赖 | 闭包内是否有节点不在任何包中 | 目录不表达跨目录引用 |
| 共享程度 | 某资源被多少个根可达 | 目录没有入边信息 |
| 冗余资源 | 哪些节点从任何根都不可达 | 需要全局遍历才能发现 |
闭包大小也是判断「依赖声明是否诚实」的手段。策划在关卡配置里声明了一组资源,程序按声明加载;如果声明的集合明显小于图的闭包,运行时就会在某个深层节点上缺资源。把声明的根集合跑一遍闭包,和实际打包清单做差集,能在提测前发现大部分「本地能跑、线上缺图」的问题。
实现
下面的文件只做四件事:建图并校验引用、用三色标记找环、给出稳定的拓扑序和统计、从根集合展开闭包。它不读取任何工程文件,也不依赖其它模块,输入是节点数组,输出是可直接断言的结构。
// file: src/resource/dependency-graph.ts
// tier: L1
// verify: node --test test/resource/dependency-graph.test.ts
export type ResourceKind =
| "texture"
| "model"
| "animation"
| "audio"
| "config"
| "other";
export interface ResourceNode {
readonly id: string;
readonly kind: ResourceKind;
readonly deps: readonly string[];
}
export interface DependencyGraph {
readonly nodes: ReadonlyMap<string, ResourceNode>;
readonly order: readonly string[];
}
export interface GraphStats {
readonly nodes: number;
readonly edges: number;
readonly roots: number;
readonly leaves: number;
readonly avgOutDegree: number;
readonly maxDepth: number;
readonly maxDepthRoot: string;
}
export type GraphErrorCode = "duplicate" | "unknown" | "cycle" | "empty";
export class GraphError extends Error {
readonly code: GraphErrorCode;
constructor(code: GraphErrorCode, message: string) {
super(message);
this.name = "GraphError";
this.code = code;
}
}
function canonicalCycle(cycle: readonly string[]): string {
const body = cycle.slice(0, -1);
let best = 0;
for (let i = 1; i < body.length; i += 1) {
if (body[i] < body[best]) best = i;
}
const rotated = [...body.slice(best), ...body.slice(0, best)];
return [...rotated, rotated[0]].join(" -> ");
}
export function buildGraph(nodes: readonly ResourceNode[]): DependencyGraph {
const map = new Map<string, ResourceNode>();
for (const node of nodes) {
if (node.id.length === 0) {
throw new GraphError("empty", "resource id must not be empty");
}
if (map.has(node.id)) {
throw new GraphError("duplicate", `duplicate resource: ${node.id}`);
}
map.set(node.id, node);
}
for (const node of nodes) {
for (const dep of node.deps) {
if (!map.has(dep)) {
throw new GraphError("unknown", `unknown dependency: ${node.id} -> ${dep}`);
}
}
}
return { nodes: map, order: [...map.keys()].sort() };
}
export function detectCycles(graph: DependencyGraph): string[][] {
const color = new Map<string, number>();
const stack: string[] = [];
const cycles: string[][] = [];
const seen = new Set<string>();
const visit = (id: string): void => {
color.set(id, 1);
stack.push(id);
const node = graph.nodes.get(id);
if (node === undefined) {
throw new GraphError("unknown", `unknown resource: ${id}`);
}
for (const dep of node.deps) {
const state = color.get(dep) ?? 0;
if (state === 0) {
visit(dep);
} else if (state === 1) {
const start = stack.indexOf(dep);
const cycle = [...stack.slice(start), dep];
const key = canonicalCycle(cycle);
if (!seen.has(key)) {
seen.add(key);
cycles.push(cycle);
}
}
}
stack.pop();
color.set(id, 2);
};
for (const id of graph.order) {
if ((color.get(id) ?? 0) === 0) visit(id);
}
return cycles;
}
export function topoOrder(graph: DependencyGraph): string[] {
const cycles = detectCycles(graph);
if (cycles.length > 0) {
throw new GraphError("cycle", `cycle detected: ${canonicalCycle(cycles[0])}`);
}
const done = new Set<string>();
const result: string[] = [];
const visit = (id: string): void => {
if (done.has(id)) return;
const node = graph.nodes.get(id);
if (node === undefined) {
throw new GraphError("unknown", `unknown resource: ${id}`);
}
for (const dep of [...node.deps].sort()) visit(dep);
done.add(id);
result.push(id);
};
for (const id of graph.order) visit(id);
return result;
}
export function analyzeGraph(graph: DependencyGraph): GraphStats {
if (detectCycles(graph).length > 0) {
throw new GraphError("cycle", "cannot analyze a cyclic graph");
}
const incoming = new Map<string, number>();
for (const id of graph.order) incoming.set(id, 0);
let edges = 0;
for (const id of graph.order) {
const node = graph.nodes.get(id);
if (node === undefined) {
throw new GraphError("unknown", `unknown resource: ${id}`);
}
edges += node.deps.length;
for (const dep of node.deps) {
incoming.set(dep, (incoming.get(dep) ?? 0) + 1);
}
}
const depth = new Map<string, number>();
const depthOf = (id: string): number => {
const cached = depth.get(id);
if (cached !== undefined) return cached;
const node = graph.nodes.get(id);
if (node === undefined) {
throw new GraphError("unknown", `unknown resource: ${id}`);
}
let best = 0;
for (const dep of node.deps) {
best = Math.max(best, 1 + depthOf(dep));
}
depth.set(id, best);
return best;
};
let roots = 0;
let leaves = 0;
let maxDepth = 0;
let maxDepthRoot = "";
for (const id of graph.order) {
const node = graph.nodes.get(id);
if (node === undefined) {
throw new GraphError("unknown", `unknown resource: ${id}`);
}
if ((incoming.get(id) ?? 0) === 0) roots += 1;
if (node.deps.length === 0) leaves += 1;
const value = depthOf(id);
if (value > maxDepth || (value === maxDepth && (maxDepthRoot === "" || id < maxDepthRoot))) {
maxDepth = value;
maxDepthRoot = id;
}
}
const nodes = graph.order.length;
const avgOutDegree = nodes === 0 ? 0 : Math.round((edges / nodes) * 100) / 100;
return { nodes, edges, roots, leaves, avgOutDegree, maxDepth, maxDepthRoot };
}
export function expandClosure(
graph: DependencyGraph,
roots: readonly string[],
): string[] {
const seen = new Set<string>();
const stack = [...roots].sort().reverse();
while (stack.length > 0) {
const id = stack.pop() as string;
if (seen.has(id)) continue;
const node = graph.nodes.get(id);
if (node === undefined) {
throw new GraphError("unknown", `unknown resource: ${id}`);
}
seen.add(id);
for (const dep of [...node.deps].sort().reverse()) {
if (!seen.has(dep)) stack.push(dep);
}
}
return [...seen].sort();
}
export function formatGraphReport(stats: GraphStats, cycleCount = 0): string {
return [
"metric\tvalue",
`nodes\t${stats.nodes}`,
`edges\t${stats.edges}`,
`avgOutDegree\t${stats.avgOutDegree.toFixed(2)}`,
`maxDepth\t${stats.maxDepth}`,
`maxDepthRoot\t${stats.maxDepthRoot}`,
`roots\t${stats.roots}`,
`leaves\t${stats.leaves}`,
`cycles\t${cycleCount}`,
].join("\n");
}
建图阶段做两项校验:id 不能为空且不能重复,依赖必须存在于图中。这两项看起来琐碎,却能挡住最常见的数据错误——复制节点时忘了改 id、删资源时漏删引用。错误用带 code 的 GraphError 抛出,调用方可以按 duplicate、unknown、cycle、empty 分别处理,而不是解析错误字符串。
detectCycles 用递归实现三色标记,递归深度等于图的最大深度。案例里的最大深度是 9,距离调用栈上限很远;如果资源图可能深到上千层,应该改成显式栈,逻辑不变。topoOrder 在排序前先检测环,有环直接抛出,避免返回一个看似完整、实际漏掉节点的顺序。
analyzeGraph 计算入度、出度、根、叶和最大深度。它同样拒绝有环的图,因为「最长路径」在有环时没有定义。expandClosure 从根集合出发做去重遍历,返回排序后的 id 列表;未知的根会抛错,而不是静默忽略,否则打包清单会悄悄少掉一整棵子树。
运行结果与边界
用一个五节点的小图运行,formatGraphReport 的输出形如:
metric value
nodes 5
edges 5
avgOutDegree 1.00
maxDepth 2
maxDepthRoot a
roots 2
leaves 1
cycles 0
这十行信息足够回答打包前的第一轮问题:规模多大、平均扇出多少、最深链条从哪个根开始、有没有环。真正接入工程时,节点数会变成 12,800 或 86,000,输出结构不变,只是数值变化。
边界要写清楚。依赖图表达的是「静态引用」,不表达运行时才决定的动态加载:例如配置表里写了一个字符串 id,程序在运行时才去加载。这类引用必须在构建期被解析成边,否则闭包会偏小。图也不记录版本和平台差异,同一个资源 id 在 Android 与 iOS 上可能是不同文件,这属于资源清单层,不属于图结构层。
环的处理策略没有中间地带。打包期发现环必须让构建失败,并把规范化后的完整路径打印出来;运行期如果因为动态引用发现环,应该拒绝加载并记录诊断,而不是带着环继续跑。确实需要互相引用的两个资源,正确做法是引入中间资源或事件解耦,把环拆成两条单向边,而不是在检测器里加白名单。
与相邻章节的关系
8.2 会在这张图上做分组:用入度判断共享程度,用闭包决定场景包内容,用出度估算单个包的资源数量。8.3 会把闭包和深度接到打包预算上,讨论 6 个包、1,490MB 的分包如何排序以及如何控制重复资源。9.1 则从产品侧回答「哪些资源必须进首包」,本节只提供判断所需的图结构。
本节不讨论资源加载器、引用计数和卸载时机,那是运行时资源管理的话题;也不讨论热更清单的差量算法,那属于 10.x 的范围。把图建对、把环挡住、把闭包算准,是后面所有打包决策的共同前提。
小结
- 资源依赖是图不是树:共享导致汇合,汇合可能导致环。
- 边的方向统一为「持有者指向被依赖者」,根、叶、出度、入度才有稳定含义。
- 循环检测用三色标记,灰色回边才是环;已访问集合既说不清位置,也会误报菱形依赖。
- 环路径要旋转到字典序最小的起点再去重,失败信息才不会随遍历顺序漂移。
- 拓扑序要确定,闭包要按根展开;目录结构不能替代逻辑依赖。
阅读导航:上一节:7.3 流量预算与弱网适配 · 下一节:8.2 资源分组:共享、场景与产品 。
常见错误与边界
- 把资源目录当依赖图。目录只表达物理位置,跨目录共享和目录内孤儿都会被漏掉。
- 用已访问集合做环检测。它只能回答有没有环,无法给出环路径,还会把正常的菱形汇合误判成环。
- 只检测长度大于 1 的环。自引用往往来自导出工具默认值,必须单独覆盖。
- 环路径不去重。同一个环从不同起点输出不同字符串,CI 报告会随遍历顺序抖动。
- 拓扑序不稳定。输入相同但输出顺序变化,会让增量打包的清单哈希整体失效。
- 在有环图上计算最大深度。最长路径在环上没有定义,必须先拒绝环再统计。
- 闭包只按目录收集。目录外的共享资源会缺失,目录内的孤儿资源会白占体积。
- 动态引用没有进图。配置表里的字符串 id 如果构建期不解析,闭包一定偏小。
- 边界:图不记录版本和平台差异。同一 id 在不同平台可能指向不同文件,这属于资源清单层。
- 边界:图不负责加载顺序和引用计数。拓扑序只给出依赖先后,真正的并发加载、超时和卸载由运行时决定。
- 边界:环不是靠白名单解决的。需要互相引用时应该拆出中间资源或事件,而不是允许检测器跳过。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。