Skip to content

Stage05|Performance ​

Lesson064|对象池解决了什么,又可能制造什么问题? ​

Tags: #Creator2.x #Stage05 #ObjectPool #Instantiate #DestroyDifficulty: ⭐⭐⭐☆☆


真实问题:用了对象池后,子弹为什么带着上一次的状态 ​

回收后的子弹再次取出,出现:

  • 旧颜色和伤害值;
  • 上一次 Tween 继续播放;
  • 命中事件执行两次;
  • 异步特效回调落到新目标;
  • SpriteFrame 一直占内存。

对象池只改变“销毁后重建”为“暂存后复用”,不会自动重置任何业务状态。

一、本课目标 ​

对象池的核心是复用生命周期,而不是简单保存节点数组。本课说明:

  • 什么时候适合对象池。
  • 取出和回收时必须重置什么。
  • 为什么对象池也可能导致内存和状态问题。

二、为什么频繁创建销毁会贵 ​

text
instantiate
→ 创建 Node / Component
→ 恢复属性和引用
→ 执行生命周期
→ 进入调度和渲染
→ destroy
→ 清理和 GC

子弹、飘字、特效和列表 Item 如果频繁经历这条链路,会产生 CPU、内存和 GC 压力。


三、对象池的基本模型 ​

ts
const node = pool.get();
use(node);
pool.put(node);

内部通常是:

text
预创建或按需创建
    ↓
空闲池
    ↓ get
激活并初始化
    ↓
使用
    ↓ put
取消任务、重置状态、隐藏
    ↓
回到空闲池

四、回收时必须处理什么 ​

text
active / enabled
Transform
颜色和透明度
动画和 Tween
schedule / Timer
事件监听
业务字段
子节点状态
外部引用

如果只做:

ts
node.active = false;

旧的 Tween、事件和数据可能在下次取出时继续生效。


五、取出时的初始化 ​

建议提供显式接口:

ts
spawn(data: DamageData) {
    this.node.active = true;
    this.resetVisualState();
    this.setData(data);
    this.startAnimation();
}

despawn() {
    this.stopAnimation();
    this.unscheduleAllCallbacks();
    this.node.active = false;
}

具体方法需按项目组件结构实现,但“取出初始化、回收清理”必须对称。


六、池容量和峰值 ​

对象池应有容量策略:

text
最小预热数量
最大容量
池满时如何处理
长期不用时是否缩容
场景切换时是否销毁

无限增长的对象池只是把运行时创建问题换成了内存问题。


七、哪些对象不适合池化 ​

不一定适合:

  • 生命周期很长的唯一对象。
  • 状态复杂且难以完整重置的对象。
  • 创建成本很低、出现频率很低的对象。
  • 需要严格资源释放的临时对象。

对象池有维护成本,必须用采样证明收益。


八、案例:子弹池 ​

text
发射 → get
飞行 → update
命中或超时 → put

回收前至少要:

  • 停止速度和轨迹。
  • 取消命中回调。
  • 停止粒子和音效引用。
  • 重置位置、旋转、透明度。
  • 从碰撞或事件系统移除。

否则下一次取出可能出现“上一颗子弹的状态”。


九、常见误区 ​

误区一:对象池越大越好 ​

池过大浪费内存,池过小仍频繁创建,需要根据峰值和设备测量。

误区二:回收就是 active=false ​

还需要清理事件、Tween、定时器和业务状态。

误区三:所有对象都应该池化 ​

维护复杂度可能超过创建成本。

误区四:池对象不能 destroy ​

达到淘汰条件、切场景或资源更新时,池也需要释放对象。


十、练习与答案 ​

  1. 对象池主要减少什么成本?
  2. 回收时为什么要停止 Tween 和 schedule?
  3. 无限增长的对象池有什么问题?
  4. 如何判断一个对象是否值得池化?

答案:

  1. instantiate、destroy、生命周期、内存分配和 GC 成本。
  2. 防止对象回收后旧任务在下一次复用时继续修改它。
  3. 长期占用内存并可能掩盖真实峰值。
  4. 看出现频率、创建成本、峰值、复用次数和重置复杂度。

池对象需要明确状态机 ​

