《Cocos 游戏开发实战》5.3 常驻节点与全局管理

本节讲清 director.addPersistRootNode 到底做了什么、目标节点必须位于层级根节点这条约束从何而来,用 PersistentRoot 承载音频、存档与转场等全局管理器,并说明常驻节点上组件 onLoad 只调一次带来的初始化陷阱与切后台自动存档的接法。

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),否则无效。 也就是说不光要有父节点,父节点还必须是场景本身。三条约束按重要性排列:

  1. 节点必须是场景的直接子节点。 挂在 Canvas 下面的节点传进去不会有任何效果,而且不会报错——这是最容易排查半天的一条。判断办法是看节点的 parent 是不是 Scene。
  2. 不能把场景本身传进去。 director.getScene() 返回的 Scene 也是 Node 的子类,但它就是场景,不能常驻。
  3. 常驻节点会被移到新场景的根下。 切场景时引擎把它「搬」到新场景层级里,因此它在新场景中依然可见、依然在渲染。

在编辑器里,常驻是在代码里声明的,不是属性面板上勾的。所以 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 层级与多分辨率适配 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画