《Cocos 游戏开发实战》18.2 复盘:Cocos 工程里最容易踩的坑

本节把《方块冲刺》全卷真正踩过的坑收敛成一份复盘:平台判断用字符串比较、.meta 丢失致引用断裂、动态加载路径带扩展名与大小写、常驻节点忘了移除、资源不释放、分包名与目录名不一致、三端存档差异、构建缓存带进旧代码,每条都给出症状、根因与修法,并汇总成一张速查表。

18.2 复盘:Cocos 工程里最容易踩的坑

这一卷写了 17 章,从建工程到发布。过程中真正让《方块冲刺》卡住的,从来不是「引擎不会用」,而是一类反复出现的小坑:判错平台、丢了 .meta、路径写错大小写、忘了释放资源。这一节把它们收敛成一份复盘——不是泛泛的「注意事项」,而是本卷真实发生过、有症状、有根因、有修法的清单。

本节把《方块冲刺》的开发经验沉淀成可复用的坑表:八个高频坑逐条给症状与修法,每个都配上正确的 L2 写法,最后汇总成一张「症状 → 根因 → 章节」的速查表。它的用途是给下一个项目省时间——这些坑几乎每个 Cocos 项目都会再踩一遍。

18.2.1 平台判断:字符串比较是头号杀手

症状:微信小游戏里走了 H5 的分支,存档写到浏览器 localStorage,用户换设备后进度全丢。

根因:手写了平台字符串判断,比如 if (sys.platform === 'wx') 或 if (sys.platform === 'wechatgame')。而 3.8.8 里 sys.platform 是枚举,微信小游戏的值是 'WECHAT_GAME',字符串对不上。

修法:永远用枚举成员,不用字面量:

import { sys } from 'cc';

export type Channel = 'wx' | 'native' | 'web';

export function detectChannel(): Channel {
  if (sys.platform === sys.Platform.WECHAT_GAME) return 'wx';
  if (sys.isNative) return 'native';
  return 'web';
}

sys.Platform 的成员全部是大写下划线形式(MOBILE_BROWSER、DESKTOP_BROWSER、WECHAT_GAME、ANDROID、IOS),写错大小写编译器不会报——因为它是字符串枚举。所以这条只能靠约定:任何地方判平台,只允许 sys.Platform.X 或 sys.isNative。

18.2.2 .meta 丢失与资源引用断裂

症状:git pull 之后,某个预制体上挂的脚本变成 Missing Script,或者纹理引用丢失。

根因:.meta 文件没进版本库。Cocos 用 .meta 里的 uuid 记录每个资源的身份,场景和预制体引用资源时存的是 uuid 而不是路径。丢了 .meta,编辑器重新生成一个新 uuid,旧引用就断了。

修法:

  • .meta 必须提交,不要加进 .gitignore。
  • 删资源要连同 .meta 一起删,改名也要让编辑器自己改(它才会同步 .meta),不要在文件系统里直接 mv。
  • library/、temp/、build/ 才是该忽略的目录,别把 assets/ 下任何 .meta 漏掉。

这条没有代码可写,它纯粹是版本管理约定。但它是本卷最贵的一个坑——断掉的引用不会报错,只会在运行时表现为「东西不见了」。

18.2.3 动态加载路径:扩展名与大小写

症状:resources.load('textures/coin', ...) 返回 null,但文件明明在。

根因:两个经典错误——路径带了扩展名,或大小写不一致。

Cocos 的 resources 与 Bundle 动态加载,路径不带扩展名,且区分大小写。textures/Coin 和 textures/coin 是两个不同的键。

修法:

import { resources, SpriteFrame } from 'cc';

export function loadCoin(onDone: (frame: SpriteFrame | null) => void): void {
  resources.load('textures/coin/spriteFrame', SpriteFrame, (err, frame) => {
    if (err) {
      console.error('加载金币纹理失败', err);
      onDone(null);
      return;
    }
    onDone(frame);
  });
}

两个细节:

  • 加载 SpriteFrame 时路径要带 /spriteFrame 子资源后缀,因为图集里的一张图是 Texture2D 下的子资源。
  • 加载失败要有回调兜底,不能 throw——动态加载失败是运行时常态,不该让整个流程崩。

