《Cocos 游戏开发实战》4.2 动态加载与引用计数

静态引用会让首包失控,动态加载是资源管线的另一半。本节讲清 resources.load、bundle.load、loadRemote 三种 API 的取舍,把回调式加载包成 Promise,用 loadDir 批量读配置表,讲透 addRef / decRef 的引用计数规则与 releaseAsset 只减引用不立即销毁的坑,并给出一个可单测的引用计数管理器与 preload 预加载。

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 远程资源与热更 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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