Skip to content

Stage03|Runtime Objects & Assets ​

Lesson026|场景切换、常驻节点与对象生命周期 ​

Tags: #Creator2.x #Stage03 #Scene #PersistRootNode #LifecycleDifficulty: ⭐⭐⭐⭐☆


一、本课从“第二次进大厅,音乐叠了两层”开始 ​

项目有三个场景:

text
Boot → Lobby → Battle

开发者在 Boot 中创建了一个 GameRoot,负责音乐、网络和玩家数据。为了让它跨场景存在,调用:

ts
cc.game.addPersistRootNode(this.node);

第一次进入 Lobby 一切正常,返回 Boot 再进入 Lobby 后却出现:

  • 背景音乐同时播放两份;
  • 一条服务器消息刷新 UI 两次;
  • 已经离开的 Battle 面板还在打印日志;
  • GameRoot 中保存的 Lobby/Canvas/TopBar 引用已经无效。

这些现象都来自同一个问题:

场景切换之后,哪些对象应该继续活着?继续活着的对象又允许持有什么?

本课会沿着一次完整切场景过程,建立可验证的对象生命周期模型。


二、本课目标 ​

完成本课后,你应该能够:

  1. 解释普通场景节点和常驻根节点在切场景时的不同命运。
  2. 正确使用 cc.game.addPersistRootNode,并避免重复创建常驻服务。
  3. 区分 onDisable、onDestroy 与场景切换回调的职责。
  4. 处理事件、定时器、异步加载和网络回调的生命周期。
  5. 识别“对象已经销毁”和“对象仍被业务系统引用”的差别。
  6. 用 Creator 2.4.x 编辑器实验观察真实回调顺序和重复实例问题。

三、先把“场景”看成对象所有权边界 ​

Scene 不只是画面文件。运行时加载 Scene 后,会得到一棵节点树:

text
Lobby(Scene)
└── Canvas
    ├── Background
    ├── TopBar
    └── LobbyController

默认情况下,这棵树中的普通节点由当前 Scene 拥有。切换到 Battle 时,可以先用一个教学模型理解:

text
加载目标场景
→ 退出旧场景
→ 旧场景普通节点失效并进入销毁流程
→ 激活新场景
→ 新场景组件开始工作

具体内部时序会受加载方式和引擎版本影响,但项目设计必须依赖下面这个稳定事实:

text
普通场景节点不能被当成跨场景对象长期使用。

如果全局服务保存了 Lobby/Canvas/TopBar,切到 Battle 后,这个字段即使还不是 JavaScript 的 null,也不代表它仍是可用的引擎对象。


四、常驻根节点改变了谁拥有它 ​

Creator 2.4.x 提供:

ts
cc.game.addPersistRootNode(node);

调用成功后,这个节点不会和当前 Scene 的普通节点一起退出,而会被后续场景继续使用。

这里有两个重要限制。

1. 它应该是场景的直接子节点 ​

常驻的是根节点,不是任意深层子节点。推荐结构:

text
Boot(Scene)
├── Canvas
└── GameRoot          ← 场景直接子节点
    ├── AudioService
    └── NetworkService

不要把 Canvas/GameRoot 这样的深层节点直接当作常驻根。需要常驻时,应先在编辑器里把所有权结构设计清楚。

2. 常驻的是节点及其子树 ​

如果 GameRoot 下挂着临时页面:

text
GameRoot
├── AudioService
└── LobbyPopup        ← 错误地一起常驻

那么 LobbyPopup 也会跨场景保留。常驻边界放错,临时对象就会被带进后续所有场景。


五、贯穿案例:只允许存在一个 GameRoot ​

先实现一个最小的常驻入口:

ts
const { ccclass } = cc._decorator;

@ccclass
export default class GameRoot extends cc.Component {
    private static instance: GameRoot = null;

    onLoad() {
        if (GameRoot.instance && GameRoot.instance !== this) {
            cc.warn('[GameRoot] duplicate instance, destroy the new one');
            this.node.destroy();
            return;
        }

        GameRoot.instance = this;
        cc.game.addPersistRootNode(this.node);
        cc.log('[GameRoot] created');
    }

