《Cocos 游戏开发高级编程》7.1 内存模型与 GC

卷二 13.2 算过纹理内存、也定了引用计数纪律,但没回答字节到底躺在哪、Asset 从加载到消失经历了什么、GC 何时被触发。本节把 Cocos 运行时内存切成 JS 堆、原生堆与 GPU 显存三块,给出 Asset 生命周期与引用计数的 cc.d.ts 证据,并用纯 TS 写一个对象池加触发式回收模型,配 vitest 断言。

7.1 内存模型与 GC

《方块冲刺》在卷二 13.2 已经算过一次纹理内存,也定下了「谁 load 谁 decRef」的纪律。但那只回答了「占多少」与「谁释放」,没回答三个更底层的问题:这些字节到底躺在哪块内存里?一个 Asset 从加载到消失经历了什么?垃圾回收到底在什么时候被触发? 这三个问题不搞清楚,优化就只剩猜。

本节把 Cocos 运行时的内存切成三块,逐块说明谁持有、谁回收;再用 cc.d.ts 里的签名证明 Asset 的引用计数语义;最后给一个纯 TypeScript 的「对象池 + 触发式回收」模型,用 vitest 断言其行为。站内 saas/game-engine/ 那 24 篇是从商业视角谈自研引擎,graphics/ 谈图形学理论;本节只谈 Cocos 运行时里能实证的部分。

7.1.1 三块内存,三套回收规则

新手谈「内存优化」时,脑子里通常只有一块内存。实际上 Cocos 运行时同时占用三块,它们的持有者、可见性和回收方式完全不同:

内存块装什么谁分配谁回收怎么观测
JS 堆Node / Component / 普通对象 / 数组 / 闭包V8 或 JavaScriptCoreJS 垃圾回收器performance.memory(Chrome)
引擎/原生堆图片解码后的像素缓冲、Spine 数据、音频 PCM引擎(Web 上是 ArrayBuffer,原生端在 C++ 堆)引用计数 + 显式释放profiler.stats.bufferMemory
GPU 显存上传后的纹理、顶点/索引缓冲、RenderTexturegfx 设备Asset 释放时 destroy 底层 GPU 资源profiler.stats.textureMemory

三块里只有第一块是 JS 垃圾回收器能看见的。后两块由引擎按引用计数管理——这正是「资源内存不能靠 GC 兜底」的根因:一个纹理的 JS 包装对象可能早被 GC 收走,但它引用的 GPU 显存还稳稳占着,直到引用计数归零。

还有一个平台差异要记牢:Web 端「原生堆」这一层其实也是 ArrayBuffer,仍在 JS 堆的可见范围内;原生端(Android / iOS)才真正落到 C++ 堆,performance.memory 之类完全看不到。所以同一份「内存涨了」的现象,在 H5 和原生上的排查路径并不一样。

7.1.2 一个 Asset 的生命周期

把 Asset 的一生画成状态,比背 API 有用得多。以 bundle-levels 里的关卡图集为例:

  1. 未加载:assets/bundle-levels/ 下只有文件与 .meta,内存里没有对应对象。
  2. 加载中:调用 resources.load 或 bundle.load 后,请求进入队列,AssetManager 去读文件、解码。
  3. 已加载:资源对象进缓存(cache),同时加载行为本身会把引用计数置为 1。
  4. 被持有:任何需要长期使用它的模块调用 addRef(),计数递增,阻止自动释放。
  5. 引用归零:调用 decRef() 减到 0,若引擎判定无其他依赖,资源进入可释放状态。
  6. 真正释放:底层 GPU / 原生资源被 destroy,缓存条目被移除。

第 3 步到第 5 步之间,就是「泄漏」最常发生的地方——只 load 不 decRef,计数永远 ≥ 1,资源永远不释放。

7.1.3 引用计数不是 GC

Cocos 的 Asset 是引用计数管理的,cc.d.ts 里三行签名把语义钉死了:

// 来源:@cocos/creator-types@3.8.8, engine/cc.d.ts(Asset 类)
get refCount(): number;
addRef(): Asset;
decRef(autoRelease?: boolean): Asset;

三行的分工:refCount 是只读的当前计数;addRef() 加一,用于「我要长期持有」;decRef(autoRelease = true) 减一,减到 0 且 autoRelease 为真时资源会被自动释放。注意 decRef 的参数是可选的,传 false 就只减计数、不触发释放——这在「批量切场景、想延后统一清理」时有用,但日常别用,容易把释放时机搞乱。

