《移动游戏产品的架构·客户端与移动运行时》6.3 资源泄漏定位与回归

泄漏之所以长期存在,是因为它通常不崩溃、重启就好、也难以复现。本节用 LRU 容量、引用计数快照与场景前后差值,把「这一局比上一局多了什么」变成可断言、可定位到具体 key 的结构化报告,并给出能在 CI 里重复执行、失败时能指出 key 的往返测试。

事故:重启就好了,所以一直没修

《潮汐消消》的家园系统有一个持续了两个版本的投诉:连续进出家园十几次后,手机会变热、切场景变慢,杀掉进程重开就恢复。因为不崩溃、不报错、重启能解决,这个问题在需求评审里始终排不上优先级。

直到一次活动版本,低端机在连续进出家园后开始出现闪退。团队把内存曲线拉出来,看到的是一条锯齿向上的线:每次进入家园抬升,退出后只回落一部分,下一次再抬升。没有单点尖峰,也没有 OOM 日志,只有「退出时少还了一点」的持续累积。

这类问题最难的地方不是修复,而是定位。泄漏点可能是一个没解绑的事件监听、一个没有上限的缓存、一条被闭包捕获的纹理引用。靠代码走查只能碰运气,靠用户反馈只能知道「哪个场景」;需要的是在场景边界自动回答:这一次退出,比上一次进入前多了哪些 key、每个 key 多了多少字节、引用计数是多少。

泄漏的三种形态

把资源泄漏按快照特征分类,比按代码位置分类更有用,因为同一类特征往往对应同一种修复手法。

形态运行表现快照特征常见修复位置
无主引用退出后资源仍在,引用计数不归零同 key 的 refs 大于 0,且持有者已销毁事件解绑、回调清理、定时器取消
缓存无上限进出越多次越高,重启归零条目数量持续增加,单条 refs 为 0给缓存设容量与淘汰策略
键增长同一功能每次生成新 keyadded 列表出现带序号或时间戳的 key资源 key 规范化、复用池

三种形态对应三种不同的断言。第一种要断言「场景退出后 refs 归零」;第二种要断言「缓存总量不超过容量」;第三种要断言「同一场景第二次进入不产生新 key」。只写一个「内存不涨」的总量断言,无法区分它们,也无法指出修复方向。

案例设定里消消的总资源是 480MB,场景多、单局短,第二种形态最常见:为了减少加载卡顿,团队给图集加了一层缓存,却没设上限,家园的装饰图集越积越多。

方舟单场 8–12 分钟、战斗资源更重,第一种形态更常见:战斗结束后的特效回调仍持有立绘引用,退出战斗后引用计数不归零。

LRU 缓存必须有容量与保护位

缓存不是泄漏的免罪符。一个没有容量上限的缓存,本质上是把泄漏写进了设计。mga-lab 的 LruResourceCache 用三个约束把缓存变成可审计的结构。

  • 容量:构造时给出字节上限,所有 put 都按「替换后的总量」预估,超出就淘汰。
  • 保护位:pinned 表示常驻资源,refs 表示正在使用;两者都不参与淘汰。
  • 显式失败:当所有条目都受保护、无法腾出空间时,put 返回 capacity-exhausted,并保持缓存不变,而不是悄悄超限。

淘汰顺序按最近使用时间,同 tick 时按 key 排序,保证测试可复现。get 会更新使用时间,peek 不会;这个区分让「只读诊断」不会污染 LRU 顺序。

保护位也带来一个必须接受的边界:缓存可能因为大量 pinned 条目而无法工作。此时正确做法是让上层看到失败并调整预加载策略,而不是让缓存突破容量。把失败暴露出来,比让它变成一条缓慢上升的内存曲线要好。

引用计数快照与场景前后差值

快照只记录四件事:key、字节数、引用计数、是否 pinned。它不记录纹理格式、显存地址或引擎对象,因为泄漏定位关心的是「谁还在」,不是「长什么样」。

两个快照相减,得到五类结果:新增、移除、变大、变小、总字节差。解释规则要固定下来,否则同一份报告会被不同的人读成不同结论。

差值含义是否泄漏
added基线没有、现在有的 key通常是;除非是本次场景应加载的资源
removed基线有、现在没有的 key不是;可能是正常淘汰
grew同 key 字节变大通常是;检查是否重复加载或尺寸变化
shrank同 key 字节变小不是;可能是降级或替换
byteDelta 为正总量增加需要结合 added 与 grew 判断