    onDestroy() {
        if (GameRoot.instance === this) {
            GameRoot.instance = null;
        }
        cc.log('[GameRoot] destroyed');
    }
}

这段代码解决的是“多个场景都放了 GameRoot”导致的重复实例问题:

text
第一次实例 → 登记并常驻
后续重复实例 → 销毁新实例

但单例只保证“访问入口唯一”,不会自动解决生命周期。仍然需要回答:

  • 谁可以访问 GameRoot?
  • 它什么时候真正结束?
  • 它是否保存场景节点?
  • 它注册的监听由谁取消?
  • 退出账号时是否需要重置状态?

六、重复创建为什么如此常见 ​

最常见的错误布局是:

text
Boot.scene   中有 GameRoot
Lobby.scene  中也有 GameRoot
Battle.scene 中仍有 GameRoot

开发者以为每个场景都放一个更保险。实际运行:

text
Boot 的 GameRoot 被设为常驻
→ 加载 Lobby
→ Lobby 自带的 GameRoot 又被创建
→ 两个实例都注册消息、播放音乐

有两种明确方案:

方案 A:只有启动场景创建 ​

其他业务场景不放 GameRoot。项目必须从 Boot 进入,初始化路径最清晰。

方案 B:每个入口允许自举,但做唯一性保护 ​

适合开发期经常单独预览业务场景。每个入口可以有 Bootstrap,但最终服务必须做重复实例保护,并明确测试模式和正式流程的区别。

无论选择哪一种,都不能让“谁负责创建全局服务”处于模糊状态。


七、onDisable 和 onDestroy 不是同一个清理时机 ​

考虑一个 Lobby 面板:

ts
onEnable() {
    EventBus.on('coin-changed', this.refreshCoin, this);
}

onDisable() {
    EventBus.off('coin-changed', this.refreshCoin, this);
}

这里使用 onEnable/onDisable,因为订阅的有效范围是“面板可用期间”。如果只在 onDestroy 取消监听,那么面板仅仅被设为 active = false 时,它还没有销毁,全局事件总线仍可能调用它。

再看一次性清理:

ts
onDestroy() {
    this.unscheduleAllCallbacks();
    this.releaseOwnedHandles();
}

适合 onDestroy 的是“对象此后永远不再使用时才做的最终清理”。选择依据:

text
隐藏时就不应工作?       → onDisable 清理
对象永久结束才清理?     → onDestroy 清理
重新显示时需要恢复?     → onEnable 建立

八、destroy() 之后为什么变量看起来还在 ​

在 Creator 2.x 中:

ts
node.destroy();

表示请求销毁引擎对象。销毁通常在当前帧结束阶段完成,不应把它理解成 JavaScript 变量立刻变为 null。

ts
const oldNode = this.target;
oldNode.destroy();

cc.log(oldNode === null);       // false
cc.log(cc.isValid(oldNode));    // 销毁阶段前后结果可能不同

真正要检查的是引擎对象有效性:

ts
if (!cc.isValid(this.target)) {
    return;
}

业务系统还应主动清空不再拥有的引用:

ts
this.target = null;

cc.isValid 是防御性检查,不是允许长期保存过期引用的设计方案。


九、常驻服务最危险的行为:反向持有场景对象 ​

下面的依赖方向存在问题:

text
GameRoot(生命周期:整个游戏)
    ↓ 持有
LobbyTopBar(生命周期:Lobby 场景)

长生命周期对象持有短生命周期对象,会导致切场景后访问失效对象、旧 UI 继续收到回调、新旧页面引用混杂。

更稳妥的方向是:

text
LobbyTopBar
    ↓ 订阅/查询
GameRoot 中的数据服务
ts
onEnable() {
    PlayerService.on('coin-changed', this.refreshCoin, this);
    this.refreshCoin(PlayerService.coin);
}

onDisable() {
    PlayerService.off('coin-changed', this.refreshCoin, this);
}

全局服务发布“金币变了”,但不需要知道哪一个 Label 正在显示金币。


十、定时器、Tween 和 Action 也有所有者 ​

这段代码看似简单:

ts
this.schedule(this.refreshTime, 1);

