《Cocos 游戏开发入门》10.3 引用计数与资源释放

资源拉进来了,还得知道什么时候放它走:用引用计数跟踪谁在用一份资源,实现 retain/release 与零引用回收,讲清循环引用为何会泄漏、如何排查,再补一层 LRU 缓存兜住内存上限;末节对照 Cocos Creator 3.8 的 addRef / decRef / releaseAsset。

10.3 引用计数与资源释放

上一节我们学会了把资源并发拉进内存,还学会了提前预加载下一关。但预加载有一个必须回答的问题:这些提前拉进来的资源,什么时候可以扔? 如果只拉不放,玩十关之后内存里堆着十关的贴图,中低端机直接崩。这一节给 mini2d 装上引用计数,把「谁在用、何时放」这件事讲清楚。末节给出 Cocos Creator 3.8 的对照。

10.3.1 为什么不能「用完就删」

最直觉的做法是「关卡切换时把上一关的资源全删了」。但它会立刻撞上两个反例:

  • 共享资源:blocks.png 被关卡 1 和关卡 2 同时引用。关卡 1 结束时删掉它,关卡 2 正在用的精灵就变成了空白。
  • 预加载资源:结算页提前拉好关卡 3 的贴图,如果关卡 2 结束的清理逻辑把「所有非当前关资源」都删了,预加载白做。

所以「能不能删」不能由时间决定,只能由还有没有人引用决定。这正是引用计数(reference counting)的用途:每份资源记一个数字,谁要用就 +1,用完就 -1,减到 0 才真正回收。

10.3.2 资源管理器与引用计数

把上一节的加载结果交给一个统一的管理器保管。每个条目记录三样东西:资源本体、引用计数、以及最近一次使用时间(留给 LRU 用):

// src/assets/resource-manager.ts
interface CacheEntry {
  value: unknown;
  refCount: number;
  size: number;
  lastUsed: number;
}

export class ResourceManager {
  private readonly entries = new Map<string, CacheEntry>();
  private readonly budget: number;
  private used = 0;

  constructor(budget: number) {
    this.budget = budget;
  }

  /** 资源刚进内存时登记,引用计数从 0 起算 */
  put(uuid: string, value: unknown, size: number): void {
    const existing = this.entries.get(uuid);
    if (existing) {
      existing.value = value;
      return;
    }
    this.entries.set(uuid, { value, refCount: 0, size, lastUsed: performance.now() });
    this.used += size;
  }

  /** 取用资源:命中则计数 +1,并刷新使用时间 */
  retain(uuid: string): unknown {
    const entry = this.entries.get(uuid);
    if (!entry) return null;
    entry.refCount += 1;
    entry.lastUsed = performance.now();
    return entry.value;
  }

  /** 归还资源:计数 -1,归零后成为可回收候选 */
  release(uuid: string): void {
    const entry = this.entries.get(uuid);
    if (!entry) return;
    entry.refCount = Math.max(0, entry.refCount - 1);
    entry.lastUsed = performance.now();
    if (entry.refCount === 0) this.collect();
  }

  /** 当前内存里是否还留着这份资源 */
  has(uuid: string): boolean {
    return this.entries.has(uuid);
  }
}

注意 retain 返回的是资源本体,调用方拿到它就能画、能播。取用与归还必须成对出现,这是整节最重要的纪律,后面 10.3.5 会讲怎么用工具逼自己守规矩。

10.3.3 计数归零不等于立刻释放

release 里我们没有直接删条目,而是调用 collect。这里有个刻意的取舍:引用计数归零的资源,先留着当缓存。

原因很现实。玩家在关卡 1 与关卡 2 之间来回切,每次切关都把贴图删了再拉一遍,是纯粹的浪费——网络请求、解码、上传显存全白做。更好的策略是「零引用资源先留在内存里,等内存紧张时再按 LRU 淘汰」。所以 collect 只在超出预算时才动手,且只挑零引用的:

  private collect(): void {
    if (this.used <= this.budget) return; // 未超预算:零引用资源留作缓存
    const candidates = [...this.entries.entries()]
      .filter(([, e]) => e.refCount === 0)
      .sort((a, b) => a[1].lastUsed - b[1].lastUsed); // 最久未用的排前面

    for (const [uuid, entry] of candidates) {
      if (this.used <= this.budget) break;
      this.evict(uuid, entry);
    }
  }

  private evict(uuid: string, entry: CacheEntry): void {
    this.entries.delete(uuid);
    this.used -= entry.size;
    entry.value = null; // 断开引用,交给 GC
  }