关键是把「场景应加载的资源」排除在泄漏之外。做法是在场景退出、资源释放并清理未引用条目之后取基线;下一次同一场景退出后取快照,与基线比较。此时任何 added 或 grew 都是异常,removed 与 shrank 不算。

可回归的泄漏测试

泄漏测试要能进 CI,就必须满足三个条件:不依赖真实设备、不依赖时间、失败时能指出 key。mga-lab 的 SceneLeakGuard 只做三件事:标记基线、取当前快照、比较并返回报告。

一个典型的往返用例是:加载常驻资源并 pin、标记基线、进入场景加载若干资源、retain/release、调用 dropUnreferenced 清理未引用条目、再次取快照、断言报告通过。如果场景代码忘了 release,条目会以 refs 大于 0 留在快照里,报告给出 added 列表和字节差;如果场景重复加载同一个 key,报告给出 grew。

容差参数只用于吸收合法的缓存波动,默认是 0。把它设成「几百 KB 让测试稳定」之前,先确认波动来自哪里;大多数波动其实是 key 不稳定,应该修 key,而不是放宽阈值。

可运行模型:LRU、快照差值与泄漏守卫

下面的 cache.ts 把容量、保护位、快照与差值收敛到一个文件。diffSnapshots 是纯函数,输入两份快照,输出五类差值;SceneLeakGuard 保存基线并在检查时返回带字节差与明细的报告。

// file: src/resource/cache.ts
// tier: L1
// verify: node --test test/resource/cache.test.ts

import { fail, ok, type Result } from "../core/result.ts";

export type CacheError = "invalid-size" | "missing-key" | "capacity-exhausted";

export interface CacheOptions {
  readonly pinned?: boolean;
  readonly refs?: number;
}

export interface CacheEntryView {
  readonly key: string;
  readonly sizeBytes: number;
  readonly refs: number;
  readonly pinned: boolean;
  readonly lastUsedTick: number;
}

export interface SnapshotEntry {
  readonly key: string;
  readonly sizeBytes: number;
  readonly refs: number;
  readonly pinned: boolean;
}

export interface CacheSnapshot {
  readonly totalBytes: number;
  readonly entries: readonly SnapshotEntry[];
}

export interface EvictionReport {
  readonly evictedKeys: readonly string[];
  readonly freedBytes: number;
  readonly totalBytes: number;
}

interface CacheEntry {
  sizeBytes: number;
  refs: number;
  pinned: boolean;
  lastUsedTick: number;
}

export class LruResourceCache {
  #capacityBytes: number;
  #entries = new Map<string, CacheEntry>();
  #tick = 0;

  constructor(capacityBytes: number) {
    if (!Number.isInteger(capacityBytes) || capacityBytes <= 0) {
      throw new RangeError(`capacityBytes must be a positive integer, got ${capacityBytes}`);
    }
    this.#capacityBytes = capacityBytes;
  }

  get capacityBytes(): number {
    return this.#capacityBytes;
  }

  get totalBytes(): number {
    let sum = 0;
    for (const entry of this.#entries.values()) sum += entry.sizeBytes;
    return sum;
  }

  put(key: string, sizeBytes: number, options: CacheOptions = {}): Result<CacheEntryView, CacheError> {
    if (!Number.isInteger(sizeBytes) || sizeBytes < 0) return fail("invalid-size", `${key}: ${sizeBytes}`);
    if (sizeBytes > this.#capacityBytes) {
      return fail("capacity-exhausted", `${key}: ${sizeBytes} > ${this.#capacityBytes}`);
    }
    const existing = this.#entries.get(key);
    const projected = this.totalBytes - (existing?.sizeBytes ?? 0) + sizeBytes;
    const victims = this.#pickVictims(projected, key);
    if (victims === null) return fail("capacity-exhausted", `${key}: protected entries fill the cache`);

    for (const victim of victims) this.#entries.delete(victim);
    this.#tick += 1;
    this.#entries.set(key, {
      sizeBytes,
      refs: options.refs ?? existing?.refs ?? 0,
      pinned: options.pinned ?? existing?.pinned ?? false,
      lastUsedTick: this.#tick,
    });
    return ok(this.#view(key));
  }

  get(key: string): CacheEntryView | null {
    const entry = this.#entries.get(key);
    if (entry === undefined) return null;
    this.#tick += 1;
    entry.lastUsedTick = this.#tick;
    return this.#view(key);
  }

