《移动游戏产品的架构·网络、资源与热更新》8.1 资源依赖图与循环检测

资源不是一棵树:同一张图集会被多个场景、多个角色共享,依赖关系因此变成有向图。本节从一次打包脚本无法结束的故障出发,说明如何用三色标记找出循环、用规范化路径去重、用拓扑序稳定输出,并把最大深度与闭包大小变成打包前就能检查的指标,避免问题拖到发布前才暴露。

一次跑不完的打包

《潮汐方舟》的资源总量是 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. 把资源目录当依赖图。目录只表达物理位置,跨目录共享和目录内孤儿都会被漏掉。
  2. 用已访问集合做环检测。它只能回答有没有环,无法给出环路径,还会把正常的菱形汇合误判成环。
  3. 只检测长度大于 1 的环。自引用往往来自导出工具默认值,必须单独覆盖。
  4. 环路径不去重。同一个环从不同起点输出不同字符串,CI 报告会随遍历顺序抖动。
  5. 拓扑序不稳定。输入相同但输出顺序变化,会让增量打包的清单哈希整体失效。
  6. 在有环图上计算最大深度。最长路径在环上没有定义,必须先拒绝环再统计。
  7. 闭包只按目录收集。目录外的共享资源会缺失,目录内的孤儿资源会白占体积。
  8. 动态引用没有进图。配置表里的字符串 id 如果构建期不解析,闭包一定偏小。
  9. 边界:图不记录版本和平台差异。同一 id 在不同平台可能指向不同文件,这属于资源清单层。
  10. 边界:图不负责加载顺序和引用计数。拓扑序只给出依赖先后,真正的并发加载、超时和卸载由运行时决定。
  11. 边界:环不是靠白名单解决的。需要互相引用时应该拆出中间资源或事件,而不是允许检测器跳过。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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