引用计数和 GC 的关系,一句话说清:GC 管 JS 对象图,引用计数管资源句柄。两者是两条独立的线,谁也替不了谁。工程纪律因此只有一条:谁 load,谁 decRef,成对出现。不要写「反正有 GC」——GC 看不到那三块内存里的后两块。

7.1.4 三种最常见的泄漏形态

引用计数是纪律,纪律被破坏就有泄漏。工程里翻来覆去就三种:

  1. 只加不减:addRef() 之后忘了 decRef()。典型是「把资源存进一个全局单例的数组,切场景时只清空数组、不遍历减引用」——数组是空的,refCount 却还挂在那儿。
  2. 事件回调持有:node.on('x', this.handler, this) 注册后,节点销毁了,但回调仍被某个长生命周期对象引用,闭包捕获的 this 又把整棵节点树拖住。
  3. 定时器未清:this.schedule(...) 创建的定时器如果不 unschedule,组件销毁后仍可能被 Scheduler 持有。它不占显存,但会让 JS 堆持续增长。

第三种最隐蔽,因为它不在「资源」这条线上,肉眼扫 addRef / decRef 完全扫不出来。一个可执行的自查习惯:每个 onEnable 都要能在 onDisable 里找到对应行,每个 schedule 都要能在 onDestroy 里找到 unschedule。写成对,就不会漏:

import { _decorator, Component } from 'cc';
const { ccclass } = _decorator;

@ccclass('EnemySpawner')
export class EnemySpawner extends Component {
  onEnable(): void {
    this.schedule(this.spawn, 1);
  }

  onDisable(): void {
    this.unschedule(this.spawn);
  }

  private spawn(): void {}
}

Component.schedule 的签名是 schedule(callback, interval?, repeat?, delay?),unschedule 收的是同一个回调引用——所以回调必须是稳定的成员方法或同一个函数引用,写成内联箭头函数会 unschedule 不掉。

7.1.5 GC 到底什么时候触发

JS 垃圾回收不可精确控制。现代引擎普遍是分代式:新生代用 scavenge(复制存活对象、回收快),老生代用标记-整理(mark-compact,开销大)。触发点由分配速率和堆阈值共同决定,不取决于你「想不想回收」。你能做的只有三件事,把「不可控」压缩成「可预期」:

  1. 减少每帧分配:这是最有效的。每帧 new 一个 Vec3、每帧 [...arr] 拷贝一个数组,都会推高分配速率,让 GC 提前、变频繁。用对象池复用是标准手段。
  2. 主动触发一次回收:sys.garbageCollect()。签名是 garbageCollect(): void,文档明确写着仅原生平台有效;Web 端调它是空操作。别指望它在浏览器里救场。
  3. 响应系统的低内存告警:监听 Game.EVENT_LOW_MEMORY,在系统内存吃紧时主动释放非必要资源,比等系统来杀进程体面得多。

下面这段 L2 脚本把第 3 件事落到工程里,只依赖 cc 公开 API,能过 tsc --noEmit:

import { _decorator, Component, game, Game, assetManager } from 'cc';
const { ccclass } = _decorator;

@ccclass('LowMemoryHandler')
export class LowMemoryHandler extends Component {
  onEnable(): void {
    game.on(Game.EVENT_LOW_MEMORY, this.onLowMemory, this);
  }

  onDisable(): void {
    game.off(Game.EVENT_LOW_MEMORY, this.onLowMemory, this);
  }

  private onLowMemory(): void {
    // 先清缓存管理器里的下载缓存(Web / 小游戏上尤其有效)
    assetManager.cacheManager?.clearLRU();
    // 原生端可以再补一刀强制回收
    console.warn('[mem] low memory, caches cleared');
  }
}

Game.EVENT_LOW_MEMORY 是 Game 的静态成员,事件名必须写成 Game.EVENT_LOW_MEMORY(用实例 game.EVENT_LOW_MEMORY 编译不过)。cacheManager 在 cc.d.ts 里的类型是 AssetManager.CacheManager | null,所以访问要带可选链。

7.1.6 L1:对象池 + 触发式回收

上面三条是「减分配、能主动、能响应」。要验证「减分配」到底省了多少,得有一份可跑的模型。下面这个对象池是纯 TypeScript,零 cc 依赖,直接单测即可复现。

// mini2d/pool/object-pool.ts —— 纯 TS,无任何 cc 依赖
export interface PoolOptions {
  /** 空闲对象数上限,超过即触发一次「回收」 */
  maxSize: number;
  /** 单次回收最多丢弃多少个,避免一次性大回收造成卡顿 */
  collectPerStep: number;
}

