对象池与 GC 卡顿

2.1 卡顿不是从物理来的,是从内存来的

有一次我带着一批包裹做压力测试,同时在场上跑 80 个动态刚体,帧率稳在 55 左右。我以为是物理算不过来,把迭代次数调低、把包裹的刚体类型改成 Kinematic,帧率变成 58。提升很小,我几乎要放弃这条路了。

后来我打开 Creator 的调试器看了一下内存曲线,发现每次有新包裹生成,JS 堆就往上跳一截,然后掉不下去。托管环境的垃圾回收是滞后的,短时间内的垃圾回收会把帧率拽下去。

我做的事很简单:把"生成一个包裹"改成"从池子里拿一个包裹","包裹消失"改成"放回池子"。帧率立刻回到 60 满帧,而且再也没抖过。

这就是对象池要解决的问题。核心机制说白了只有三步:取出来、重置、放回去。3.x 引擎自带 NodePool 类,官方文档里给的第一条用例就是子弹这种"死亡很快、频繁创建"的对象,跟我的包裹一模一样。

2.2 NodePool 的三个方法

NodePool 的用法很短。构造函数可以传一个组件类型或名称,作为池的处理组件:

import { _decorator, Component, NodePool, instantiate, Node, Prefab } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('PackagePool')
export class PackagePool extends Component {
    @property(Prefab)
    packagePrefab!: Prefab;
    private _pool!: NodePool;

    onLoad () {
        // 传入处理组件,节点回收和复用时会调用它的 unuse / reuse
        this._pool = new NodePool(PackageNode);
    }

    acquire (): Node {
        let node = this._pool.get() as Node | null;
        if (!node) {
            node = instantiate(this.packagePrefab); // 池子空了才真正创建
        }
        node.parent = this.node.parent;
        node.active = true;
        return node;
    }
    release (node: Node) { this._pool.put(node); }
}

get 在池子空的时候返回 null,所以永远要做兜底创建。这个兜底很重要:如果你假设 get 一定返回东西,某天池子刚好空了,运行时空指针直接崩。

put 会自动把节点从父节点上摘下来,但官方文档特别写了一句:不会做 cleanup 操作。也就是说,节点身上所有你自己加的状态,引擎不管。你不重置,它就带着旧状态回到池子里。

size() 返回当前可用的对象数量,clear() 销毁池子里缓存的所有节点。clear() 一般在场景卸载或者关卡结束的时候调,不然这些节点会一直占着内存。

引擎自带的 NodePool 有个配套的机制:如果你传入了处理组件,池子在 get 的时候会调用它的 reuse 方法,在 put 的时候会调用 unuse 方法。这两个方法名是约定好的,我在下面的重置清单里会用到。

2.3 重置到底要清哪些东西

这是对象池最容易出事的地方。我第一版只重置了位置,跑了半小时测试,游戏里出现了"已经送达的包裹突然又飞起来了"这种鬼故事。原因是我漏了计时器。

我现在的重置清单是这样:

要重置的东西 在哪 怎么清 漏掉的后果
位置和旋转 节点 node.setPosition()、node.angle 包裹从上一个位置冒出来
速度 RigidBody2D body.linearVelocity = new Vec2() 包裹刚放回池子就自己飞走了
计时器 组件 unscheduleAllCallbacks() 上一次的生命周期回调在下次复用时触发
事件监听 组件或全局 off(...) 或 targetOff(this) 上次的接收者收到这次的回调
动画状态 Animation 组件 play(...) 换成待机动画,调用 stop() 包裹保持上次的死亡姿态
业务状态 自定义字段 组件的 reuse() 里重置 血量、耐久、状态标记全是上次的
子节点激活状态 子节点 child.active = true 或按需 特效节点忘了开,下次不显示

其中"速度"这条我踩得最狠。包裹的刚体是 Dynamic 类型,落到地上会进入休眠,看起来一切正常。但我把它放回池子的时候没清速度,休眠状态下的刚体速度是保留的。下次取出来,速度还是上次的,直接飞出去。

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

@ccclass('PackageNode')
export class PackageNode extends Component {
    private _body!: RigidBody2D;
    private _anim!: Animation;
    onLoad () {
        this._body = this.getComponent(RigidBody2D)!;
        this._anim = this.getComponent(Animation)!;
    }
    // 取出时调
    reuse () {
        this.node.setPosition(0, 0, 0);
        this.node.angle = 0;
        this._body.linearVelocity = new Vec2(0, 0);
        this._body.angularVelocity = 0;
        this.unscheduleAllCallbacks();
        this._anim.play('idle');
    }
    // 放回时调用
    unuse () { this.unscheduleAllCallbacks(); this._anim.stop(); }
}

