4.2 动态加载与引用计数
4.1 讲清了资源放哪、怎么分包。但资源真正进入内存,靠的是两种方式:静态引用(在属性面板把资源拖进组件的槽,随场景一起加载)和动态加载(运行时用 API 按路径取)。卷二的原则是:首包需要的用静态引用,按需的用动态加载。这一节把动态加载的 API 与它背后的引用计数讲透。
引用计数是卷二最容易出内存泄漏的地方。加载了不释放,内存一路涨到小游戏平台被系统杀掉;释放早了,屏幕上出现一片空白精灵。这一节的目标是让读者能准确回答:这个资源现在被谁引用、什么时候能安全释放。
4.2.1 三种加载 API
| API | 从哪加载 | 典型用途 |
|---|---|---|
resources.load(path, type, cb) | assets/resources/ | 快速原型;卷二基本不用 |
bundle.load(path, type, cb) | 指定 Bundle | 关卡配置、关卡贴图 |
assetManager.loadRemote(url, cb) | 任意 HTTP 地址 | 不在包里的远程图片/音频 |
三者都返回 void,结果通过回调给出,回调签名统一是 (err, data)。err 非空时必须提前返回,否则 data 是 null,后续访问会抛错——这是回调式 API 最常见的崩溃来源。
import { assetManager, resources, JsonAsset, SpriteFrame, AudioClip } from 'cc';
// 1) resources:从 assets/resources/ 加载
resources.load('configs/levels', JsonAsset, (err, asset) => {
if (err) { console.error(err.message); return; }
console.log(asset.json);
});
// 2) bundle:从自定义 Bundle 加载
const bundle = assetManager.getBundle('bundle-levels');
bundle?.load('textures/coin/spriteFrame', SpriteFrame, (err, frame) => {
if (err) { return; }
console.log(frame.name);
});
// 3) loadRemote:从任意 URL 加载
assetManager.loadRemote<AudioClip>('https://cdn.example.com/bgm.mp3', (err, clip) => {
if (err) { return; }
console.log(clip.name);
});
三段代码的差别只在「资源从哪来」:resources 是内置 Bundle 的快捷方式,bundle 是显式指定分包,loadRemote 绕开 Bundle 直接走 HTTP。注意第二段的 spriteFrame 后缀——图集里的子资源要用「图集路径 + /spriteFrame」这种带后缀的路径。
4.2.2 把回调包成 Promise
回调嵌套几层就变成「回调地狱」。Cocos 3.8 的资源 API 仍是回调式,所以卷二统一写一层 Promise 包装:
import { resources, JsonAsset } from 'cc';
export function loadJson(path: string): Promise<JsonAsset> {
return new Promise((resolve, reject) => {
resources.load(path, JsonAsset, (err, asset) => {
if (err) { reject(err); return; }
resolve(asset);
});
});
}
包装之后就能 await,和「加载 Bundle → 读配置 → 建关卡」的流程天然契合。注意每次 load 都会让资源的引用计数 +1,所以包装函数不该被无脑重复调用——4.2.4 会讲怎么配一层引用账本。
4.2.3 loadDir:批量加载一个目录
关卡配置通常是一堆 JSON,逐个 load 太啰嗦。loadDir 一次加载目录下所有指定类型的资源:
import { resources, JsonAsset } from 'cc';
resources.loadDir('configs', JsonAsset, (err, assets) => {
if (err) { return; }
for (const a of assets) { console.log(a.name); }
});
loadDir 同样会把每一个资源都算作一次引用。目录里有 50 个关卡表就 +50 次引用,全释放时要逐个 decRef——所以卷二只在「确实要一次用完整组」时才 loadDir,否则还是按需 load 单个。
4.2.4 引用计数:addRef / decRef
Cocos 的每个 Asset 都有一个引用计数。动态 load 会让计数 +1,addRef() 也 +1,decRef() 减 1。计数归零后资源才可能被回收。手动持有资源时,规则是「谁需要它活着,谁就 addRef」:
import { assetManager, SpriteFrame } from 'cc';
export function useFrame(frame: SpriteFrame): void {
frame.addRef();
}
export function releaseFrame(frame: SpriteFrame): void {
frame.decRef();
assetManager.releaseAsset(frame);
}
三条必须记住的规则:
load之后的资源已经带着 1 次引用,不需要再addRef;把引用传给另一个模块时才需要addRef,让计数反映「两个持有者」。releaseAsset(asset)只减引用,不立即销毁。计数没归零,资源仍在内存里,再次load会命中缓存、立即回调。- 依赖资源会连带计数。一张
SpriteFrame依赖它的Texture2D,释放SpriteFrame时依赖的Texture2D计数也会减。
| 操作 | 引用计数变化 |
|---|---|
load('a') 首次成功 | +1 |
load('a') 再次(命中缓存) | +1 |
preload('a') | 不变 |
赋给 sprite.spriteFrame | +1(引擎自动) |
addRef() | +1 |
decRef() | −1 |
releaseAsset(a) | −1 |
| 节点销毁、组件释放 | −1(引擎自动) |
这张表解释了「为什么我释放了它还在」:只要表里还有任何一行是「+」,计数就不为零。排查时先问「谁还在引用它」,而不是反复调 decRef。
4.2.5 释放的坑
| 你以为 | 实际 |
|---|---|
releaseAsset 立即释放内存 | 只减引用,归零才释放 |
assetManager.releaseUnusedAssets() 可用 | 3.8 已无此 API,别再找 |
释放了 SpriteFrame 屏幕就空 | 场景里还在用的会被自动 addRef,不会空 |
clearCache() 能清掉正在用的资源 | 只清未被引用的缓存 |
第三行是个反直觉但重要的机制:当资源被挂到场景节点上(比如 sprite.spriteFrame = frame),引擎会自动为它 addRef。所以「我 decRef 了它怎么还占内存」和「我释放了它怎么还显示着」两种疑问,答案都是「还有别的引用者」。排查内存问题的正确姿势是用 assetManager.assets 看当前缓存里每个资源的引用数,而不是猜。
4.2.6 一个可单测的引用计数管理器
引擎的引用计数是 C++ 层的,业务代码看不到全貌。卷二在 assets/scripts/core/ 下自建一层路径级引用账本,纯 TypeScript、零 cc 依赖,可以直接用 vitest 单测(这是卷二里少见的 L1 代码):
export interface RefEntry {
path: string;
count: number;
}
export class RefBook {
private readonly entries = new Map<string, number>();
retain(path: string): number {
const next = (this.entries.get(path) ?? 0) + 1;
this.entries.set(path, next);
return next;
}
release(path: string): number {
const cur = this.entries.get(path) ?? 0;
const next = Math.max(0, cur - 1);
if (next === 0) { this.entries.delete(path); } else { this.entries.set(path, next); }
return next;
}
needRelease(path: string): boolean {
return !this.entries.has(path);
}
snapshot(): RefEntry[] {
return [...this.entries.entries()].map(([path, count]) => ({ path, count }));
}
}
业务层每次 load 成功后 retain(path),每次离开关卡 release(path);needRelease 为真时才真正调引擎的 decRef。这层账本的价值不在省内存,而在可测试:写一个 vitest 断言 retain('a') 两次后 release 一次仍为真持有、release 两次后 needRelease 为真,就把「谁忘了释放」这类 bug 挡在了运行时之外。
4.2.7 preload:先下后用的预加载
preload 只把资源下载并解析到缓存,不增加引用,之后 load 会立即命中缓存:
import { resources, SpriteFrame } from 'cc';
resources.preload('textures/bg', SpriteFrame, (err, items) => {
if (err) { return; }
console.log('preloaded', items.length);
});
卷二用它做「关卡切换前预下载下一关资源」:在玩家还在跑当前关时就 preload 下一关的贴图,切关时 load 几乎无等待。预加载不增引用,意味着预下载的资源可能在没人引用时被自动回收——所以预加载后要在真正用到时 load 一次,别指望 preload 过的资源会一直待命。
4.2.8 编辑器里看资源 UUID(需编辑器,不可命令行复现)
本节涉及 .meta 工程文件,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 资源管理器里选中任一资源,属性检查器底部能看到它的 UUID。这个 UUID 就是场景与预制体里保存的引用(__uuid__ 字段),也是构建后 config.json 里的键:
{
"ver": "1.1.0",
"uuid": "8f0c1e2a-3b4d-4e5f-9a0b-1c2d3e4f5a6b",
"importer": "json",
"files": [".json"]
}
打开 assets/configs/levels.json.meta 会看到上面这个结构。UUID 与文件路径无关:把 levels.json 改名成 stage-01.json,UUID 不变,所有引用照旧;但如果删掉 .meta 重新导入,UUID 会变,引用全丢。这就是 4.1.1 强调「用编辑器移动资源」的根本原因——编辑器会同步维护 .meta。
4.2.9 一个完整的资源生命周期
把关卡资源从进到出串起来,就是一个标准的「加载—使用—释放」三段。卷二把它封在 LevelSession 里(RefBook 是 4.2.6 那个路径级账本):
import { assetManager, AssetManager, JsonAsset, SpriteFrame } from 'cc';
export class LevelSession {
private readonly refs = new RefBook();
private bundle: AssetManager.Bundle | null = null;
private readonly bundleName: string;
constructor(bundleName: string) {
this.bundleName = bundleName;
}
async open(levelPath: string): Promise<JsonAsset> {
const bundle = await this.ensureBundle();
const asset = await new Promise<JsonAsset>((resolve, reject) => {
bundle.load(levelPath, JsonAsset, (err, a) => (err ? reject(err) : resolve(a)));
});
this.refs.retain(levelPath);
return asset;
}
async openFrame(path: string): Promise<SpriteFrame> {
const bundle = await this.ensureBundle();
const frame = await new Promise<SpriteFrame>((resolve, reject) => {
bundle.load(path, SpriteFrame, (err, a) => (err ? reject(err) : resolve(a)));
});
this.refs.retain(path);
return frame;
}
close(): void {
for (const entry of this.refs.snapshot()) {
if (this.refs.release(entry.path) === 0) {
this.bundle?.release(entry.path);
}
}
}
private ensureBundle(): Promise<AssetManager.Bundle> {
if (this.bundle) { return Promise.resolve(this.bundle); }
const cached = assetManager.getBundle(this.bundleName);
if (cached) { this.bundle = cached; return Promise.resolve(cached); }
return new Promise((resolve, reject) => {
assetManager.loadBundle(this.bundleName, (err, b) => {
if (err) { reject(err); return; }
this.bundle = b;
resolve(b);
});
});
}
}
这里把 bundleName 拆成显式字段加构造函数赋值,而不是写 constructor(private readonly bundleName: string) 那种构造函数参数属性——参数属性属于非 erasable syntax,Node 的类型剥离处理不了,卷二全程禁用。
close() 用 bundle.release(path) 释放 Bundle 内资源——它内部就是对 releaseAsset 的封装,按路径释放并处理依赖。只要路径级的 RefBook 说「没人引用了」,就可以安全释放,这正是 4.2.6 那层账本的用武之地。
下一节把「远程」这个维度补上:远程 Bundle 与原生热更,讲清两者的适用场景与 manifest 结构。
阅读导航:上一节:4.1 资源目录与 Bundle · 下一节:4.3 远程资源与热更 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。