性能定位与优化
4.1 先量后改:把面板打开
性能优化最没用的姿势是凭感觉改。我有过一次「明明觉得是脚本慢,结果是 drawcall 炸了」的教训,从此再不信直觉。
3.8 里能直接用的测量工具有三档。
第一档,浏览器预览的统计条。 编辑器顶部「预览」按钮旁边的下拉里选「浏览器」,预览页顶部有一排控制按钮,其中 Show FPS 控制是否在左下角显示每秒帧数和 DrawCall 数量,Debug Mode 控制哪些级别的日志输出到控制台,Rotate 切横竖屏,FPS 限制最高帧率,Pause 暂停。这个统计条是 2D 游戏最快的诊断手段,drawcall 一眼就能看出来。
第二档,浏览器开发者工具。 编辑器菜单 开发者 -> 开发人员工具。用它看 Performance 面板的火焰图、Memory 面板的堆快照,能定位到具体函数。
第三档,引擎自带的 Profiler。 这里有个必须说清的限制:官方性能剖析器目前仅限原生平台。要在原生上看到那套完整的统计(核心数据、对象个数统计、内存使用统计、性能数据统计),得先在 项目 -> 项目设置 -> 功能裁剪 里勾上「调试文字渲染器」,再把 native/cocos/base/Config.h 里的 CC_USE_PROFILER 宏改成 1,重新编译。改回 0 就完全关闭,不会有任何副作用。
如果只是想在运行时拿几个数值,引擎提供了 profiler 模块,profiler.stats 里可以读到 fps、draws、frame 这些计数。
import { profiler } from 'cc';
profiler.showStats(); // 左下角显示统计面板
// profiler.stats.fps / .draws / .frame
实测要以真机为准。 编辑器预览和模拟器预览都跑在你自己的电脑上,iPhone 11 Pro Max 和一台千元安卓机的差距不是数量级而是量级。模拟器适合抓报错,真机才适合抓帧率。
4.2 瓶颈归类:先判断是哪一类
拿到「卡」这个现象,第一步是分类。四类原因,症状不同,治法也完全不同。
| 类型 | 典型症状 | 主要证据 | 主要手段 |
|---|---|---|---|
| 渲染 | drawcall 高、GPU 忙、掉帧集中在画面复杂的场景 | 统计条上的 drawcall 数 | 图集合并、静态合批、材质复用 |
| 脚本 | CPU 忙、逻辑帧时间占比高 | Performance 火焰图里你的函数排在前面 | 消除 update 里的分配、事件驱动替代轮询 |
| GC | 帧时间周期性尖刺、卡顿呈规律性抖动 | 火焰图里 GC 段落明显 | 减少临时对象、复用节点 |
| 物理 | 刚体多的场景卡、碰撞回调密集 | 物理相关代码在火焰图里占比高 | 刚体休眠、简化碰撞矩阵 |
有个判断技巧很好用:把场景里所有脚本逻辑临时注释掉再测一次。如果帧率立刻回来了,问题在脚本;如果还是卡,问题在渲染或者物理。这个二分法比读火焰图快得多。
针对 2D 项目,我自己的优先级是:渲染 > 物理 > 脚本 > GC。2D 游戏通常 drawcall 占主导,因为 UI 元素多、材质实例杂。脚本层的问题往往被误诊成渲染问题,表现是「一卡一卡的」,其实是 update 里 new 了个数组。
4.3 渲染:drawcall 从哪来,为什么高
drawcall 是「CPU 通知 GPU 画一次」的次数。每次都是一次跨总线通信,次数一多,CPU 侧的调度开销就压不住。
来源一:贴图分散。 同一个界面上十张图来自十张不同的贴图,就是十个材质、十次绘制。解决办法是合图。3.8 里两条路:自动图集(把散图按配置打包成大图)和手动图集(自己在编辑器里建 SpriteAtlas)。Skyline Courier 的 HUD 图标我全塞进了一张 2048×2048 的图集里,合批之后首页 drawcall 从 89 降到 31。
注意一个陷阱:开启构建选项里的 CLEANUP_IMAGE_CACHE 会导致动态合图失效。图集合不了,drawcall 就下不来。我为省内存开过这个选项,结果两个目标互相打架,最后内存那边让了步。
来源二:图集没合。 同一批素材有的在图集里、有的散着,合批就断了。引擎的合批要求相邻节点材质相同、状态一致,中间插一个不同材质的节点会把批次切开。
来源三:材质实例过多。 每个 Sprite 单独赋一个材质实例,等于每个节点一份状态。解决办法是让节点共享同一个材质实例,或者干脆用 SpriteFrame 的默认材质。
来源四:静态合批没用上。 3.x 的 UI 节点可以设为静态合批,把不变的节点预先合并成一个批次。这个对背景层特别有效,不变的楼体、装饰、静态文字,摆上去就合。
4.4 脚本层:update 里别做分配
这一层的规矩其实很朴素:update 是每帧都跑的,任何一次分配都会变成一次 GC 压力。
我列几条自己踩过的。
不要在 update 里 new 对象。 new Vec3()、{}、[] 都算。缓存一个成员变量,反复改它的值。引擎的很多 API 支持传入 out 参数,就是为了让你复用对象。
不要在 update 里拼字符串。 str += 'a' 在循环里尤其致命,字符串是不可变的,每次拼接都产生新对象还带垃圾。用数组 join,或者干脆用多个 Label。
不要在 update 里做 find。 this.node.getChildByName('xxx') 每帧跑一次,层级深了就是每帧几十次遍历。getComponent 同理。缓存引用。
不要用轮询代替事件。 每帧检查 if (this.targetHp <= 0) 和在 hp 变化时触发一次事件,代价差着数量级。能把轮询改成事件的都改。
不要在 update 里做物理查询。 PhysicsSystem2D.instance.testAABB 之类很贵,能用触发器就用触发器。
UI 层也有个类似的问题:隐藏的 ScrollView 一样在花成本。setActive(false) 只是不让它显示,不影响它内部的布局计算和内容生成。我在做任务列表时发现,把三个 Tab 的 ScrollView 都设为不可见之后,切换 Tab 还是会顿一下,三个都还在算。后来改成用 removeFromParent() 加上按需重建,顿挫没了。
列表项要复用。虚拟列表是标准做法:可视区加上下各一屏缓冲,总共十几个 item 节点来回用,而不是渲染几百个。
物理层的优化有三条。刚体休眠:离玩家远的建筑物、装饰物设成不需要物理响应或者直接 sleep。减少刚体:纯视觉的碰撞盒换成普通节点。碰撞矩阵:把不需要互相碰撞的类别关掉,2D 项目里这一项的收益常常被低估。
4.5 前后对比一定要记下来
这是整章我最想强调的一点:优化没有数据记录,就等于没优化。 因为三个月后你会怀疑「当时到底有没有变快」,然后忍不住又去改一遍。
我用的记录格式很简单,一个 markdown 表格,每次改动追加一行:
| 日期 | 场景 | 改动 | FPS(前→后) | drawcall(前→后) | 峰值内存 | 设备 |
|---|---|---|---|---|---|---|
| 03-12 | 首页 HUD | 12 张图标合并为 1 张图集 | 58→60 | 89→31 | — | iPhone 12 |
| 03-15 | 城市关卡 | 楼体改静态合批 | 41→49 | 210→96 | 180MB→171MB | 小米 10 |
| 03-18 | 任务列表 | 3 个 ScrollView 改按需重建 | 52→60 | 45→44 | — | 小米 10 |
| 03-20 | 城市关卡 | 远景刚体改 sleep | 49→55 | 96→96 | 171MB→152MB | 小米 10 |
表格里刻意包含了「没变化」的项。3 月 20 日那行 drawcall 前后都是 96,看着像白干,但峰值内存降了 19MB,这就是收益。记录里必须允许这种「A 没变、B 变了」的情况,否则你只记得住成功的改动,忘了哪些方向是死胡同。
测量的口径也要固定:同一个场景、同一段操作路径、同一个设备、同一档画质。我的做法是每次改动后在真机上重复三遍操作(进关 → 送三个包裹 → 出关),取第三遍的稳定值。头两遍因为刚进场景有加载波动,数字不可信。
关于目标值,我不敢给一个「drawcall 必须低于 N」的硬标准,因为不同项目、不同设备差别太大。我自己的标准是:在最低配的目标机型上,最复杂的那一关全程不低于 45 帧。达不到就继续优化,直到达标为止。定一个跟设备挂钩的标准,比定一个跟数字挂钩的标准靠谱。