"事件监听"这一条要展开说。我在包裹上监听了送达点的碰撞事件,用的是 collider.on(Contact2DType.BEGIN_CONTACT, 回调, this)。放回池子的时候如果不 off,下次复用同一个节点,监听就会变成注册两次、两次都触发。跑了二十分钟之后,一个包裹送达会弹三个结算飘字。

修法有两条:要么在 unuse 里精确 off 掉那一条,要么直接用 targetOff(this) 把我注册的所有监听一次性清掉。后者更省心,targetOff 是 Node 上的方法,接收一个 target 参数,移除该 target 在这个节点上注册的所有监听。

不过 targetOff 有个副作用:它清的是"这个节点上所有以我为 target 的监听"。如果你的组件在同一个节点上还监听了别的系统事件,也会被一起清掉。多数情况下这正是你想要的,但心里得有数。

2.4 池容量定多少,哪些东西不该池化

池容量我一开始定的是 100,结果池子里的节点一直没用满,白占内存。后来我按"同屏最大数量"来定。

我的估算方式是:关卡设计文档里写清楚这一关最多同时出现多少个包裹,就定多少。加上一点余量,应对玩家把包裹丢在同一个角落的情况。

对象 池化吗 池容量 原因
飞行包裹 池化 24 创建销毁最频繁,收益最大
障碍物碎片 池化 16 撞击时批量生成
送达飘字 池化 8 数量少但创建极频繁
障碍墙体 不池化 无 一关就建一次,池化没意义
场景背景层 不池化 无 从头到尾不销毁
订单列表项 池化 10 滚动列表反复创建
UI 弹窗 不池化 无 一次开一个,池化反而增加复杂度

"不池化"那一栏的理由值得说一下:对象池的成本不只是内存,还有代码复杂度和出 bug 的概率。墙体的生命周期跟场景一样,池化它等于在池里存一份永远不会被取出的东西,还要在场景卸载时记得 clear()。

我的判断标准是:一个对象在一次会话里被创建销毁超过二十次,才值得池化。低于这个数,池化带来的管理成本大于收益。

还有一个池化的隐性成本是内存。池子里的节点虽然不激活,但它们的组件、节点树、引用都还在。微信小游戏的主包内存上限虽然比原生宽松,但低端机 300MB 内存打到顶一样会被系统杀掉。所以池容量宁少勿多,宁可频繁创建也不要一次占太多。

2.5 卡顿排查的真实路径

我踩过的弯路值得写出来,因为我一开始的排查顺序是错的。

我第一次遇到卡顿,直接就去脚本里找。谁都知道脚本逻辑是嫌疑犯,所以我打开代码开始 review,看哪段 update 里做了重活。看了半小时没看出问题,因为问题根本不在脚本逻辑里。

我现在的排查顺序是这样的:

第一步,看总帧时间。总帧时间超了,说明这一帧有东西花了时间,但不知道是谁花的。这一步的产出是"卡顿到底在脚本、渲染还是物理",方向性的判断。

第二步,看是哪一段超了。Creator 的调试器能分模块看:脚本、渲染、物理、UI 布局,各占多少。脚本这一栏如果很高,说明问题在逻辑里,接着往下钻。渲染这一栏高,看 Draw Call 和节点数量。物理这一栏高,看刚体数量和迭代次数。

第三步,只有确定在脚本里,才去看具体是哪个函数。

这个顺序的意义在于跳过很多无用功。我第一次直接 review 脚本,看的全是"看起来很高效"的代码,而真正的元凶是 instantiate 和 destroy 造成的 GC,这在脚本 review 里几乎看不出来,因为那两行代码本身很便宜。

怎么看到 GC?两个办法。

一个是在运行时的统计面板。profiler 这个模块挂在 cc 上,调用 profiler.showStats() 就能把性能统计显示出来。API 里的 Profiler 类有 stats 属性,showStats()、hideStats()、isShowingStats() 三个方法。

import { profiler } from 'cc';

// 在需要的时候打开统计面板
profiler.showStats();

// 也可以在别处读取当前状态,stats 里带帧率和绘制调用等数据
const state = profiler.stats;

另一个是 Chrome 的 Memory 面板,Web 平台下最直接。反复做同一个操作,看 JS 堆的锯齿。健康的曲线是阶梯状上升然后回落,锯齿越来越深说明有东西在累积。

我判断一个卡顿是不是 GC 引起的,靠一个土办法:把出问题的操作频率降下来。如果把生成速率从每秒 20 个降到 5 个,卡顿就消失了,那八成是 GC。

再补一条实测经验:物理那一栏高的时候,别急着调参数。先看刚体数量。我的项目里刚体数量从 60 涨到 120 的时候物理耗时翻倍,但同时场景里多出来的节点把渲染也拖慢了,帧率掉得比物理那部分还多。真正的解法是把离屏的障碍物直接 active = false,而不是优化物理参数。

2.6 相关阅读