必须继续问:谁创建、隐藏后是否运行、谁取消、重复进入会不会再次调度?

ts
onEnable() {
    this.schedule(this.refreshTime, 1);
}

onDisable() {
    this.unschedule(this.refreshTime);
}

Tween、Action、第三方计时器也需要相同思维。不要假设关闭 UI 就自动终止所有外部系统创建的任务。


十一、异步回调不会因为场景切换自动消失 ​

假设 Lobby 发起头像加载:

ts
cc.resources.load('avatars/player-01', cc.SpriteFrame, (err, frame) => {
    this.avatar.spriteFrame = frame;
});

加载期间切到 Battle,回调仍可能稍后返回。此时组件可能已失效,新请求也可能已经开始。可以同时验证对象和请求代次:

ts
private avatarRequest = 0;

loadAvatar(path: string) {
    const request = ++this.avatarRequest;

    cc.resources.load(path, cc.SpriteFrame, (err, frame) => {
        if (err) {
            cc.warn('[Lobby] load avatar failed', err);
            return;
        }

        if (request !== this.avatarRequest || !cc.isValid(this.node)) {
            return;
        }

        this.avatar.spriteFrame = frame;
    });
}

onDisable() {
    this.avatarRequest++;
}

请求编号表达的是:

text
只有最后一次仍然有效的请求,才有资格修改当前界面。

忽略回调结果不等于释放资源。资源所有权和释放会在 L032~L035 专门处理。


十二、场景切换也需要状态机 ​

如果按钮可连续点击,就可能同时发起 Lobby 和 Battle 切换。最小保护:

ts
private switching = false;

switchScene(name: string) {
    if (this.switching) {
        return;
    }

    this.switching = true;
    cc.director.loadScene(name, (err) => {
        this.switching = false;
        if (err) {
            cc.error('[SceneFlow] load failed', name, err);
            return;
        }
        cc.log('[SceneFlow] entered', name);
    });
}

不同 2.4.x 小版本的类型声明和回调签名应以项目安装版本为准。生产项目还应处理加载页、失败恢复、并发策略和预加载结果。


十三、退出登录时,常驻并不等于永生 ​

某些服务跨 Scene,但不应跨用户会话:网络连接、玩家缓存、聊天订阅和匹配状态。

退出登录可以重置服务,或将常驻根移回普通生命周期:

ts
cc.game.removePersistRootNode(gameRootNode);
gameRootNode.destroy();

不要只销毁节点却忘记外部连接,也不要只断开网络却保留上一位用户的数据。更清晰的层级是:

text
应用级:日志、崩溃上报、基础音频
会话级:账号、连接、玩家数据
场景级:大厅、战斗控制器
界面级:弹窗、列表 Item、临时动画

十四、Creator 2.4.x 编辑器实验 ​

本实验只通过 Creator 编辑器创建场景、节点和组件绑定。不要手工编辑 .scene、.prefab 或 .meta。

实验目标 ​

观察普通节点退出、GameRoot 跨场景保留、重复实例保护,以及隐藏和销毁的区别。

准备两个场景 ​

在 Creator 2.4.x 中创建并保存:

text
PersistLabA.scene
PersistLabB.scene

将它们加入 Build Settings 的 Scenes In Build 列表。

创建 LifecycleProbe.ts ​

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

@ccclass
export default class LifecycleProbe extends cc.Component {
    @property
    label = 'unnamed';

    onLoad() { cc.log(`[${this.label}] onLoad`); }
    onEnable() { cc.log(`[${this.label}] onEnable`); }
    start() { cc.log(`[${this.label}] start`); }
    onDisable() { cc.log(`[${this.label}] onDisable`); }
    onDestroy() { cc.log(`[${this.label}] onDestroy`); }
}

实验 A:普通节点 ​

  1. 在 A 场景创建 SceneOnlyA,挂载 LifecycleProbe。
  2. 把 label 填为 SceneOnlyA。
  3. 用 Button 调用 cc.director.loadScene('PersistLabB')。
  4. 运行 A 并点击切换。

预期日志包含:

text
[SceneOnlyA] onLoad
[SceneOnlyA] onEnable
[SceneOnlyA] start
切场景后:[SceneOnlyA] onDisable
切场景后:[SceneOnlyA] onDestroy

精确交错顺序可能受引擎流程影响;关键证据是普通节点最终进入禁用和销毁流程。

实验 B:常驻节点 ​

  1. 在 A 场景根层级创建 GameRoot。
  2. 挂载前文唯一性保护脚本和 LifecycleProbe。
  3. label 填为 PersistentRoot。
  4. 再从 A 切到 B。

预期:

text
[GameRoot] created 只出现一次
进入 B 后 GameRoot 仍存在
[PersistentRoot] 不会因普通切场景执行 onDestroy

实验 C:故意制造重复实例 ​

  1. 在 B 场景也放一个 GameRootCandidate。
  2. 挂载同一个 GameRoot 脚本。
  3. 重新从 A 切到 B。

预期:

text
[GameRoot] duplicate instance, destroy the new one

实验 D:隐藏而不销毁 ​

创建普通 Panel,挂载探针;运行时把 active 先设为 false 再设为 true。

预期:

text
false → onDisable
true  → onEnable
中间不会执行 onDestroy

十五、实验不符合预期时怎么查 ​

常驻节点没有留下来 ​

text
是否在 onLoad 调用 addPersistRootNode
→ 节点是否是 Scene 的直接子节点
→ 脚本是否编译并实际挂载
→ 是否有重复保护误杀了正确实例

GameRoot 创建了两次 ​

text
是否多个场景都自带 Bootstrap
→ 唯一性字段是否共享同一个脚本类
→ 重复实例销毁前是否已经注册事件

全局监听应放在通过唯一性检查之后。

切场景后旧 UI 还打印日志 ​

text
日志来自 Node 事件还是全局 EventBus
→ onDisable 是否执行
→ off 的事件名、函数和 target 是否与 on 一致
→ 常驻服务是否保存旧组件引用
→ 第三方定时器或网络库是否有独立取消接口

引用不是 null,但访问时报错 ​

用 cc.isValid(reference) 检查引擎对象,并追查是谁跨生命周期保存了它。不要只增加空判断掩盖所有权错误。


十六、真实项目故障:战斗重进后伤害结算两次 ​

现象 ​

第一次进入 Battle 时,每条伤害消息结算一次。返回 Lobby 再次进入后执行两次;第三次进入执行三次。

错误实现 ​

ts
onLoad() {
    NetworkService.on('damage', this.onDamage, this);
}

组件没有取消监听。常驻 NetworkService 内部保存了旧 BattleController 的回调。

根因链 ​

text
每次进入创建新控制器
→ 每个控制器向常驻服务注册
→ 离开场景没有 off
→ 监听数组保留旧控制器
→ 第 N 次进入存在 N 个监听器

修复 ​

ts
onEnable() {
    NetworkService.on('damage', this.onDamage, this);
}

onDisable() {
    NetworkService.off('damage', this.onDamage, this);
}

同时给事件系统增加调试能力:输出事件名、监听器数量、订阅者类型和注册位置。


十七、错误方案为什么看似有效 ​

错误一:把所有管理器都设成常驻 ​

短期减少初始化代码,长期会让场景边界消失。战斗规则、Lobby UI 和临时弹窗不应拥有应用级生命周期。

错误二:切场景前执行一次 offAll ​

它可能误删其他模块的合法监听,也无法说明每条监听由谁拥有。每个订阅者应取消自己的监听。

错误三:每次回调先写 if (!this.node) return ​

Creator 对象销毁后引用未必是普通 null,这种判断也没有移除外部系统中的旧回调。

错误四:用 setTimeout 猜场景已经加载完 ​

设备性能和资源状态不同,固定延时不是生命周期协议。应使用引擎回调、场景事件或项目状态机。

错误五:常驻对象永久缓存所有资源 ​

跨场景存在不代表应无限持有资源。资源缓存要有所有权、预算和释放策略。


十八、版本边界 ​

