Skip to content

Stage05|Performance ​

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

Tags: #Creator2.x #Stage05 #Event #Scheduler #LeakDifficulty: ⭐⭐⭐⭐☆


真实问题:页面关闭了,为什么还在每秒刷新 ​

页面节点已经销毁,但常驻 EventBus 和 Scheduler 仍保存回调:

text
Global EventBus
→ callback closure
→ PageComponent
→ Node / Sprite / Asset

画面消失不等于引用链断开。回调既会重复执行,也可能让整棵对象图无法回收。

一、本课目标 ​

有些内存泄漏不是资源 API 使用错误,而是“已经不用的对象仍然被某个回调系统持有”。本课重点:

  • 事件监听器为什么会延长对象生命周期。
  • schedule、Tween、Promise 和闭包如何保留引用。
  • 如何建立成对的注册与清理规则。

二、引用链比画面更重要 ​

对象不可见不代表可以回收:

text
EventBus
    ↓ listener
旧页面组件
    ↓ node
旧页面资源和子节点

只要根部对象仍持有回调,整个对象图可能继续存活。


三、事件注册和取消必须成对 ​

ts
onEnable() {
    this.bus.on('data-change', this.refresh, this);
}

onDisable() {
    this.bus.off('data-change', this.refresh, this);
}

常见风险是:

ts
onLoad() {
    this.bus.on('data-change', this.refresh, this);
}

组件反复启用或场景切换时,如果没有明确取消,可能重复响应或无法回收。


四、匿名函数为什么难以取消 ​

ts
this.node.on('touchend', () => this.fire(), this);

如果没有保留同一个函数引用,之后很难准确 off:

ts
this.node.off('touchend', () => this.fire(), this);

这两个箭头函数不是同一个对象。更稳妥的是使用成员方法或保存回调引用。


五、Scheduler 和 Tween 的残留 ​

ts
this.schedule(this.refresh, 1);
cc.tween(this.node).to(1, { scale: 2 }).start();

页面关闭、对象池回收或场景切换时,需要明确:

  • 是否取消 schedule。
  • 是否停止 Tween / Action。
  • 回调是否检查对象仍有效。
  • 回收后重新取出是否清空旧状态。

六、异步回调和 Promise ​

ts
loadData((data) => {
    this.label.string = data.title;
});

如果请求完成时页面已经销毁,回调可能访问无效对象。可以使用状态标记、请求令牌或生命周期解绑:

ts
private requestId = 0;

load() {
    const id = ++this.requestId;
    loadData((data) => {
        if (id !== this.requestId || !this.node.isValid) {
            return;
        }
        this.label.string = data.title;
    });
}

具体有效性 API 需按 Creator 2.x 版本确认,但原则是异步结果必须验证归属和生命周期。


七、案例:反复打开页面后的重复刷新 ​

现象:

text
第一次打开:refresh 执行 1 次
第二次打开:refresh 执行 2 次
第三次打开:refresh 执行 3 次

常见根因:每次打开都 on,但没有对应 off。排查方法:

  1. 统计注册次数。
  2. 给 listener 打印对象身份。
  3. 检查 onEnable/onDisable 是否对称。
  4. 场景切换后检查全局 Bus 是否仍持有旧对象。

八、泄漏和重复执行的区别 ​

text
重复执行:同一个对象被多个 listener 或任务调用
内存泄漏:对象生命周期结束后仍被引用

两者经常同时出现,但修复方式要分别验证:

  • 执行次数是否恢复为预期。
  • 旧对象是否能被回收。
  • 场景切换后引用链是否断开。

九、常见误区 ​

误区一:节点销毁后事件会自动从所有全局系统移除 ​

不应假设所有自定义事件系统都能自动清理。

误区二:匿名函数方便,任何时候都能 off ​

没有同一个函数引用就无法准确取消。

误区三:Promise 完成后访问组件一定安全 ​

异步完成时间可能晚于节点生命周期。

误区四:对象池回收后不用清理事件 ​

复用对象会把旧 listener 带到下一次业务中。


