场景切换与事件管理
2.1 跨场景传数据,三种办法三种坑
Skyline Courier 有三个场景:主菜单、关卡、结算。关卡跑完要把得分带到结算场景,结算完了要回主菜单并且把这次的记录累加到存档里。
我试过三种传数据的办法,每一种都有它明确的适用场景,也都有明确的坑。
第一种,场景加载回调。director.loadScene 的第二个参数是场景加载完成后的回调:
import { _decorator, Component, director } from 'cc';
const { ccclass } = _decorator;
@ccclass('SceneSwitcher')
export class SceneSwitcher extends Component {
toResult (score: number) {
director.loadScene('Result', () => {
// 回调里新场景已经跑起来了
const label = this.node.getComponentInChildren('ScoreLabel');
if (label) label.setText(String(score));
});
}
}
这个回调有个官方文档明确写了的限制:回调函数只能写在本脚本中。所以回调的 this 指向调用 loadScene 的那个组件,而那个组件所在的节点属于旧场景,旧场景在切换时会被销毁。
这就是这个办法最大的坑。我的第一版是在关卡场景的一个按钮组件里调 loadScene,回调里想改新场景的节点,结果改的是一个已经销毁的组件上的东西,界面上什么反应都没有,还报了一串 "object has been destroyed"。
官方推荐的解法是把这段代码挂到常驻节点上。director.addPersistRootNode(node) 会让一个根节点在场景切换时不销毁,挂在它上面的组件能活过场景切换,回调里 this 指向的就是这个常驻组件,能访问新场景。
第二种,全局单例。一个模块级的变量存全局数据,谁都能读。优点是简单,缺点是没有生命周期,没有订阅通知,数据变了得靠别人主动去查。官方文档在讲全局变量时也说得很直接:要谨慎使用。
我用它的场景很窄:只存"当前关卡编号"这种一次写一次读的标量。存复杂对象我不放全局变量。
第三种,事件。用事件做跨场景通知,谁关心谁订阅。这是我的主方案,但事件系统用错了会出大问题,下面单独一节讲。
2.2 常驻节点和预加载,各管什么
常驻节点的用法是 director.addPersistRootNode 和 director.removePersistRootNode。官方文档提醒了两件事:目标节点必须位于层级的根节点,否则设置无效;removePersistRootNode 不会立即销毁节点,只是把它还原成下次切换场景时可销毁的节点。
我把常驻节点做成了一个 GameRoot,上面挂着:
| 常驻节点上的组件 | 负责什么 |
|---|---|
| GameState | 分数、金币、当前关卡、存档读写 |
| EventBus | 全局事件收发 |
| AudioManager | 音效播放和音量 |
| SceneSwitcher | 所有场景切换都从这里发起 |
这样做的收益是:所有跨场景的东西都有唯一的家。关卡场景想读分数,找 GameState;想切换场景,找 SceneSwitcher。不用在各个场景里各自搞一套全局变量。
预加载是另一个东西。director.preloadScene 在后台静默加载场景资源,不会切换场景。加载完了再调 loadScene 就能立刻切过去。
import { director } from 'cc';
// 主菜单显示的时候,后台就把下一关加载好
director.preloadScene('Level01', () => {
console.log('下一关预加载完成');
});
// 玩家点开始
director.loadScene('Level01');
官方文档说明了一个很实用的细节:就算预加载还没完成,也可以直接调 loadScene,等预加载完成后场景会自动启动。这个设计让"预加载没做完就点开始"这种情况不需要特殊处理。
但有个坑我踩过:预加载的回调是在主菜单场景里触发的,而主菜单可能在玩家点开始之前就已经被切走了。这时候回调里的 this 指向已销毁的组件。我现在的做法是预加载回调里只改一个常驻节点上的状态标记,不碰任何场景内的 UI。
至于用 Asset Bundle 加载场景,官方文档的写法和 director.loadScene 不同:
import { assetManager, director } from 'cc';
assetManager.loadBundle('level', (err, bundle) => {
if (err) { console.error(err); return; }
bundle.loadScene('Level01', (err, scene) => {
if (err) { console.error(err); return; }
director.runScene(scene); // bundle.loadScene 不会自动运行场景
});
});
关键区别:bundle.loadScene 只加载不运行,得自己调 director.runScene。director.loadScene 是加载加运行一步到位。
2.3 黑屏和卡住,怎么查
场景切换卡住不加载,是我遇到过的最烦的 bug 之一。屏幕黑着,什么都不发生,控制台也没有明显报错。
我的排查路径现在固定下来了:
第一步,看控制台有没有报错。微信小游戏平台的报错要连真机调试才能看到,编辑器的预览看不到。这个平台差异坑过我一次:编辑器里好好的一上真机就卡死。
第二步,看是"加载卡住"还是"加载完了但场景没起来"。这两者的表现都是黑屏,但原因完全不同。
import { director, assetManager } from 'cc';
// 加载卡住:回调永远不来
director.loadScene('Level01', () => {
console.log('场景起来了'); // 这行没打印,就是加载没完成
});
// bundle 加载失败:err 有值
assetManager.loadBundle('level', (err, bundle) => {
if (err) { console.error('bundle 加载失败', err); return; }
});
第三步,如果 loadScene 的回调来了但界面还是黑的,那问题在场景本身。常见原因有三个:场景没配进构建发布面板的"参与构建场景"列表;场景里缺了必要资源导致反序列化失败;场景的入口节点没激活。
bundle.loadScene 还有一个特有的失败模式:场景放在 bundle 里,但 bundle 的名字写错了,或者场景资源不在这个 bundle 里。这种情况下 err 会有值。
资源释放这块我踩过一个坑。切场景之后旧场景的节点会被销毁,但资源不一定被释放。资源释放是另一套机制,要用 assetManager.releaseAsset 释放单个资源,或者 bundle.releaseAll() 释放整个 bundle 里的资源。
我的处理方式是:每个关卡的资源单独放一个 bundle,切到结算场景之前先 bundle.releaseAll(),然后 assetManager.removeBundle(bundle)。官方文档提醒过 removeBundle 不会自动释放资源,必须先释放再移除,顺序不能反。
2.4 幽灵监听:跨场景不解绑的代价
我做过一次长时间的挂机测试,跑了四十分钟,游戏没崩,但内存从 80MB 涨到了 240MB。打开调试器看内存曲线,是一条持续上升的直线,没有任何回落。
原因是我在关卡场景的一个全局管理器上监听了物理系统的碰撞回调:
PhysicsSystem2D.instance.on(Contact2DType.BEGIN_CONTACT, this.onContact, this);
关卡场景销毁的时候,这个管理器组件也被销毁了。但监听还挂在 PhysicsSystem2D.instance 上,而物理系统是全局单例,不会随场景销毁。于是每玩一关就多一批回调,每个回调指向一个已销毁的组件。
这些幽灵监听有两个后果。内存一直涨,这是看得见的。更隐蔽的是执行效率:每帧物理系统要把所有监听跑一遍,监听越多跑得越慢,而且跑的大部分是无效调用。
修法就是解绑。3.x 的事件系统给了一个很好用的方法:targetOff(target),移除指定 target 在当前事件目标上注册的所有监听。写在 onDestroy 里就行。
import { _decorator, Component, PhysicsSystem2D, Contact2DType, Collider2D, IPhysics2DContact } from 'cc';
const { ccclass } = _decorator;
@ccclass('ContactWatcher')
export class ContactWatcher extends Component {
onEnable () {
PhysicsSystem2D.instance.on(Contact2DType.BEGIN_CONTACT, this.onContact, this);
}
onDestroy () {
// 把自己从物理系统上摘掉,否则会变成幽灵监听
PhysicsSystem2D.instance.targetOff(this);
}
onContact (self: Collider2D, other: Collider2D, contact: IPhysics2DContact | null) {
console.log(other.node.name);
}
}
写解绑代码有个原则我坚持下来了:on 和 off 必须配对,而且 on 的第三个参数一定是 this。因为 targetOff(this) 靠的就是这个 target 标识来找到要删的监听。如果 on 的时候不传 target,或者传了别的对象,targetOff(this) 就删不掉。
另一条原则是不要用匿名函数订阅。匿名函数每次创建都是新引用,off 的时候没法精确匹配到,只能用 targetOff 一刀切。我见过有人为了省事写成 off(type, () => {...}),这样根本删不掉,因为传入的不是同一个函数对象。
还有一类幽灵监听不在事件系统里,而在计时器里。组件用 schedule 注册的定时器,在组件销毁时引擎会自动清掉,这块引擎帮了忙。但如果你把回调存到了别的地方,比如注册到某个全局数组里,那就要自己清。
2.5 一个轻量事件管理器的设计
我定下来的方案是一个很薄的事件管理器,核心只有三条规则。
第一条,所有全局事件名集中定义在一个文件里,不能在调用处直接写字符串。我建了一个 GameEvents 常量文件,每个事件名配一个类型描述:
// GameEvents.ts
export enum GameEvents {
ScoreChanged = 'score-changed',
OrderAccepted = 'order-accepted',
PackageDelivered = 'package-delivered',
LevelCompleted = 'level-completed',
}
事件名用 kebab-case,语义是"已经发生的事实",不是"请你去做某事"。score-changed 而不是 add-score。这样订阅方看到名字就知道它是通知,不是命令。
第二条,订阅和解绑成对。组件在 onEnable 里订阅,onDisable 里解绑;或者 onLoad 订阅,onDestroy 解绑。两种都行,但同一个组件里要保持一致。
第三条,不用自己写 Map 和数组。3.x 的 EventTarget 已经能做这件事,官方文档明确推荐用它来自定义事件,而且推荐不要用 Node 来做自定义事件的收发,因为那样效率不高。
基于这三条,我的管理器长这样:
import { EventTarget } from 'cc';
import { GameEvents } from './GameEvents';
// 全局唯一的事件收发点
export const gameEvents = new EventTarget();
// 类型化的订阅入口
export function onScoreChanged (cb: (score: number) => void, target: unknown) {
gameEvents.on(GameEvents.ScoreChanged, cb, target);
}
export function emitScoreChanged (score: number) {
gameEvents.emit(GameEvents.ScoreChanged, score);
}
这个设计的取舍我想说清楚。
用 EventTarget 而不是自己写一个 Map<string, Function[]>,是因为引擎的 EventTarget 内部已经处理了监听器的去重、target 匹配、off 的参数一一对应校验。自己写一遍只会引入 bug,而且官方文档在 3.x 里把 EventTarget 定位成自定义事件的推荐方案。
订阅函数要传 target 参数,这是为了 targetOff。组件调用时传 this,组件销毁时调 gameEvents.targetOff(this) 就能一次清干净。
emit 最多支持 5 个参数,这是官方文档写明的限制,出于底层事件派发的性能考虑。所以事件参数要精简,超过 5 个就打包成一个对象传。
我特意没做的两件事:一是不做类型化的 emit 返回值,二是没做事件优先级。都不需要。
还有一条约束我自己加的:不在 update 里高频 emit 全局事件。高频数据用属性直接读。比如无人机的坐标每帧都在变,用事件广播出去既慢又没人真的需要监听。