  peek(key: string): CacheEntryView | null {
    return this.#entries.has(key) ? this.#view(key) : null;
  }

  retain(key: string): Result<CacheEntryView, CacheError> {
    const entry = this.#entries.get(key);
    if (entry === undefined) return fail("missing-key", key);
    entry.refs += 1;
    return ok(this.#view(key));
  }

  release(key: string): Result<CacheEntryView, CacheError> {
    const entry = this.#entries.get(key);
    if (entry === undefined) return fail("missing-key", key);
    entry.refs = Math.max(0, entry.refs - 1);
    return ok(this.#view(key));
  }

  setPinned(key: string, pinned: boolean): Result<CacheEntryView, CacheError> {
    const entry = this.#entries.get(key);
    if (entry === undefined) return fail("missing-key", key);
    entry.pinned = pinned;
    return ok(this.#view(key));
  }

  evict(key: string): Result<number, CacheError> {
    const entry = this.#entries.get(key);
    if (entry === undefined) return fail("missing-key", key);
    if (entry.pinned || entry.refs > 0) return fail("capacity-exhausted", `${key}: protected`);
    this.#entries.delete(key);
    return ok(entry.sizeBytes);
  }

  trim(): EvictionReport {
    const candidates = [...this.#entries.entries()]
      .filter(([, entry]) => !entry.pinned && entry.refs === 0)
      .sort((a, b) => a[1].lastUsedTick - b[1].lastUsedTick || a[0].localeCompare(b[0]));
    const victims: string[] = [];
    let remaining = this.totalBytes;
    for (const [key, entry] of candidates) {
      if (remaining <= this.#capacityBytes) break;
      victims.push(key);
      remaining -= entry.sizeBytes;
    }
    return this.#remove(victims);
  }

  dropUnreferenced(): EvictionReport {
    const keys = [...this.#entries.entries()]
      .filter(([, entry]) => !entry.pinned && entry.refs === 0)
      .map(([key]) => key)
      .sort();
    return this.#remove(keys);
  }

  snapshot(): CacheSnapshot {
    const entries = [...this.#entries.entries()]
      .map(([key, entry]) => ({ key, sizeBytes: entry.sizeBytes, refs: entry.refs, pinned: entry.pinned }))
      .sort((a, b) => a.key.localeCompare(b.key));
    return { totalBytes: this.totalBytes, entries };
  }

  #remove(keys: readonly string[]): EvictionReport {
    let freedBytes = 0;
    for (const key of keys) {
      freedBytes += this.#entries.get(key)?.sizeBytes ?? 0;
      this.#entries.delete(key);
    }
    return { evictedKeys: [...keys], freedBytes, totalBytes: this.totalBytes };
  }

  #pickVictims(projected: number, protectedKey: string | null): string[] | null {
    if (projected <= this.#capacityBytes) return [];
    const candidates = [...this.#entries.entries()]
      .filter(([key, entry]) => key !== protectedKey && !entry.pinned && entry.refs === 0)
      .sort((a, b) => a[1].lastUsedTick - b[1].lastUsedTick || a[0].localeCompare(b[0]));
    let remaining = projected;
    const victims: string[] = [];
    for (const [key, entry] of candidates) {
      victims.push(key);
      remaining -= entry.sizeBytes;
      if (remaining <= this.#capacityBytes) return victims;
    }
    return null;
  }

  #view(key: string): CacheEntryView {
    const entry = this.#entries.get(key);
    if (entry === undefined) throw new Error(`missing cache entry: ${key}`);
    return {
      key,
      sizeBytes: entry.sizeBytes,
      refs: entry.refs,
      pinned: entry.pinned,
      lastUsedTick: entry.lastUsedTick,
    };
  }
}

export interface SizeChange {
  readonly key: string;
  readonly beforeBytes: number;
  readonly afterBytes: number;
}

export interface SnapshotDiff {
  readonly added: readonly string[];
  readonly removed: readonly string[];
  readonly grew: readonly SizeChange[];
  readonly shrank: readonly SizeChange[];
  readonly byteDelta: number;
}

export function diffSnapshots(before: CacheSnapshot, after: CacheSnapshot): SnapshotDiff {
  const beforeMap = new Map(before.entries.map((entry) => [entry.key, entry.sizeBytes]));
  const afterMap = new Map(after.entries.map((entry) => [entry.key, entry.sizeBytes]));
  const added = [...afterMap.keys()].filter((key) => !beforeMap.has(key)).sort();
  const removed = [...beforeMap.keys()].filter((key) => !afterMap.has(key)).sort();
  const grew: SizeChange[] = [];
  const shrank: SizeChange[] = [];
  for (const [key, beforeBytes] of beforeMap) {
    const afterBytes = afterMap.get(key);
    if (afterBytes === undefined || afterBytes === beforeBytes) continue;
    const change = { key, beforeBytes, afterBytes };
    if (afterBytes > beforeBytes) grew.push(change);
    else shrank.push(change);
  }
  grew.sort((a, b) => a.key.localeCompare(b.key));
  shrank.sort((a, b) => a.key.localeCompare(b.key));
  return {
    added,
    removed,
    grew,
    shrank,
    byteDelta: after.totalBytes - before.totalBytes,
  };
}

export interface LeakReport {
  readonly scene: string;
  readonly ok: boolean;
  readonly byteDelta: number;
  readonly diff: SnapshotDiff;
}

export class SceneLeakGuard {
  #baselines = new Map<string, CacheSnapshot>();

  mark(scene: string, snapshot: CacheSnapshot): void {
    this.#baselines.set(scene, snapshot);
  }

  hasBaseline(scene: string): boolean {
    return this.#baselines.has(scene);
  }

  check(scene: string, snapshot: CacheSnapshot, toleranceBytes = 0): LeakReport {
    const baseline = this.#baselines.get(scene);
    if (baseline === undefined) throw new Error(`no baseline for scene: ${scene}`);
    const diff = diffSnapshots(baseline, snapshot);
    const leaked = diff.added.length > 0 || diff.grew.length > 0;
    const overTolerance = diff.byteDelta > toleranceBytes;
    return { scene, ok: !leaked && !overTolerance, byteDelta: diff.byteDelta, diff };
  }
}

配套测试 test/resource/cache.test.ts 覆盖十条路径:LRU 淘汰顺序、pin 保护、引用保护、直接淘汰受保护条目、非法尺寸、快照差值、场景往返通过、忘记释放被发现、pin 资源不算泄漏,以及缺失基线直接抛错。运行命令是 node --test test/resource/cache.test.ts。

这里是模型,不是设备上的测量。真实引擎的纹理、音频与脚本对象有各自的释放时机,快照只能覆盖进入缓存的那部分;模型负责把「场景往返后结构应还原」写成断言,引擎侧的释放验证仍要回到设备矩阵。

与相邻章节的关系

6.1 的引用计数回答「谁在持有」,本节把持有关系固化成快照并比较;没有 6.1 的 owner 与代际,快照里的 refs 无法解释。6.2 的降级会改变快照,回归测试要区分「降级导致的 shrank」与「泄漏导致的 grew」。垃圾回收与引擎内存模型的细节可以参考内存与 GC ,远程热更引入的版本差异可以参考远程热更 。

小结

  • 泄漏通常不崩溃、重启就好,必须靠场景边界的结构化快照定位,而不是靠用户反馈。
  • 三种形态要分别断言:无主引用的 refs 不归零、缓存条目持续增加、同一场景产生新 key。
  • LRU 必须有容量与保护位;无法腾出空间时返回 capacity-exhausted,不要悄悄超限。
  • 快照只记录 key、字节、refs、pinned;差值中的 added 与 grew 才是泄漏信号,removed 与 shrank 不是。
  • 泄漏测试要能在 CI 重复执行:固定基线、固定场景、失败时给出 key 与字节差。

阅读导航:上一节:6.2 内存水位与低端机降级 · 下一节:7.1 UI 栈、弹窗队列与返回键语义 。

常见错误与边界

  • 用总量断言代替结构断言。总量不涨也可能是一个 key 变大、另一个被淘汰;必须比较 added、grew 与 refs。
  • 给缓存不设上限。没有容量的缓存就是把泄漏写进设计;容量、pin、refs 三个约束缺一不可。
  • 把 removed 与 shrank 当泄漏。正常淘汰和降级都会让快照变小;只有 added 与 grew 需要解释。
  • 基线取在场景加载之后。基线必须取在释放与清理之后,否则本次场景的资源会被当成基线的一部分,泄漏永远测不出来。
  • 用容差掩盖不稳定的 key。带序号或时间戳的 key 会让每次快照都出现 added;应先修 key 规范,再谈阈值。
  • 边界:快照不覆盖引擎内部对象与显存。未被缓存接管的纹理、音频句柄与脚本闭包不在模型内,需要引擎侧工具与设备验证补位。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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