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.localStorage | 18.2.7 |
| 改了代码不生效 | 构建缓存未清 | 18.2.8 |
这张表的用法不是「读一遍」,而是出问题时先来查一遍。八条里有六条的症状是「东西不见了 / 不生效 / 重复了」,都属于不看表想半天、看表一分钟的类型。
再补一条使用建议:把这张表和 18.1 的发布清单一起放进工程仓库,而不是放在个人笔记里。坑是团队共有的,清单也应该是团队共有的。新人入职第一天就该看到它们,而不是等踩了坑再补课。
18.2.12 工程收口
到这里,卷二的复盘就完成了。回头看,这八个坑有一个共同点:它们几乎都不是「不会用引擎」,而是「约定没定死」——平台怎么判、资源怎么命名、缓存什么时候清、存档键怎么起。
换句话说,卷二真正想教的不是「这八个坑怎么躲」,而是一种工程习惯:
- 遇到「同一件事有多种写法」时,先停下来定一种,写进规范。
- 遇到「加了没减」的地方,立刻补上配对的清理。
- 遇到「改了不生效」的现象,先怀疑缓存与流程,再怀疑代码。
这三种习惯,比记住任何一个 API 都值钱。
下一节是最后一节。我们把整卷做个收尾,并交代高级卷要回答的问题:这些约定为什么是这样、引擎内部到底怎么运作。下一节我们进入高级卷。
阅读导航:上一节:18.1 发布检查清单 · 下一节:18.3 进入高级卷 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。