export class ObjectPool<T> {
  private readonly factory: () => T;
  private readonly reset: (item: T) => void;
  private readonly options: PoolOptions;
  private free: T[] = [];
  private created = 0;
  private collected = 0;

  constructor(factory: () => T, reset: (item: T) => void, options: PoolOptions) {
    this.factory = factory;
    this.reset = reset;
    this.options = options;
  }

  get size(): number {
    return this.free.length;
  }

  get createdCount(): number {
    return this.created;
  }

  get collectedCount(): number {
    return this.collected;
  }

  acquire(): T {
    const reused = this.free.pop();
    if (reused !== undefined) {
      return reused;
    }
    this.created += 1;
    return this.factory();
  }

  release(item: T): void {
    this.reset(item);
    this.free.push(item);
    if (this.free.length > this.options.maxSize) {
      this.collect();
    }
  }

  /** 触发式回收:按步长丢弃多余对象,交回给 JS GC */
  private collect(): void {
    let dropped = 0;
    while (this.free.length > this.options.maxSize && dropped < this.options.collectPerStep) {
      this.free.pop();
      dropped += 1;
    }
    this.collected += dropped;
  }
}

设计上有两个刻意的选择。第一,release 时先 reset(item) 再入池,避免把脏状态(比如残留的坐标、速度)带进下一次 acquire——这是对象池最经典的 bug 来源。第二,回收是分步的:collectPerStep 限制单次丢弃数量,对应到真实引擎里就是「不要把几百个对象在某一帧集中销毁」,因为集中释放同样会在那一帧制造一个 GC 尖峰,把帧时间顶上去。

配一份 vitest 断言,把「复用」和「触发式回收」两个行为都钉住:

import { describe, expect, it } from 'vitest';
import { ObjectPool } from './object-pool';

describe('ObjectPool', () => {
  it('复用对象,且 release 时被重置', () => {
    const pool = new ObjectPool<{ x: number }>(
      () => ({ x: 0 }),
      (o) => { o.x = 0; },
      { maxSize: 8, collectPerStep: 4 },
    );
    const a = pool.acquire();
    a.x = 42;
    pool.release(a);
    const b = pool.acquire();
    expect(b).toBe(a);        // 同一个对象被复用
    expect(b.x).toBe(0);      // 脏状态已清
    expect(pool.createdCount).toBe(1);
  });

  it('空闲数超过上限时按步长触发回收', () => {
    const pool = new ObjectPool<object>(() => ({}), () => {}, {
      maxSize: 4,
      collectPerStep: 3,
    });
    const items = Array.from({ length: 12 }, () => pool.acquire());
    for (const it of items) {
      pool.release(it);
    }
    expect(pool.size).toBeLessThanOrEqual(4);
    expect(pool.collectedCount).toBeGreaterThan(0);
  });
});

跑起来:

cd mini2d && npx vitest run pool/object-pool.test.ts

第二段断言能通过,是因为每次 release 把空闲数推过上限 4 时,collect 就丢一个回来。12 次释放里有 8 次触发了丢弃,最终空闲数稳定在上限以内。这正是「触发式回收」相对「攒着不管」的差别:内存曲线是一条有上界的锯齿,而不是单调上升的斜坡。

7.1.7 工程决策:什么时候用池,什么时候不

对象池不是免费的。把对象留在池里,等于主动拒绝了一次 GC 回收——它占的内存是常驻的,只是避免了反复分配的开销。所以判断标准是「分配频率」而不是「对象大小」:

场景该不该池理由
每帧发射的子弹、金币、飘字该池高频分配,GC 尖峰的直接来源
关卡切换时创建一次的敌人不必池低频,池化收益抵不过常驻开销
长生命周期、状态复杂的实体不必池池化要写 reset,状态越复杂越容易漏字段
临时向量/矩阵(Vec3 等)该池数学库对象小、分配极频繁

最后一条要和引擎自身对照:Cocos 的 Vec3 等数学类型是引擎自己管理的可变对象,v.set(...) 这类原地修改的 API 就是为了避免分配。在 update 里写 node.position = new Vec3(x, y, 0) 每帧都会分配一个 Vec3,而 node.setPosition(x, y, 0) 不会——同一件事,分配次数差一个数量级。

下一节从 JS 堆转到 GPU 显存:既然资源内存不归 GC 管,那它到底该按什么口径估、按什么预算管?我们会把格式枚举、块压缩对齐和整包预算表一次算清。

阅读导航:上一节:目录 · 下一节:7.2 纹理、显存与资源预算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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