十、练习与答案 ​

  1. 为什么 onEnable/onDisable 适合管理事件?
  2. 匿名 listener 有什么取消风险?
  3. 异步回调为什么需要检查对象有效性?
  4. 如何区分重复执行和内存泄漏?

答案:

  1. 它们对应组件进入和退出可用状态,生命周期对称。
  2. 每次创建的匿名函数引用不同,无法准确 off。
  3. 回调完成时对象可能已关闭、销毁或被其他请求取代。
  4. 前者看调用次数,后者看生命周期结束后是否仍有引用。

监听器所有权表 ​

每条监听应记录四项:

注册者事件源有效期取消点
PanelNode touchactive 期间onDisable
BattleControllerNetworkbattle 期间onDisable/onDestroy
GameRootApp event应用期间shutdown
Pooled ItemEventBusreuse 期间unuse

没有取消点的监听就是设计缺口。

Creator 2.4.x 实验:复现重复回调 ​

LeakProbe.ts ​

ts
const { ccclass } = cc._decorator;

const bus = new cc.EventTarget();

@ccclass
export default class LeakProbe extends cc.Component {
    private tickCount = 0;

    onEnable() {
        bus.on('tick', this.onTick, this);
        this.schedule(this.emitTick, 1);
    }

    onDisable() {
        bus.off('tick', this.onTick, this);
        this.unschedule(this.emitTick);
    }

    private emitTick = () => {
        bus.emit('tick');
    };

    private onTick() {
        this.tickCount++;
        cc.log(
            '[leak-probe]',
            this.node.name,
            this.tickCount
        );
    }
}

实验 ​

  1. 正常反复 active,确认每秒一次。
  2. 临时注释 off,反复五次,观察重复。
  3. 改成匿名函数注册/取消,观察函数身份问题。
  4. 创建多个实例并销毁,检查旧实例日志。
  5. 恢复正确代码并验证监听数稳定。

实验结束必须恢复清理代码。

闭包怎样延长对象生命 ​

ts
const panel = this;
network.request(() => {
    panel.refresh();
});

回调捕获 panel。在请求完成或请求系统清除回调前,引用可能继续存在。

解决不只是加 cc.isValid:

  • 若 API 支持取消,生命周期结束时取消;
  • 回调使用 generation 拒绝过期结果;
  • 请求系统完成后移除回调;
  • 不在全局队列永久保存 Promise handler;
  • 清除缓存 Map 中已结束任务。

真实项目故障:重连一次,网络消息多处理一遍 ​

每次重连都执行:

ts
socket.onMessage((data) => this.handle(data));

旧 socket handler 未移除,匿名函数又无法精确取消。第三次重连存在三套处理器。

修复:

  • 保存稳定 handler;
  • 关闭旧连接时解绑;
  • 连接状态机保证只存在一个 active session;
  • 消息增加 session ID,旧会话结果失效;
  • 输出事件名、订阅数和 session。

泄漏与重复执行如何区分 ​

重复执行是行为现象;泄漏是引用/内存现象。二者常同时出现,但也可能:

  • 重复监听被 offAll 清掉,未长期泄漏;
  • 缓存永久持有对象,却没有任何回调执行;
  • Scheduler 重复运行但对象本就合法常驻。

需要分别给出调用次数与引用/内存证据。

版本边界与练习答案 ​

Creator Node EventTarget、Scheduler、Tween 与第三方网络库取消 API 不同。Creator 2.4.x 项目应为外部系统封装一致生命周期。

  1. 为什么匿名函数难取消? 注册和取消创建的是不同函数对象。
  2. onDestroy 清理是否永远足够? 不够。inactive/入池对象还没 destroy,通常应在 onDisable/unuse 停止工作。
  3. cc.isValid 能释放闭包吗? 不能,只阻止使用无效对象;事件源仍可能持有回调。
  4. 如何验证监听泄漏修复? 反复开关/重连,多次后监听数与单次回调数保持稳定,同时内存平台不阶梯增长。

十一、本课总结 ​

text
事件、定时器、Tween 和异步回调都可能延长对象生命周期。
注册和清理要成对,异步结果要验证生命周期和请求归属。

十二、下一课预告 ​

text
Lesson066|load、preload、缓存与加载峰值