本课主线是 Cocos Creator 2.4.x:

  • 常驻根节点使用 cc.game.addPersistRootNode。
  • 移除常驻属性使用 cc.game.removePersistRootNode。
  • 场景切换使用 cc.director.loadScene。
  • 引擎对象有效性使用 cc.isValid。

Creator 3.x 的模块导入、组件类型、Node 生命周期 API 和资源系统写法不同,不要直接复制 3.x 示例到 2.4.x 项目。

稳定的工程原则是:创建者明确、所有者明确、注册取消成对、异步结果验证当前性、长生命周期对象不反向持有短生命周期 UI。

官方验证入口:


十九、练习 ​

A. 概念题 ​

  1. 普通场景节点与常驻根节点的所有权有什么不同?
  2. 为什么调用 destroy() 后不能只用 reference !== null 判断可用性?
  3. onDisable 和 onDestroy 各适合清理什么?
  4. 为什么全局服务不应保存具体场景 Label?

B. 应用题 ​

为下面对象划分生命周期:

text
崩溃上报器、账号 Token、Lobby 排行榜面板、Battle 子弹、背景音乐播放器、匹配队列

说明哪些适合常驻,哪些应该在退出登录时重置,哪些必须随场景或界面结束。

C. 诊断题 ​

游戏第三次进入 Lobby 后,一次金币变化让顶部数字刷新三次。已知金币事件由常驻 PlayerService 发出,请给出证据驱动的排查步骤。

D. 设计题 ​

开发期要求可以直接预览 Lobby 或 Battle,正式包又要求统一从 Boot 启动。请设计 GameRoot 的创建规则。


二十、练习参考答案 ​

A. 概念题答案 ​

  1. 普通节点由当前 Scene 拥有,切场景后进入销毁流程;常驻根节点脱离单个 Scene 的生命周期,由应用显式管理。
  2. destroy() 请求引擎销毁对象,JavaScript 引用不会自动同步变成 null,且销毁可能延迟到帧末。应使用 cc.isValid 做防御检查并清除过期引用。
  3. 页面隐藏后就不应工作的监听和定时器放在 onDisable;对象永久结束时才释放的所有权放在 onDestroy。清理还应保证幂等。
  4. 服务比 Label 活得更久。保存 Label 会形成长生命周期到短生命周期的反向引用。

B. 应用题答案 ​

text
应用级常驻:崩溃上报器、背景音乐播放器
会话级常驻:账号 Token、匹配队列;退出账号时清理
界面级:Lobby 排行榜面板;关闭或离开 Lobby 时结束
场景/对象级:Battle 子弹;战斗结束、命中或回收时结束

匹配队列是否跨场景取决于产品规则,但必须有明确取消点。

C. 诊断题答案 ​

text
1. 在 PlayerService 输出 coin-changed 的监听器数量。
2. 给每个 LobbyTopBar 实例编号并记录生命周期。
3. 确认每次进入是否只创建一个 TopBar。
4. 对比 on/off 的事件名、回调和 target。
5. 检查 TopBar 是否误挂在常驻根下。
6. 检查是否用匿名函数订阅,导致无法精确 off。
7. 连续进出三次,确认监听数始终从 0 到 1,再回到 0。

D. 设计题答案 ​

正式流程只由 Boot 创建 GameRoot。开发预览时,业务场景中的轻量 Bootstrap 先查询实例;不存在才创建与正式入口相同的 GameRoot。GameRoot.onLoad 仍保留唯一性保护,所有全局注册都在通过保护之后执行。构建配置要明确正式首场景是 Boot。


二十一、本课总结 ​

text
Scene 是普通节点的所有权边界。
常驻根节点跨 Scene,但不是不受管理的永久对象。
唯一性保护要发生在注册事件和启动服务之前。
onDisable 管“暂时不工作”,onDestroy 管“永久结束”。
destroy 不会把所有 JavaScript 引用自动变成 null。
异步结果必须验证对象有效性和请求当前性。
长生命周期服务应发布数据,不应持有短生命周期 UI。

二十二、下一课预告 ​

text
Lesson027|节点事件的捕获、目标和冒泡

下一课继续拆开:一次触摸如何找到目标、父节点为什么也会收到、捕获和冒泡是什么顺序,以及 stopPropagation 能解决什么。