性能定位与优化

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 帧。达不到就继续优化,直到达标为止。定一个跟设备挂钩的标准,比定一个跟数字挂钩的标准靠谱。

4.6 相关阅读