排查这类问题时,最快的办法是在编辑器里把资源拖进属性面板,对比它生成的路径字符串。编辑器写进序列化数据里的路径,就是运行时该用的路径,直接抄下来即可,别自己拼。

18.2.4 常驻节点忘了移除

症状:每次场景切换后,音效管理器/事件总线的回调触发两次、三次,越来越多次。

根因:常驻节点用 director.addPersistRootNode 加进去,但切场景时没有 removePersistRootNode,或者重复添加了同一个节点。

修法:加的时候判重,切换时清理:

import { director, Node } from 'cc';

export function ensurePersist(node: Node): void {
  if (director.isPersistRootNode(node)) return;
  if (node.parent !== director.getScene()) return;
  director.addPersistRootNode(node);
}

调用方在 onLoad 里 ensurePersist(this.node),判重逻辑保证重复进场景不会叠加。事件监听也要成对:on 了就在 onDestroy 里 off,否则即使节点不常驻,回调也会挂在全局事件上。

18.2.5 资源不释放与内存上涨

症状:连续玩十关后,内存从 80 MB 涨到 300 MB,低端机开始卡顿甚至被系统杀。

根因:resources.load 加载的资源默认会被引用计数,但切场景时不会自动全放;加上纹理缓存 macro.CLEANUP_IMAGE_CACHE 默认不主动清,图片解码后的内存一直占着。

修法:

  • 关卡切换时显式释放上一关的资源:assetManager.releaseAsset(asset) 或对 bundle 调 releaseAll()。
  • 大图用完调用 texture.destroy(),并配合 macro.CLEANUP_IMAGE_CACHE = true 清掉 CPU 侧的解码数据。
  • 用 13.2 节讲的 Profiler 内存面板,看的是趋势不是绝对值——稳定在某个值说明正常,一路向上才是泄漏。

一个可复现的验证方法:连续进出同一关 20 次,如果内存每次都涨一点、20 次后回不到起点,就是泄漏;如果涨到某个值后稳定,说明是正常的缓存增长。别用「玩一局内存涨了」当泄漏证据,那可能只是纹理第一次加载。

18.2.6 分包名与目录名不一致

症状:assetManager.loadBundle('bundle-levels') 报错,但目录 assets/bundle-levels/ 确实存在。

根因:Bundle 的名字不等于目录名。Bundle 名由该目录下的 .meta 里的 bundleName 决定,默认取目录名,但可以被改。

修法:统一约定——目录名、bundleName、代码里 loadBundle 的字符串三者完全一致,且都用 §C 定的 bundle-levels。构建后在构建产物的 assets/ 下确认存在 bundle-levels 目录,而不是别的名字。

建议把分包名抽成一个常量模块,代码里只引用常量:

// core/bundles.ts —— 全工程唯一的 bundle 名来源
export const BUNDLE_LEVELS = 'bundle-levels';

这样改名时只改一处,不会出现「一个文件写对、另一个文件写错」的情况。字符串散落各处是这类坑的温床。

18.2.7 三端存档差异

症状:H5 上存档正常,微信小游戏上 sys.localStorage.setItem 报错或静默失败。

根因:微信小游戏没有浏览器的 localStorage 对象,但 Cocos 的 sys.localStorage 做了平台适配——前提是你用它,而不是直接访问 window.localStorage。

修法:

import { sys } from 'cc';

const SAVE_KEY = 'blockdash:save:v1';

export function writeSave(data: unknown): void {
  sys.localStorage.setItem(SAVE_KEY, JSON.stringify(data));
}

export function readSave<T>(): T | null {
  const raw = sys.localStorage.getItem(SAVE_KEY);
  if (!raw) return null;
  try {
    return JSON.parse(raw) as T;
  } catch {
    return null;
  }
}

要点:只用 sys.localStorage,键名带版本号后缀(v1),读取时 try/catch 兜住脏数据。存档键名与 §C 的约定一致,三端共用一套。

18.2.8 构建缓存把旧代码带进包

症状:明明改了代码,构建出来的包行为还是旧的。

根因:构建缓存(library/、temp/)没清,或者增量构建没有识别到改动。