这套设计把两个问题合成了一个:引用计数决定「能不能删」,LRU 决定「该不该删」。引用计数保证正确性(不会删掉别人在用的),LRU 保证内存上限(不会无限膨胀)。缺了任何一个都会出问题:只有引用计数,零引用资源永远留着,内存只涨不落;只有 LRU,会误删正在用的资源。

10.3.4 句柄模式:把成对调用变成习惯

retain 与 release 必须成对,但「必须」两个字管不住人。工程上常用**句柄(handle)**来降低写错的概率:retain 不再返回裸资源,而是返回一个带 dispose 的小对象,把「归还」变成一个显式动作:

export interface AssetHandle<T> {
  readonly uuid: string;
  readonly value: T;
  dispose: () => void;
}

export function acquire<T>(mgr: ResourceManager, uuid: string): AssetHandle<T> | null {
  const value = mgr.retain(uuid) as T | null;
  if (value === null) return null;
  let disposed = false;
  return {
    uuid,
    value,
    dispose: () => {
      if (disposed) return; // 幂等:重复 dispose 只生效一次
      disposed = true;
      mgr.release(uuid);
    },
  };
}

有了句柄,业务代码里就不再见得到 mgr.release('img:blocks') 这种字符串调用,取而代之的是持有者自己在生命周期结束时 handle.dispose()。disposed 标志还顺手解决了「重复释放」的问题——重复 release 会把计数减成负数(我们用 Math.max(0, ...) 兜住了,但那是事后补救)。

更严格的团队会配上 lint 规则:谁 acquire 了却没在任何路径上 dispose,静态检查直接报错。TypeScript 的类型系统做不到「线性类型」(保证恰好用一次),但句柄把「隐式配对」变成「显式对象」,已经能挡掉大部分手滑。

10.3.5 循环引用与泄漏排查

引用计数有一个众所周知的死穴:循环引用。如果 A 持有 B、B 又持有 A,两者的计数都降不到 0,永远无法回收。在游戏里最常见的形态是「回调闭包」:

// 反例:每个方块都注册了指向关卡对象的回调,关卡对象又持有所有方块
level.onScoreChange = () => {
  this.playSound(); // 闭包捕获了 this(关卡),而关卡持有 blocks 数组
};

blocks 里的每个方块都持有 level,level 又持有 blocks,环就形成了。破环的办法有三条:

  • 单向化:父子关系只保留一个方向。子对象记父用弱引用(WeakRef),父对象正常持有子。
  • 显式注销:关卡销毁时遍历所有方块,block.onScoreChange = null,主动断链。
  • 事件总线:回调不直接挂到对方身上,而是注册到全局事件总线,销毁时统一取消订阅。

光靠自觉不够,得给管理器加一个泄漏探针:定期 dump 出「引用计数长期大于 0、且很久没被 retain」的条目。正常的在用资源会不断刷新 lastUsed,只有泄漏的环会一直不动:

  /** 诊断用:列出疑似泄漏的条目 */
  suspectLeaks(idleMs = 60_000): string[] {
    const now = performance.now();
    const out: string[] = [];
    for (const [uuid, e] of this.entries) {
      if (e.refCount > 0 && now - e.lastUsed > idleMs) out.push(uuid);
    }
    return out;
  }

在开发者控制台(第 15 章)里定时打印这个列表,比事后对着内存快照猜要高效得多。泄漏排查的第一原则是「让它自己报出来」,而不是等人去翻。

10.3.6 在《方块冲刺》里:切关时的引用账

把三节串起来看一次切关的完整引用账:

  1. 进入关卡 2:bootTasks 求闭包,runPool 拉资源,每个资源 put 进管理器(计数 0)。
  2. 精灵创建时 retain,计数变 1;同一份 blocks.png 被 20 个砖块精灵引用,计数变 20。
  3. 关卡 2 结束,销毁所有砖块精灵,每个 release 一次,计数回到 0。
  4. 计数归零后不立刻删——关卡 3 若还要用 blocks.png,直接命中缓存。
  5. 内存超预算时,collect 按 LRU 淘汰最久未用的零引用资源。

