5.3 常驻节点与全局管理
5.2 场景切换与预加载
里那张转场遮罩反复提到「挂在常驻根上」,这一节把它兑现。问题本身很简单:Cocos 切换场景时会销毁整棵旧场景树,main 里挂着的音频管理器、存档管理器、转场遮罩,一切都会跟着没。但游戏需要一批「活过整个生命周期」的对象——背景音乐不能每次切场景重头播,存档管理器不能每次重建,转场遮罩必须跨场景存在才挡得住黑帧。
Cocos Creator 3.8 给的机制是常驻根节点(persist root node):把某个节点声明为常驻,它就不再随场景切换被销毁。这一节讲清它的边界条件,并把它组织成一个可控的全局管理层。
与站内
game/client/那批文章的区分:那些文章把「单例管理器」当作通用设计模式讲,本节讲的是 Cocos 场景树销毁语义下常驻节点的真实约束,以及它在block-dash里的具体落地。
5.3.1 场景切换到底销毁了什么
要理解常驻节点的必要性,先要看清楚一次 director.loadScene 之后什么还在、什么没了。
| 对象 | 切场景后 | 说明 |
|---|---|---|
| 旧场景的节点树 | 销毁 | 包括所有子节点与挂在它们身上的组件 |
组件的 onLoad / start | 重新触发 | 新场景的组件从零走一遍生命周期 |
| 挂在全局对象上的监听 | 保留 | 比如 director.on(...)、game.on(...),需要手动 off |
director / game / sys / assetManager | 保留 | 引擎单例,不属于任何场景 |
tween 动画 | 随目标节点销毁而停 | 目标被销毁后 tween 自动失效 |
| 常驻根节点及其子树 | 保留 | 本节的主角 |
第三行和第六行是全部的关键。「全局对象上的监听保留」意味着切场景会泄漏监听,而**「常驻根保留」意味着可以有一个跨场景的容器**。转场遮罩、音频管理器、存档管理器都该放进后者。
5.3.2 addPersistRootNode 的三条约束
director 上有一组常驻节点接口,cc.d.ts 里的签名很直白:
addPersistRootNode(node: Node): void;
removePersistRootNode(node: Node): void;
isPersistRootNode(node: Node): boolean;
但文档注释里藏了一条硬约束:目标节点必须位于层级的根节点(root level of hierarchy),否则无效。 也就是说不光要有父节点,父节点还必须是场景本身。三条约束按重要性排列:
- 节点必须是场景的直接子节点。 挂在
Canvas下面的节点传进去不会有任何效果,而且不会报错——这是最容易排查半天的一条。判断办法是看节点的parent是不是Scene。 - 不能把场景本身传进去。
director.getScene()返回的Scene也是Node的子类,但它就是场景,不能常驻。 - 常驻节点会被移到新场景的根下。 切场景时引擎把它「搬」到新场景层级里,因此它在新场景中依然可见、依然在渲染。
在编辑器里,常驻是在代码里声明的,不是属性面板上勾的。所以 main.scene 的节点树里要预留一个空的 PersistRoot 节点,放在场景根下、Canvas 的同级(L3 工程文件,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现):
{
"__type__": "cc.Node",
"_name": "PersistRoot",
"_parent": { "__id__": 1 },
"_children": [],
"_components": [ { "__id__": 20 } ]
}
需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 打开 assets/scenes/main.scene 后,层级管理器里 PersistRoot 与 Canvas、Camera 平级,都在场景根下;它没有 UITransform,因为它不参与 UI 布局。属性面板里它只挂一个自定义脚本组件 PersistentRoot。
5.3.3 PersistentRoot:全局管理器的唯一挂载点
与其到处调 addPersistRootNode,不如只声明一个常驻根,其余全局对象全部挂在它下面。这样「谁常驻」这件事在工程里只有一处可查。
import { _decorator, Component, Node, director, game, Game, sys } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('PersistentRoot')
export class PersistentRoot extends Component {
@property(Node) transitionMask: Node | null = null;
@property(Node) audioRoot: Node | null = null;
private static shared: PersistentRoot | null = null;
static get instance(): PersistentRoot | null {
return PersistentRoot.shared;
}
onLoad(): void {
if (PersistentRoot.shared !== null) {
// 第二个常驻根出现说明启动流程被走了两遍,直接销毁自己
this.node.destroy();
return;
}
PersistentRoot.shared = this;
// 关键一步:声明常驻。必须在节点已经挂进场景之后调用
director.addPersistRootNode(this.node);
game.on(Game.EVENT_HIDE, this.onHide, this);
game.on(Game.EVENT_SHOW, this.onShow, this);
console.log('[PersistentRoot] 已常驻,平台', sys.platform);
}
onDestroy(): void {
game.off(Game.EVENT_HIDE, this.onHide, this);
game.off(Game.EVENT_SHOW, this.onShow, this);
if (PersistentRoot.shared === this) PersistentRoot.shared = null;
}
private onHide(): void {
// 切后台:暂停 BGM、把当前进度落盘(见 11.1)
console.log('[PersistentRoot] 进入后台,自动存档');
}
private onShow(): void {
// 回前台:恢复 BGM、校正时间
console.log('[PersistentRoot] 回到前台');
}
}
管理器的具体实现挂在 PersistRoot 下面,而不是做成散落各处的全局单例。以存档管理器为例:
// src/game/save-manager.ts —— 挂在 PersistRoot 下
import { _decorator, Component, sys } from 'cc';
const { ccclass } = _decorator;
export const SAVE_KEY = 'block-dash.save.v1';
interface SaveData {
coins: number;
maxLevel: number;
lang: string;
}
const DEFAULT_SAVE: SaveData = { coins: 0, maxLevel: 1, lang: 'zh' };
@ccclass('SaveManager')
export class SaveManager extends Component {
private data: SaveData = { ...DEFAULT_SAVE };
private dirty = false;
onLoad(): void {
this.load();
}
load(): void {
const raw = sys.localStorage.getItem(SAVE_KEY);
if (raw === null) return;
try {
const parsed = JSON.parse(raw) as Partial<SaveData>;
this.data = { ...DEFAULT_SAVE, ...parsed };
} catch (e) {
// 存档损坏时退回默认值,绝不阻断启动
console.warn('[SaveManager] 存档解析失败,使用默认值', e);
}
}
markDirty(): void {
this.dirty = true;
}
/** 只在切后台或关卡结束时落盘,避免每帧写 localStorage */
flush(): void {
if (!this.dirty) return;
sys.localStorage.setItem(SAVE_KEY, JSON.stringify(this.data));
this.dirty = false;
}
get coins(): number {
return this.data.coins;
}
}
try / catch 是必须的:JSON.parse 遇到损坏的存档会抛异常,不接住的话整个启动流程会中断在 onLoad 里,玩家看到的是白屏而不是「存档损坏」的提示。flush 用 dirty 标志合并写操作,是因为 sys.localStorage.setItem 在 H5 上是同步阻塞的,每帧写一次会直接拖低帧率。
哪些状态该进存档,有一条简单判据——下次冷启动时该不该还在:
| 状态 | 是否入存档 | 理由 |
|---|---|---|
| 金币、最高关卡 | 是 | 跨会话累计的进度 |
| 语言、音量、震动开关 | 是 | 玩家偏好,重启应保持 |
| 当前关卡内坐标、剩余时间 | 否 | 属于一局游戏,进常驻根会串关 |
| 临时道具倒计时 | 否 | 由关卡场景组件持有,随场景销毁 |
onLoad 里那句「必须在节点已经挂进场景之后调用」是有原因的:addPersistRootNode 内部会读节点的 parent 做校验,onLoad 触发时节点已在场景树上,是安全时机;如果在 constructor 里调用,parent 还是 null,声明会静默失效。用静态字段 shared 做单例,是为了让别的脚本能通过 PersistentRoot.instance 拿到管理器,而不必写 find('PersistRoot') 这种一改层级就断的路径。
5.3.4 常驻节点的生命周期陷阱
常驻节点最反直觉的一点:它的组件 onLoad 只调用一次。 普通场景里的组件每次进入场景都会重新 onLoad,而常驻节点跨过 N 次场景切换,onLoad 依然只执行过一次。
这条语义带来两个必须处理的后果:
- 依赖场景的回调不能放在
onLoad。 比如「每次进入主界面刷新金币数」,写在常驻根上只在第一次生效,之后切回来数字不更新。正确做法是监听Director.EVENT_AFTER_SCENE_LAUNCH,在每次新场景挂载后手动刷新。 - 常驻根不会自动清理自己的状态。 它活多久,状态就留多久。所以常驻根上只放「本来就该全局唯一」的东西(音频、存档、转场),不放「属于某一关」的玩法状态。
import { _decorator, Component, director, Director } from 'cc';
const { ccclass } = _decorator;
@ccclass('GlobalStateBridge')
export class GlobalStateBridge extends Component {
onEnable(): void {
// 每次新场景挂载后都要刷新的逻辑,走场景事件,而不是 onLoad
director.on(Director.EVENT_AFTER_SCENE_LAUNCH, this.refresh, this);
}
onDisable(): void {
director.off(Director.EVENT_AFTER_SCENE_LAUNCH, this.refresh, this);
}
private refresh(): void {
const scene = director.getScene();
if (scene === null) return;
// 只有主界面才需要刷新金币 HUD
if (scene.name === 'main') {
console.log('[GlobalStateBridge] 刷新主界面 HUD');
}
}
}
5.3.5 反模式:别把玩法状态塞进常驻根
常驻根很顺手,于是很容易被当成「万能全局容器」。下面是三个真实会出问题的写法。
| 反模式 | 症状 | 正确做法 |
|---|---|---|
| 把玩家节点挂进常驻根 | 回主界面后上一关的角色还在,坐标是旧的 | 玩家节点属于关卡场景,随场景销毁 |
| 把关卡计时器状态存常驻根字段 | 第二关开局读到了第一关的剩余时间 | 关卡状态放进随场景销毁的组件,或由路由器显式重置 |
在常驻根 update 里做全屏遍历 | 帧率随游戏时长下降(节点越积越多) | 常驻根不写 update,逻辑下放到场景内组件 |
判断标准只有一句话:这个状态在「关掉游戏再打开」时该不该还在? 该在的(音量、存档、语言)放常驻根;不该在的(当前关卡、当前分数、临时道具)放场景内。block-dash 里常驻根只挂三个东西:TransitionMask、AudioRoot(音频管理器与音效池)、SaveManager。
5.3.6 工程收口
这一节做完,工程里多了这些确定的东西:
assets/scenes/main.scene根下新增PersistRoot节点(与Canvas平级),挂PersistentRoot组件。assets/scripts/game/persistent-root.ts:常驻声明 + 单例入口 + 前后台事件。assets/scripts/game/global-state-bridge.ts:场景事件驱动的全局刷新点。- 约定:全工程只有一处调用
director.addPersistRootNode,新增全局管理器一律挂在PersistRoot下,不再新增常驻根。
到这里第 5 章收尾:boot.scene 负责把玩家接进来,TransitionMask 负责把玩家送出去,PersistRoot 负责这两者之间活下来的东西。下一章转入 UI 系统,先解决「一块设计稿怎么在 960×540 到 2532×1170 之间的所有屏幕上都不变形、不越界」。
阅读导航:上一节:5.2 场景切换与预加载 · 下一节:6.1 UI 层级与多分辨率适配 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。