修法:发布构建一律清缓存后全量构建,即删掉 library/ 与 temp/ 再构建。开发期为了速度可以增量,但发布期不要图省事。这条要写进 18.1 的发布流程——它是「我改了但没生效」这类玄学问题的最大来源。

有一个快速判别法:如果「改了代码不生效」但「重启编辑器后生效」,那几乎可以确定是缓存问题,而不是代码问题。遇到这类现象,先清缓存再排查,能省掉一半时间。

顺带一提,.meta 的 uuid 也存在 library/ 的映射里,所以清理缓存后第一次打开工程会慢,这是正常的重建过程,不是卡死。

18.2.9 这些坑的共同根因

八个坑看起来零散,但归到根上只有三类:

类别坑本质
约定没定死平台判断、路径大小写、分包名、存档键同一件事有多种写法,团队没选一种
身份被打破.meta 丢失、构建缓存资源靠 uuid 识别,缓存靠时间戳识别
生命周期没配对常驻节点、事件监听、资源释放加了没减,开了没关

第一类最好治:把它写进代码规范。判平台只准用 sys.Platform.X,加载路径一律小写下划线不带扩展名,分包名只有一个常量,存档键只有一个模块导出。规范定死,坑就没地方长。

第二类靠流程:.meta 提交、发布前清缓存。它不需要写代码,只需要把动作放进 18.1 的清单。

第三类靠成对原则:add 必有 remove,on 必有 off,load 必有 release。写的时候就想好「谁在什么时候撤销」。

18.2.10 防坑约定

把上面三类根因翻译成五条团队约定,写进工程 README:

  • 判平台只允许 sys.Platform.X 与 sys.isNative,禁止字符串字面量
  • 动态加载路径一律小写下划线、不带扩展名;加载 SpriteFrame 带 /spriteFrame
  • assets/ 下所有 .meta 必须提交,只有 library/ temp/ build/ 进 .gitignore
  • 常驻节点、事件监听、资源加载都必须有配对的清理调用
  • 发布构建一律清 library/ 与 temp/ 后全量构建

这五条没有一条涉及复杂技术,但每一条都对应本卷真实踩过的坑。约定写下来只要十分钟,重新踩一遍要半天。

18.2.11 一张总表

把八个坑按「症状 → 根因 → 出处」收敛,贴在团队 wiki 上:

症状根因章节
微信端走了 H5 分支平台判断用字符串18.2.1
Missing Script / 引用丢失.meta 没提交18.2.2
动态加载返回 null路径带扩展名 / 大小写不符18.2.3
回调重复触发且越来越多常驻节点重复添加18.2.4
内存一路上涨资源不释放18.2.5
loadBundle 报错分包名与目录名不一致18.2.6
微信端存档失败直接用了 window.localStorage18.2.7
改了代码不生效构建缓存未清18.2.8

这张表的用法不是「读一遍」,而是出问题时先来查一遍。八条里有六条的症状是「东西不见了 / 不生效 / 重复了」,都属于不看表想半天、看表一分钟的类型。

再补一条使用建议:把这张表和 18.1 的发布清单一起放进工程仓库,而不是放在个人笔记里。坑是团队共有的,清单也应该是团队共有的。新人入职第一天就该看到它们,而不是等踩了坑再补课。

18.2.12 工程收口

到这里,卷二的复盘就完成了。回头看,这八个坑有一个共同点:它们几乎都不是「不会用引擎」,而是「约定没定死」——平台怎么判、资源怎么命名、缓存什么时候清、存档键怎么起。

换句话说,卷二真正想教的不是「这八个坑怎么躲」,而是一种工程习惯:

  • 遇到「同一件事有多种写法」时,先停下来定一种,写进规范。
  • 遇到「加了没减」的地方,立刻补上配对的清理。
  • 遇到「改了不生效」的现象,先怀疑缓存与流程,再怀疑代码。

这三种习惯,比记住任何一个 API 都值钱。

下一节是最后一节。我们把整卷做个收尾,并交代高级卷要回答的问题:这些约定为什么是这样、引擎内部到底怎么运作。下一节我们进入高级卷。

阅读导航:上一节:18.1 发布检查清单 · 下一节:18.3 进入高级卷 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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