外观
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引用已经无效。
这些现象都来自同一个问题:
场景切换之后,哪些对象应该继续活着?继续活着的对象又允许持有什么?
本课会沿着一次完整切场景过程,建立可验证的对象生命周期模型。
二、本课目标
完成本课后,你应该能够:
- 解释普通场景节点和常驻根节点在切场景时的不同命运。
- 正确使用
cc.game.addPersistRootNode,并避免重复创建常驻服务。 - 区分
onDisable、onDestroy与场景切换回调的职责。 - 处理事件、定时器、异步加载和网络回调的生命周期。
- 识别“对象已经销毁”和“对象仍被业务系统引用”的差别。
- 用 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:普通节点
- 在 A 场景创建
SceneOnlyA,挂载LifecycleProbe。 - 把
label填为SceneOnlyA。 - 用 Button 调用
cc.director.loadScene('PersistLabB')。 - 运行 A 并点击切换。
预期日志包含:
text
[SceneOnlyA] onLoad
[SceneOnlyA] onEnable
[SceneOnlyA] start
切场景后:[SceneOnlyA] onDisable
切场景后:[SceneOnlyA] onDestroy精确交错顺序可能受引擎流程影响;关键证据是普通节点最终进入禁用和销毁流程。
实验 B:常驻节点
- 在 A 场景根层级创建
GameRoot。 - 挂载前文唯一性保护脚本和
LifecycleProbe。 label填为PersistentRoot。- 再从 A 切到 B。
预期:
text
[GameRoot] created 只出现一次
进入 B 后 GameRoot 仍存在
[PersistentRoot] 不会因普通切场景执行 onDestroy实验 C:故意制造重复实例
- 在 B 场景也放一个
GameRootCandidate。 - 挂载同一个
GameRoot脚本。 - 重新从 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. 概念题
- 普通场景节点与常驻根节点的所有权有什么不同?
- 为什么调用
destroy()后不能只用reference !== null判断可用性? onDisable和onDestroy各适合清理什么?- 为什么全局服务不应保存具体场景 Label?
B. 应用题
为下面对象划分生命周期:
text
崩溃上报器、账号 Token、Lobby 排行榜面板、Battle 子弹、背景音乐播放器、匹配队列说明哪些适合常驻,哪些应该在退出登录时重置,哪些必须随场景或界面结束。
C. 诊断题
游戏第三次进入 Lobby 后,一次金币变化让顶部数字刷新三次。已知金币事件由常驻 PlayerService 发出,请给出证据驱动的排查步骤。
D. 设计题
开发期要求可以直接预览 Lobby 或 Battle,正式包又要求统一从 Boot 启动。请设计 GameRoot 的创建规则。
二十、练习参考答案
A. 概念题答案
- 普通节点由当前 Scene 拥有,切场景后进入销毁流程;常驻根节点脱离单个 Scene 的生命周期,由应用显式管理。
destroy()请求引擎销毁对象,JavaScript 引用不会自动同步变成null,且销毁可能延迟到帧末。应使用cc.isValid做防御检查并清除过期引用。- 页面隐藏后就不应工作的监听和定时器放在
onDisable;对象永久结束时才释放的所有权放在onDestroy。清理还应保证幂等。 - 服务比 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 能解决什么。
