ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Cocos Creator 场景切换全解析:从机制到实践,告别流程混乱

Cocos Creator 场景切换全解析:从机制到实践,告别流程混乱 1. 场景不是界面先理顺 Cocos Creator 中的场景概念与构建配置先说个很多新手都会踩的坑把场景理解成游戏里的一个页面。这个概念一旦偏了后面做场景切换时就会绕很多弯路尤其是当你想做主菜单→游戏关卡→结算页这种最基础的游戏流程时你会发现网上教程各写各的有的讲cc.director.loadScene有的讲编辑器里拖拽绑定看完还是不知道到底怎么用。我先把场景这个概念掰开揉碎讲清楚后面对代码和事件绑定的理解就顺了。在 Cocos Creator 里场景Scene是一个完整的、独立运行时环境。它包含了一整套节点树、组件、灯光、相机、UI 画布、音频甚至逻辑脚本。可以理解为场景就是游戏世界的一个状态快照一个关卡、一个主菜单、一个结算界面都可以各自做成一个场景。编辑器里每个.scene文件对应一个场景双击就能打开编辑。很多人误以为场景就是UI 界面所以把主菜单、设置、排行榜全塞进同一个场景里用隐藏/显示节点的方式切来切去。这种做法不是不行但对于中大型项目后续维护就是灾难节点层级几千个查找困难场景加载速度被拖慢而且多个 UI 之间的状态管理极其容易出 bug。最合理的做法是把逻辑独立、加载时机不同的模块拆分成多个场景用场景切换来组织游戏流程。1.1 创建多个场景的标准操作在 Cocos Creator 的assets面板里右键 → 创建 → Scene就能新建一个场景文件。建好后给它起个有意义的名字比如MainMenu、GameLevel、GameOver。注意文件名就是场景名也是后续代码里用来加载场景的标识。所以命名一定要规范别用中文名也别叫什么new scene 1不然到了写loadScene的时候你自己都不知道该填什么。我习惯的项目结构是这样的assets/ scenes/ MainMenu.scene GameLevel.scene GameOver.scene scripts/ MainMenu.ts GameLevel.ts GameOver.ts prefabs/ ...每个场景对应一个同名的入口脚本挂在场景根节点或者一个专门的GameManager节点上。这样场景之间的职责边界非常清晰主菜单场景只负责主菜单的 UI 交互游戏关卡场景只负责玩法逻辑结算场景只负责展示结果。创建好场景后需要在构建发布面板里确认一下场景列表。快捷键 CtrlShiftB 打开构建发布在参与构建的场景里勾选所有需要打包的场景。这一步经常被忽略结果就是编辑器预览时一切正常一打包 APK某些场景进不去报错说场景找不到。为什么因为没勾选的场景根本没被打进包里。1.2 场景切换时的资源加载机制理解场景切换必须理解 Cocos Creator 的资源加载机制。每个场景文件会引用一堆资源图片、音频、预制体、图集、材质等。当调用loadScene切换到新场景时引擎会做两件事销毁当前场景的所有节点然后加载并实例化新场景的所有内容。这个销毁是彻底的。当前场景里所有节点的onDestroy会被调用所有挂载的组件实例会被回收场景根部挂的普通全局变量如果被写在组件属性上也会一并消失。很多新手第一次写出切场景后数据丢失的 bug就死在这一步——组件属性的生命周期是跟着场景走的不是跟着程序走的。但也别慌后面第 4 章会专门讲跨场景数据保存的方案。这里你要先建立起一个核心认知场景切换 销毁重建所以在切换前要想清楚哪些数据需要保留、用什么方式保留而不是在切换后才着急找数据。2. 代码切换场景director.loadScene 的前因后果代码切换场景是 Cocos Creator 里最基础也最核心的场景切换方式掌握它之后不管是按钮点击、定时跳转、还是游戏结束自动切结算页都是同一套逻辑。Cocos Creator 3.x 的 API 是director.loadScene(sceneName)在 2.x 里是cc.director.loadScene(sceneName)区别只在有没有cc前缀使用逻辑完全一致。为什么用代码切换而不是纯编辑器操作因为实际项目里场景切换绝大多数不是点击按钮这么简单。比如玩家通关后需要先播放一段过场动画再进入下一关或者游戏失败后先弹出结算界面点击重新开始才回到关卡场景。这些带条件的跳转编辑器拖拽绑定根本做不了必须在代码里写判断逻辑。2.1 最基础的代码切换一个完整示例先上一个最朴素的示例在MainMenu场景的某个组件脚本里写import { _decorator, Component, director } from cc; const { ccclass } _decorator; ccclass(MainMenu) export class MainMenu extends Component { start() { // 5 秒后自动进入游戏关卡 this.scheduleOnce(() { director.loadScene(GameLevel); }, 5); } }这段代码的逻辑很简单场景加载完成后5 秒后切到GameLevel场景。实际项目里你只需要把scheduleOnce里的回调换成玩家点击事件触发即可。拿到这段代码后新手最容易提出一个疑问这个GameLevel字符串哪里来的答案就是场景文件的名字。你在assets面板里看到GameLevel.scene代码里就填GameLevel。这里有个我踩过的坑场景重命名后代码里的字符串不会自动跟着更新。你重构了场景名但忘了改代码运行时报Scene xxx not found排查半天才发现是名字对不上。所以场景命名要尽量一次到位或者在重命名后全局搜索一遍代码里对应的字符串。2.2 预加载场景避免切换白屏loadScene是加载并切换所以引擎会先加载新场景的全部资源完成后再跳转。如果新场景资源多、体积大加载期间屏幕会卡在旧场景最后一帧体验很差。解决办法是director.preloadScene(sceneName, onLoaded, onProgress)也就是预加载。把新场景提前加载到内存里等真正需要切换时loadScene的速度就会快很多因为资源已经就绪了。// 在进入主菜单时预加载游戏关卡场景 director.preloadScene(GameLevel, (err) { if (err) { console.error(预加载失败, err); return; } console.log(GameLevel 预加载完成); }, (completedCount, totalCount) { const progress completedCount / totalCount; console.log(预加载进度: ${Math.round(progress * 100)}%); } );预加载的典型使用场景是玩家在主菜单停留时后台预加载游戏主关卡在关卡加载界面时预加载结算场景。我的经验是不要预加载用不上的场景尤其不要一口气预加载全部场景那样启动内存占用会飙升低端安卓机上很容易被杀进程。预加载要按游戏流程下一站要用什么就提前载什么。loadScene本身也支持回调director.loadScene(GameLevel, (err) { if (err) { console.error(场景加载失败, err); return; } console.log(场景切换完成); });如果你在切换后还要做一些初始化、传递参数之类的事强烈建议把逻辑写在这个回调里而不是直接放在loadScene下一行。因为loadScene是异步的下一行的代码会先执行此时新场景的节点可能还没创建完成。很多人在这里踩坑调用loadScene后立刻find(Canvas/xxx)去找节点结果返回 null报错说节点不存在。2.3 切换前后的生命周期调用顺序有一件事官方文档写得很零散但实际开发中非常关键——场景切换时各生命周期方法的调用顺序。我实测下来的调用顺序是这样的以场景 A 切到场景 B 为例排在第一的是场景 A 里所有组件的onDisable和onDestroy也就是旧场景开始销毁。接着创建场景 B 的节点树执行场景 B 里所有组件的onLoad然后是onEnable最后start场景 B 正式运行。这个顺序里最值得注意的点是先销毁旧场景再加载新场景。所以如果你在场景 A 的onDestroy里读了某个全局数据把它传给场景 B这个操作是安全的。反过来如果你在场景 A 的onDisable里访问场景 B 的节点那就会出错——因为此时场景 B 还没创建。我踩过一次这样的坑在场景 A 的销毁回调里调用了director.loadScene(GameOver)结果场景 A 还没销毁完又触发了一次场景切换引擎直接报场景切换中无法再次切换。从此我给自己定了个规矩场景切换的入口统一放在事件回调或定时器里绝不放在生命周期方法onLoad、onDestroy中直接触发。3. 事件绑定切换场景从编辑器拖拽到动态监听的完整套路代码里写死5 秒后切场景只是演示实际游戏里玩家需要自己控制跳转时机最常见的入口就是一个按钮。按钮的场景切换有两种做法编辑器拖拽绑定和代码动态监听。两种方式适用场景不同我建议都要掌握。3.1 编辑器里直接拖拽绑定点击事件最简单的方式在场景里创建一个 Button 节点选中它在Button 组件的Click Events数组里点加号新增一个条目。然后把挂有脚本的节点拖到Target槽里组件下拉框选择脚本名方法名选择你写的跳转函数。示意流程大致是Button 节点 → Button 组件 → Click Events → 添加事件 → 拖拽目标节点 → 选择组件 → 选择方法。配套的脚本长这样import { _decorator, Component, director } from cc; const { ccclass } _decorator; ccclass(MainMenu) export class MainMenu extends Component { // 这个方法会被 Button 点击事件调用 onStartGame() { director.loadScene(GameLevel); } }注意编辑器的事件绑定点的是组件里的方法名所以方法必须是publicTypeScript 里默认就是 public而且不能有参数或者参数能由事件系统自动传入但新手阶段建议保持无参。绑定完成后运行预览点击按钮场景就会切换。3.2 代码动态绑定点击事件的写法编辑器拖拽绑定虽然直观但因为事件配置存在.scene文件里重构脚本时极易失效——比如方法改名了或者脚本重新挂载了编辑器里的事件引用就断了点击没反应而且没有报错排查成本很高。代码绑定则灵活得多。在start生命周期里获取按钮节点注册监听import { _decorator, Component, Button, director } from cc; const { ccclass } _decorator; ccclass(MainMenu) export class MainMenu extends Component { start() { // 先从当前节点下找到按钮 const btn this.node.getComponent(Button); if (btn) { btn.node.on(Button.EventType.CLICK, this.onStartGame, this); } } onStartGame() { director.loadScene(GameLevel); } onDestroy() { // 反注册防止内存泄漏 const btn this.node.getComponent(Button); if (btn) { btn.node.off(Button.EventType.CLICK, this.onStartGame, this); } } }Button.EventType.CLICK在 Cocos Creator 3.x 里可以直接写成click但用枚举是官方推荐做法因为引擎升级时若枚举值变了代码会自动适配。这里要注意组件挂载的节点本身必须是 Button 节点getComponent(Button)才能拿到组件。如果你的按钮是子节点需要先this.node.getChildByName(StartBtn).getComponent(Button)找到子节点再取组件。为什么我强调onDestroy里要off反注册因为 Cocos Creator 的事件系统是在 Node 上监听的虽然场景销毁时节点本身会释放但如果节点被做成常驻节点后续会讲或者按钮被复用不反注册就会导致回调被多次触发。新手阶段可以宽松一点但养成随手off的习惯对后面做复杂项目绝对有好处。3.3 事件绑定最容易踩的三个坑第一个坑重复绑定导致点击一次触发两次跳转。典型场景是你在onLoad里写了node.on(...)然后场景切回来再次加载时又执行了一次onLoad同一个按钮被绑了两次监听。点击一次loadScene被调用两次第一次成功后场景已经切走了第二次往往报错或者出现诡异的 bug。对策就是确保绑定逻辑只执行一次或者在绑定前先off一次。第二个坑按钮事件回调里场景名拼写不一致。编辑器里看场景文件名是GameLevel但代码里写成了gameLevel或者多了个空格。场景名是区分大小写的这种错误在运行时表现为点击按钮没反应控制台报Failed to load scene。排查方法很简单在回调第一行打印场景名确认传入的值正确。第三个坑隐藏节点上的按钮仍然可以被点击。有些新手把 UI 面板切走时只是node.active false但忘记把按钮的interactable设成 false导致玩家疯狂点击屏幕时触发了一连串场景切换。建议在切换场景前先禁用所有 UI 的交互或者直接销毁入口按钮节点。4. 场景切换中的数据保存不留神就会丢的全局变量先说一个很多新手都会犯迷糊的认知误区。很多人以为我在场景 A 的某个组件里写了一个static count 0或者声明了一个全局变量window.gameData {}切换场景后这个数据应该还在。事实上静态属性和挂载在window上的全局数据在场景切换后确实还在因为它们不属于任何一个场景节点。真正会被销毁的是挂在节点组件上的普通实例属性。比如你写了这么一个组件ccclass(PlayerData) export class PlayerData extends Component { score: number 0; }然后把它挂在了场景 A 的某个节点上玩家玩了一会儿score变成了 500。一切换场景这个节点连同组件一起被销毁score就没了。数据跟着组件走组件跟着节点走节点跟着场景走——这是理解场景数据保存最核心的一句话。4.1 最实用的跨场景数据方案全局单例跨场景保存数据的办法有很多最实用也最推荐新手使用的是全局单例。原理很简单把数据挂在模块级别而不是场景节点上这样场景销毁重建不影响它。// GameData.ts export class GameData { private static _instance: GameData | null null; static get instance(): GameData { if (!this._instance) { this._instance new GameData(); } return this._instance; } score: number 0; level: number 1; }在任意场景的组件里读写import { GameData } from ./GameData; // 写入 GameData.instance.score 100; // 读取 const score GameData.instance.score;这个单例不依赖任何场景节点纯粹是一个 JavaScript 对象只要游戏进程不退出数据就一直在。配合loadScene的回调使用就能实现结算页读取关卡数据这种常见需求// 在关卡场景里玩家死亡时 GameData.instance.score this.score; GameData.instance.level this.currentLevel; director.loadScene(GameOver, () { console.log(结算场景已加载当前分数, GameData.instance.score); });注意全局单例在场景切换后不会被销毁所以要注意数据重置时机。比如玩家点击重新开始时需要手动把score清零。否则会出现上一局的分数带到下一局这种 bug。4.2 常驻节点跨场景存活的另一种思路全局单例适合存数据但如果你要跨场景保留的是一整个节点比如背景音乐播放器、全局消息提示 UI、玩家角色模型那更合适的是常驻节点。Cocos Creator 提供了game.addPersistRootNode(node)接口把一个节点标记为常驻。被标记后这个节点在场景切换时不会被销毁而是原封不动地保留到下一个场景。比如import { _decorator, Component, game } from cc; const { ccclass } _decorator; ccclass(BackgroundMusic) export class BackgroundMusic extends Component { onLoad() { // 把这个节点设为常驻节点 game.addPersistRootNode(this.node); } }这个BackgroundMusic组件挂在场景根节点下的一个空节点上节点上挂了一个 AudioSource 组件。只要这个场景加载过一次音乐节点就会跨场景存活后续切换场景音乐都不会断。用常驻节点要注意一个问题不能重复挂载。如果主菜单场景加载时生成了一个常驻节点进入游戏关卡后关卡场景里又有一个同名节点调用了addPersistRootNode内存里就会有两个音乐播放器声音重叠。稳妥的做法是先检查是否已存在onLoad() { // 避免重复常驻 if (!game.isPersistRootNode(this.node)) { game.addPersistRootNode(this.node); } }实际项目中我的习惯是数据用全局单例UI/音频/玩家角色等需要存活的节点用常驻节点两者配合使用。全局单例灵活、轻量但要自己管理生命周期常驻节点直观、天然支持组件但要防重复挂载。新手从全局单例入手就好等遇到场景切换后背景音乐中断这类问题时再引入常驻节点。5. 真机调试与场景加载优化实测中容易忽略的细节场景切换在不同环境下表现差异很大我见过不少人在浏览器预览里一切正常一打包到真机就各种问题。这里的坑值得专门写一节。5.1 编辑器预览和真机的差别在浏览器里调场景切换资源加载用的是本地文件服务速度快基本看不到白屏。但打包成 APK 后资源需要从本地包体或者远程服务器加载速度会慢很多。低端安卓机上一个大场景从loadScene到显示耗时可能超过两秒。所以我建议所有做场景切换的教程项目从一开始就要养成用真机预览的习惯。Cocos Creator 支持浏览器预览的同时也支持直接连接安卓设备进行真机调试。真机测试能暴露出一堆开发模式下发现不了的问题加载慢、内存不足闪退、音频资源没打包进去等。5.2 场景加载性能优化的三个方向场景加载慢的根本原因是资源量太大。优化的方向有三个第一资源裁剪。同一个场景里尽量不要塞入另一套场景的专属资源。比如主菜单场景的背景图不应该引用关卡场景里才用到的模型、贴图。Cocos Creator 在构建时会做资源合并但如果你在多个场景里引用了同一张大图它会被打入多个场景包造成重复下载和重复内存。第二图集合并。UI 图片尽量打图集而不是散图。散图数量一多加载时间会指数级上升。创建图集的方式是在assets面板选中多张图片右键 → 创建图集。合并后原本几百张小图变成了一张大图加一个配置文件加载效率极大提升。第三场景分包与远程加载进阶。Cocos Creator 支持把某些资源放到 Bundle 里按需加载。比如你的游戏有 10 个关卡每个关卡 100MB 资源全部打进安装包显然不行。这时候可以把每个关卡单独做成一个 Bundle玩家玩到第 3 关时才动态下载第 3 关的 Bundle加载完再进场景。这个方案对新手来说偏复杂但至少要知道有这种玩法等项目体量上去了再回来看这篇文章你会发现方向还是对的。5.3 常见报错和排查思路最后整理几个我遇到过的、跟场景切换直接相关的报错方便你遇到问题时快速定位。场景名找不到的报错控制台会提示Scene xxx not loaded。先检查构建发布面板里有没有勾选该场景再看代码里的字符串大小写和拼写。如果两个都确认无误就把xxx.scene文件从 assets 里删掉重新导入一次偶尔会有缓存的坏文件。切换后卡在加载界面常见原因是新场景的某个脚本在onLoad里抛了异常。此时新场景没有正常渲染但引擎又已经销毁了旧场景看起来就像卡死。排查方法是看控制台是否有红色报错或者在onLoad里加日志看看节点创建到哪一步停下来了。内存溢出的报错表现为运行一段时间后游戏被系统杀掉。这时候去检查有没有频繁切换大场景而没有释放资源。Cocos Creator 3.x 有资源管理器但默认不会立刻释放旧场景的资源。如果你遇到明显的内存增长可以在切换场景前手动调用资源释放接口把旧场景不再使用的资源释放掉。有个细节值得一提切换场景时的加载动画我建议做成独立的加载场景而不是在当前场景上叠一个加载 UI。因为director.loadScene在加载过程中旧场景的节点还在但主循环可能处于阻塞状态UI 动画不一定能流畅播放。独立加载场景的思路是主菜单点击开始后先切到一个非常简单的Loading场景在Loading场景里做预加载加载完成后再切到真正的游戏场景。这个方案虽然多了一次切换但体验更好也是商业游戏里最常见的做法。如果你现在手头正好卡在场景切换上我的建议是先做最小的例子一个小场景 A 一个场景 B一个按钮一次切换跑通之后再逐步增加资源量、添加数据传递、做预加载。把这篇文章提到的每个环节都亲手验证一遍你对 Cocos Creator 场景机制的理解会比看十遍文档都扎实。
返回列表