这里有个容易踩的坑:同一份资源被 retain 多次,就必须 release 相同次数。如果精灵销毁时只 release 一次而创建时 retain 了两次(比如构造函数与挂载各调一次),计数永远回不到 0,泄漏就发生了。所以工程上的惯例是:把 retain/release 收敛到少数几个明确的位置——资源系统与对象生命周期各一处,绝不在业务逻辑里随手调用。

给切关逻辑写个单测,把引用账钉死。假数据只需三份资源,断言「计数归零后条目仍在缓存、超预算后才被淘汰」:

import { describe, expect, it } from 'vitest';
import { ResourceManager } from '../src/assets/resource-manager';

describe('资源引用计数', () => {
  it('计数归零不立即释放,超预算才按 LRU 淘汰', () => {
    const mgr = new ResourceManager(100); // 预算 100 字节
    mgr.put('a', { img: 'a' }, 60);
    mgr.put('b', { img: 'b' }, 60); // 合计 120,已超预算

    mgr.retain('a');
    mgr.retain('b');
    expect(mgr.suspectLeaks(0)).toEqual([]); // 两个都在用,不算泄漏

    mgr.release('b');
    // b 计数归零且总用量超预算,应被淘汰;a 仍被持有,必须留下
    expect(mgr.has('a')).toBe(true);
    expect(mgr.has('b')).toBe(false);
  });

  it('重复释放不会把计数减成负数', () => {
    const mgr = new ResourceManager(1000);
    mgr.put('a', {}, 10);
    mgr.retain('a');
    mgr.release('a');
    mgr.release('a'); // 多还一次
    expect(mgr.suspectLeaks(0)).toEqual([]);
  });
});

第一条断言最有价值:它同时验证了「引用计数保护在用资源」与「LRU 只淘汰零引用项」两条规则。能写出这种断言,说明你真正理解了引用计数的语义,而不是照着 API 抄了一遍。

10.3.7 Cocos 对照:addRef、decRef 与 releaseAsset

Cocos Creator 3.8 把引用计数做在 Asset 基类上。cc.d.ts 中 Asset 提供 addRef(): Asset、decRef(autoRelease?: boolean): Asset 与只读的 refCount getter:

import { assetManager, resources, SpriteFrame } from 'cc';

let coinFrame: SpriteFrame | null = null;

resources.load('textures/coin/spriteFrame', SpriteFrame, (err, sf) => {
  if (err) return;
  coinFrame = sf;
  sf.addRef(); // 手动持有,阻止被自动释放
  console.log(sf.refCount); // 当前引用数

  // 不再需要时归还,计数归零且允许自动释放则被回收
  sf.decRef();
});

// 也可以由管理器直接释放某个资源,不必自己维护计数
if (coinFrame) assetManager.releaseAsset(coinFrame);

引擎的自动释放(auto-release)是我们在 10.3.3 里手写逻辑的对应物:切换场景时,若某资源计数归零且未被 addRef 保护,引擎会把它清掉。assetManager.releaseAll() 与 Bundle.releaseAll() 则是「一次性清空」的兜底:

我们的方法Cocos 3.8备注
retain(uuid)asset.addRef()返回 Asset 自身,可链式
release(uuid)asset.decRef(autoRelease?)归零后按 autoRelease 决定
引用计数读数asset.refCount只读 getter
evict(uuid)assetManager.releaseAsset(asset)立即释放指定资源
清空缓存assetManager.releaseAll() / Bundle.releaseAll()释放整包资源
LRU 预算淘汰assetManager.cacheManager 的缓存策略引擎内置

一个关键差别:Cocos 里 preload 会自动加引用计数,所以 10.2 里那句「preload 后忘了 release 会一直占内存」在这里得到了印证——addRef / decRef 必须成对。而 instantiate 出的节点若引用了资源,destroy 时会自动 decRef,这也是引擎帮你守纪律的方式。

到这里,mini2d 的资源系统闭环了:能列出(10.1)、能并发拉(10.2)、能安全放(10.3)。资源一到位,接下来就该用它们搭界面了——下一节我们进入 UI,从最基础的布局与适配讲起。

阅读导航:上一节:10.2 异步加载、进度与预加载 · 下一节:11.1 布局系统:锚点、对齐与适配 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画