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 在《方块冲刺》里:切关时的引用账
把三节串起来看一次切关的完整引用账:
- 进入关卡 2:
bootTasks求闭包,runPool拉资源,每个资源put进管理器(计数 0)。 - 精灵创建时
retain,计数变 1;同一份blocks.png被 20 个砖块精灵引用,计数变 20。 - 关卡 2 结束,销毁所有砖块精灵,每个
release一次,计数回到 0。 - 计数归零后不立刻删——关卡 3 若还要用
blocks.png,直接命中缓存。 - 内存超预算时,
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 布局系统:锚点、对齐与适配 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。