text
Created
→ Active
→ Returning
→ Pooled
→ Reusing
→ Active
→ Destroyed(池清空)

每次转换都要定义:

  • 是否在节点树;
  • active 状态;
  • 事件是否注册;
  • Scheduler/Tween 是否运行;
  • 异步 generation;
  • 资源引用;
  • 业务数据;
  • 物理/碰撞状态。

Creator 2.4.x 编辑器实验:有缺陷与完整 reset ​

Bullet.ts ​

ts
const { ccclass } = cc._decorator;

@ccclass
export default class Bullet extends cc.Component {
    private version = 0;
    private damage = 0;

    reuse(damage: number) {
        this.version++;
        this.damage = damage;
        this.node.active = true;
        this.node.opacity = 255;
        this.node.color = cc.Color.WHITE;
        this.node.setPosition(cc.v2());
        this.node.on('hit', this.onHit, this);
    }

    unuse() {
        this.version++;
        this.node.off('hit', this.onHit, this);
        this.unscheduleAllCallbacks();
        cc.Tween.stopAllByTarget(this.node);
        this.damage = 0;
        this.node.active = false;
    }

    private onHit() {
        cc.log('[bullet] damage=', this.damage);
    }
}

Tween API 以项目 2.4.x 小版本为准;若使用第三方 Tween,要调用其对应停止接口。

PoolProbe.ts ​

ts
const { ccclass, property } = cc._decorator;

@ccclass
export default class PoolProbe extends cc.Component {
    @property(cc.Prefab)
    bulletPrefab: cc.Prefab = null;

    @property(cc.Node)
    container: cc.Node = null;

    private pool = new cc.NodePool();

    spawn() {
        const node = this.pool.size() > 0
            ? this.pool.get()
            : cc.instantiate(this.bulletPrefab);
        this.container.addChild(node);
        node.getComponent(Bullet).reuse(10);
    }

    recycle(node: cc.Node) {
        node.getComponent(Bullet).unuse();
        this.pool.put(node);
    }

    onDestroy() {
        this.pool.clear();
    }
}

注意 cc.NodePool.get/put 可配合组件的 reuse/unuse 协议;不要在项目里同时手动调用与自动协议造成双重重置。以上示例表达责任,实际写法按项目选择一种。

实验 ​

  1. 故意不 off,复用五次,观察重复命中。
  2. 不停止 Tween,观察旧动画。
  3. 不重置颜色/位置,观察脏状态。
  4. 完整 reset 后连续复用。
  5. 对比 instantiate/destroy 与池化的 p99、内存和稳定 CPU。

池容量如何决定 ​

text
容量下限:常见并发需求
容量上限:可接受常驻内存
超出策略:销毁、延迟回收或扩容
缩容时机:场景结束、内存警告、长期空闲

不要直接用历史峰值永久预热。极端峰值可能只发生一次。

真实项目故障:弹幕池预热导致启动变慢 ​

团队启动时预创建 5000 个弹幕,每个含 Label、碰撞器和 Sprite。战斗很顺,但启动增加数秒、内存常驻高。

修复:

  • 只预热首秒合理数量;
  • 后续分帧扩容;
  • 弹幕数据与显示节点分离;
  • 简化单个弹幕 Component;
  • 设置上限与溢出降级;
  • 场景结束缩容。

版本边界与练习答案 ​

Creator 2.4.x cc.NodePool 与组件 reuse/unuse 的具体调用规则需以 API 为准。项目应封装单一池接口。

  1. 对象池主要解决什么? 高频创建/销毁和相关生命周期尖峰。
  2. 为什么池会保留资源? 池中节点及 Sprite/Component 字段仍引用 Asset。
  3. unuse 必须做什么? 取消监听/定时器/Tween/异步结果,清业务数据和外部引用,恢复可复用状态。
  4. 如何证明池有效? 对比目标动作 p99、创建/GC、稳定内存与正确性,而非只看“没有 instantiate”。

十一、本课总结 ​

text
对象池复用的是对象生命周期。
get/put 必须配套初始化和清理。
池容量、状态重置和资源释放决定对象池是否真的有效。

十二、下一课预告 ​

text
Lesson065|Event、Scheduler、闭包和监听器泄漏