上周有个做独立游戏的朋友跑来找我,说他那款游戏"跑起来一顿一顿的,是不是得优化一下"。我问他:你说的优化,是指帧率不够,还是指加载太慢,还是指玩家前十分钟就跑了?他愣了几秒,说"都有点吧"。这个反应其实特别典型——游戏中的优化这四个字,在同一个团队里往往同时装着五件完全不同的事,而大家开会的时候还都以为自己说的是同一件。
这篇东西想干的事很简单:把这个被用滥的词拆开,一层一层讲清楚每一层到底在优化什么、用什么指标衡量、具体怎么动手、以及最容易在哪里翻车。不管你是刚入行的新人,还是做了几年但一直只负责其中一小块的从业者,看完应该能对"我这游戏到底该优化哪儿"有个清楚的判断顺序。文中涉及的引擎和工具都用通用说法,具体参数是基于常见实践给的参考值,你在自己项目里要按实际设备重新标定。
1. 同一个词,五种活:先把"优化"这个词拆开
1.1 玩家说"优化差"的时候,他在说什么
玩家几乎不会说"你的帧生成时间抖动过大",他说的是"卡"、"烫"、"进游戏要等半天"、"放技能的时候会顿一下"、"手机玩十分钟就发烫掉帧"。这些抱怨背后至少对应五类完全不同的技术问题:平均帧率不足、帧时间不稳定(尖刺)、加载与首包时间过长、功耗与发热导致的持续降频、以及输入到画面的响应延迟。
关键在于,玩家感知的不是平均值,而是最差的那几帧。一台机器稳定跑 55 帧,玩家觉得挺顺;另一台机器平均 60 帧但每四秒来一次 120 毫秒的停顿,玩家会直接给差评。所以做性能优化时把"平均帧率"当成唯一 KPI,基本等于用体感温度去评判空调制冷能力。真正要盯的是帧时间的分布:99 分位、99.9 分位,以及尖刺出现的周期性。
还有一个容易被忽略的点:玩家说的"优化"里,有相当一部分根本不在渲染管线上。比如背包打开要转两秒圈、商店页面每次切页都重新请求、任务完成动画不能跳过——这些在玩家嘴里也是"优化差",但它属于体验层的等待管理,跟 GPU 一个字节的关系都没有。
1.2 策划说"优化体验"的时候,他在说什么
策划嘴里的优化,通常指节奏和路径。新手引导能不能少点两次、战斗前摇能不能缩短 50 毫秒、日常任务能不能一键领取、背包能不能自动整理、重复劳动能不能批量处理。这类优化的衡量指标是留存、完成率、平均在线时长,而不是毫秒。
我见过一个很典型的案例:某模拟项目在测试阶段收到大量"操作太累"的反馈,团队第一反应是去做性能优化,查了两周发现帧率一直在 60 稳定运行。真正的问题是一次日常流程要点 37 次屏幕,玩家手指累了。后来加了批量操作和自动寻路,同样的内容,完成率涨了一大截,代码一行渲染都没改。
所以当你听到"体验需要优化"这句话,先问一句:是等待太久,还是操作太多,还是反馈太弱?这三个方向的解法完全不同,搞错了方向就是白干。
1.3 程序说"优化"的时候,他说的其实是四本账
技术侧的优化可以拆成四本独立的账,每本账的度量工具、瓶颈位置、优化手段都不一样:
- CPU 账:每帧逻辑耗时。包括游戏逻辑、物理、动画、AI、UI 刷新、序列化。看的是各线程耗时和调用树。
- GPU 账:每帧渲染耗时。包括绘制调用、着色器复杂度、带宽、过度绘制、后处理。看的是 GPU 抓帧和各类计数器。
- 内存账:峰值占用、常驻占用、碎片、泄漏。看的是内存快照和分配追踪。
- IO 账:包体大小、加载耗时、流式加载的卡顿。看的是资源依赖图和磁盘读取曲线。
这四本账会互相借债。你为了省 CPU 把逻辑改成多线程,可能引入了同步开销;你为了省 GPU 把分辨率降下来,画面糊了玩家照样骂;你为了省内存把纹理压得很狠,远处一片马赛克。优化的本质是在这四本账之间做取舍,而不是把某一本账做到极致。
1.4 一张对照表:谁在说优化,实际在说什么
| 说话的人 | 他嘴里的"优化" | 真实含义 | 验收指标 |
|---|---|---|---|
| 玩家 | 优化太差了 | 卡顿/发热/加载慢/操作延迟 | 主观流畅度、差评率 |
| 策划 | 优化下体验 | 减少等待、减少点击、加强反馈 | 完成率、留存、时长 |
| 客户端程序 | 优化下性能 | 降 CPU/GPU 单帧耗时 | 帧时间分位数、峰值内存 |
| 服务器程序 | 优化下同步 | 降带宽、降延迟、降同步频率 | 每玩家带宽、RTT 分布 |
| 主美 | 优化下资源 | 降面数、降贴图、合图集 | 包体、显存、Draw Call |
| 制作人 | 优化下进度 | 砍需求、保上线 | 里程碑达成率 |
把这张表贴在项目群里,能省掉至少一半的无效会议。因为大部分"优化"争吵,本质是两个人拿着不同的账本在对账。
2. 帧时间才是硬通货:性能问题的度量与定位
2.1 平均帧率会骗人,帧时间曲线不会
假设一段 10 秒的录像,9.9 秒都是 16 毫秒一帧,最后 0.1 秒里有一帧花了 300 毫秒。平均下来帧率依然接近 60,但你玩的时候会明显感到"顿了一下"。这就是为什么现代性能分析都在看帧时间曲线而不是帧率数字。
业内常用的两个指标是 1% Low 和 0.1% Low,意思是把这段时间内所有帧按耗时排序,取最慢的 1% 和 0.1% 那部分帧,算它们的平均帧率。1% Low 反映的是"最难受的那一小段体验",0.1% Low 反映的是"偶发但致命的卡顿"。一个游戏平均 90 帧、1% Low 只有 25 帧,体感上会比平均 60 帧、1% Low 55 帧的游戏差得多。
生活化的类比:一段高速路,平均时速 100 公里,但中间堵了三次各五分钟。你到目的地的时间没差多少,但你记住的是那三次堵车。帧时间的尖刺就是堵车。
2.2 单帧预算是怎么算出来的
先把目标定死,再谈优化。目标帧率决定了你的总预算:
| 目标帧率 | 单帧总预算 | 逻辑线程参考 | 渲染线程参考 | GPU 参考 |
|---|---|---|---|---|
| 30 FPS | 33.3 ms | 12 ms | 10 ms | 30 ms |
| 60 FPS | 16.7 ms | 6 ms | 5 ms | 15 ms |
| 90 FPS | 11.1 ms | 4 ms | 3.5 ms | 10 ms |
| 120 FPS | 8.3 ms | 3 ms | 2.5 ms | 7 ms |
这张表里有个容易搞错的地方:GPU 的预算看起来比总预算还大,原因是 CPU 和 GPU 是并行流水的。这一帧 CPU 在准备数据,GPU 在画上一帧的内容。只要两边都不超过总预算,整体就能跑满帧。但一旦某一侧超了,就会出现等待,帧时间立刻抬升。所以你会发现有些项目 CPU 只用了 5 毫秒、GPU 用了 25 毫秒,跑 60 帧照样卡——瓶颈在 GPU。
再往下拆,就是各系统的预算分配。一个比较常见的 60 帧移动端分配是:渲染 6 ms、物理 2 ms、动画 2 ms、AI 1.5 ms、UI 2 ms、逻辑与脚本 2 ms、预留 1 ms。这个表不是拿来供着的,是拿来吵架用的——谁超了谁解释。
2.3 先测量再动手:三层定位工具怎么配合
我见过太多人一上来就"我觉得是 Draw Call 太多",然后花三天合批,结果一点没变。原因很简单:他根本不知道瓶颈在哪。
正确的顺序是三层:
- 引擎自带 Profiler 做粗定位。看这一帧的耗时大头落在哪个线程、哪个阶段。是逻辑线程爆了还是渲染线程爆了?是主线程等待 GPU 还是 GPU 等待主线程?
- 平台级 CPU 采样工具做细定位。引擎 Profiler 的采样精度有限,遇到函数级的热点要靠系统级采样器,看调用栈,找那个占 30% 的函数。
- GPU 抓帧工具做渲染分析。抓一帧完整的渲染过程,看每个 Pass 的耗时、Draw Call 数量、各类计数器(带宽、过度绘制、纹理采样)。
这里有个经验:先判断是 CPU 瓶颈还是 GPU 瓶颈,这一步能省掉一半时间。方法很土但很有效——把分辨率降到 320×240 再跑一遍。如果帧率明显上去了,说明是 GPU 或者带宽瓶颈;如果帧率纹丝不动,说明卡在 CPU 侧。反过来,把渲染全部关掉(只留一个纯色背景)再跑,如果还是卡,那基本可以确定逻辑侧有问题。
2.4 一次卡顿排查的完整链路
说个模拟场景,某横版动作项目,测试反馈"每隔三四秒会顿一下,大概零点一秒"。这种周期性尖刺最有意思,因为周期本身就带着线索。
第一步,录帧时间曲线。发现尖刺间隔稳定在 3.2 秒左右,持续时间 80 到 120 毫秒。周期性说明大概率是某个定时任务,而不是随机的资源加载。
第二步,在 Profiler 里对齐时间轴。尖刺那一帧的主线程耗时从 4 毫秒涨到 90 毫秒,渲染线程正常,GPU 正常。锁定在逻辑线程。
第三步,看调用树。尖刺帧里耗时最大的三块是:UI 文本重建、字符串拼接、以及一次规模不小的托管内存分配触发的垃圾回收。三者加起来正好 80 多毫秒。
第四步,往上追溯为什么是 3.2 秒一次。查代码发现有个战斗结算面板,即使在隐藏状态下也在每帧刷新倒计时文本,而且用了字符串拼接生成"剩余 XX 秒"。3.2 秒正好是它内部一个刷新节流的周期。隐藏状态下没人看,但它依然在生成字符串、触发 UI 重建、制造垃圾。
第五步,改。三个动作:面板隐藏时注销刷新、倒计时文本改成缓存好的字符数组只改数字位、把每次刷新产生的临时对象改成复用。改完再测,尖刺消失,1% Low 从 38 帧回到 57 帧。
这个链路里最有价值的不是最后那三行改动,而是第四步——找到"为什么是 3.2 秒"这个周期。没有这一步,你只知道这里慢,不知道该不该改、改完还会不会复发。
排查铁律:一次只改一个变量,改完立刻复测同一段场景。同时改五处,你永远不知道是哪一处起的作用,下次复发你也没法复用经验。
3. 渲染侧的开销地图:Draw Call、带宽和过度绘制
3.1 Draw Call 的成本不在"提交"这两个字上
很多人以为一个 Draw Call 就是 CPU 给 GPU 发一条命令,所以数量多一点无所谓。实际上单次绘制的开销包括:状态切换(材质、着色器、常量缓冲、纹理绑定)、驱动层的参数校验、命令缓冲区写入、以及可能触发的渲染目标切换。在移动端,状态切换的代价尤其高,因为每一类状态变更都可能让 GPU 的流水线重新配置。
所以合批的价值主要不在"少发几条命令",而在减少状态切换。同一材质的物体合并成一次绘制,中间不需要换材质、不需要重新绑纹理,这才是省下来的大头。
常见的合批手段和它们的代价:
| 手段 | 省什么 | 代价 | 适用场景 |
|---|---|---|---|
| 静态合批 | 减少 Draw Call | 内存翻倍、包体变大 | 场景静态摆件 |
| 动态合批 | 减少 Draw Call | 每帧 CPU 顶点变换开销 | 小物件、粒子 |
| GPU Instancing | 减少 Draw Call | 需要同网格同材质 | 大量重复物体 |
| 图集合并 | 减少纹理切换 | 图集尺寸上限、管理复杂 | UI、小道具 |
| 材质合并 | 减少状态切换 | 需要统一参数化设计 | 场景通用材质 |
数量级参考:移动端一个画面控制在几百到一千多个 Draw Call 比较稳,PC 端几千也能接受。但这不是硬标准,一个 Draw Call 极其复杂的项目(比如每个物体都是高面数加复杂着色器)可能三百个就顶不住了。所以永远以自己的抓帧数据为准。
3.2 带宽:最容易被忽略的大头
先算一笔账。1920×1080 的渲染目标,每个像素 4 字节(RGBA8),一次全屏写入就是 1920 × 1080 × 4 ≈ 8.3 MB。如果这一帧有 10 个全屏 Pass(后处理、阴影、反射、颜色校正……),那就是 83 MB 的写入,再加上读取,轻松超过 150 MB。按 60 帧算,就是每秒 9 GB 的带宽需求。
而移动端整机的内存带宽理论峰值也就十几到几十 GB/s,还要分给 CPU、系统、其他进程。所以在移动端,带宽往往比算力更早成为瓶颈。表现出来的现象是:着色器逻辑明明很简单,但就是慢;分辨率降一半帧率立刻翻倍。
省带宽的常见手段:减少渲染目标切换、合并后处理 Pass、能不用全屏 Pass 就不用、用更紧凑的格式(比如法线用两个通道重建第三个)、避免不必要的 resolve 和拷贝、能在低分辨率算的(比如 bloom 的模糊)就在低分辨率算。
这里有个反直觉的点:降分辨率比优化着色器更有效,在带宽瓶颈的场景下尤其明显。很多团队花两周优化着色器指令数,收益 3%;把内部渲染分辨率从 1.0 调到 0.85,收益 20%,画面差异玩家几乎看不出来。
3.3 过度绘制与半透明排序
过度绘制指的是同一个像素被画了多次。最典型的三个来源:全屏粒子特效、UI 叠加层、以及各种后处理。
一个全屏的烟雾粒子,如果叠了 15 层,那就等于这个屏幕被画了 15 遍,每遍都要读纹理、混合、写回。移动端的 tile-based 架构虽然有片上缓存能缓解一部分,但层数一多照样顶不住。
处理思路有几条:粒子数量按屏幕占比动态限制、半透明层数设上限、UI 做合并绘制、不透明的先画(利用 early-z 提前剔除被遮挡的像素)、半透明严格从后往前排序(减少无效混合)。
排序这件事在小项目里经常被忽略,因为引擎默认会帮你排。但一旦你用了自定义材质或者手动控制渲染队列,排序错了就会出现"某些角度下特效闪烁"或者"透明物体互相遮挡关系混乱",而且性能也会掉——因为错误的顺序会让更多像素被重复混合。
3.4 剔除与 LOD:不画才是最便宜的优化
渲染优化里性价比最高的一招,永远是"别画"。视锥剔除、遮挡剔除、距离剔除、小物件剔除,这些开关在很多项目里根本没打开,或者打开了但配置不对。
我遇到过一个项目,场景里放了上千个装饰性小物件,每一帧全部提交。开了距离剔除之后,Draw Call 从 1400 降到 400,帧率直接涨了 25 帧。改动量:三个配置项。
LOD 也类似,但要注意两个坑。第一个是切换抖动:物体在 LOD 边界附近来回移动时会反复切换模型,视觉上会"跳"。解决办法是加滞回区间,比如切到低模的距离是 30 米,切回高模的距离是 25 米。第二个是LOD 生成质量:自动减面生成的低模经常在轮廓上崩掉,尤其是带硬边的机械模型。重要的物体建议手动做 LOD,或者至少在自动生成后逐个检查。
4. CPU 与内存:那些看不见的每帧成本
4.1 每帧都在跑的东西,才是成本大头
CPU 侧的优化有个基本规律:一次性开销可以忍,每帧开销不能忍。
一个函数单次执行 5 毫秒,如果只在加载时跑一次,玩家感知不到;如果每帧都跑,60 帧下就是 30% 的预算没了。所以排查 CPU 耗时,第一件事是搞清楚"这段代码一帧跑多少次"。
常见的隐形大户:
- 上千个对象的空更新。每个对象都有一个每帧调用的更新函数,里面什么都不做,但虚调用、遍历、缓存失效的成本是实打实的。
- UI 的无效刷新。血条每帧重设一次数值、文本每帧重建一次、布局每帧重算一次。
- 事件系统的广播。一个事件被几百个监听者订阅,每次广播都要遍历、装箱、调用。
- 物理查询。每帧对上千个物体做射线检测或者范围查询。
- 寻路。几十个单位同时重新计算路径。
处理这些的手段叫分帧:把不需要每帧做的事分摊到多帧去做。比如 500 个单位的 AI 决策,分成 10 帧,每帧处理 50 个;UI 刷新按 10Hz 而不是 60Hz;寻路请求进队列,每帧只处理固定数量。玩家的感知是有阈值的,10Hz 刷新的数值和 60Hz 刷新,肉眼几乎分辨不出来,但 CPU 开销差了六倍。
4.2 托管堆与垃圾回收:卡顿的常客
在有自动内存管理的环境里,最讨厌的卡顿来源就是垃圾回收。它不一定频繁,但一旦触发就是几毫秒到几十毫秒的停顿,而且触发时机不可控。
问题在于,很多看起来无害的写法每帧都在制造垃圾:
- 字符串拼接。
"剩余 " + n + " 秒"每次都会产生新的字符串对象。 - 装箱。把值类型塞进 object 参数、放进非泛型集合。
- 闭包。lambda 捕获了外部变量,每次执行都可能分配。
- 迭代器。某些集合遍历方式会产生枚举器对象。
- 临时数组和列表。每次 new 一个 List 然后丢掉。
// 每帧都在制造垃圾的写法 void Update() { label.text = "HP: " + currentHp + "/" + maxHp; // 每次拼接产生新字符串 var list = new List<Enemy>(); // 每帧一个新列表 foreach (var e in enemies) { if (e.alive) list.Add(e); } } // 改成复用 + 缓存 private readonly StringBuilder _sb = new StringBuilder(32); private readonly List<Enemy> _buffer = new List<Enemy>(256); void Update() { _sb.Clear(); _sb.Append("HP: ").Append(currentHp).Append('/').Append(maxHp); label.SetText(_sb); // 走不产生托管分配的接口 _buffer.Clear(); // 复用同一个列表,不产生新对象 for (int i = 0; i < enemies.Count; i++) if (enemies[i].alive) _buffer.Add(enemies[i]); }除了减少分配,另一个手段是对象池。子弹、特效、伤害数字、临时 UI 元素,这些高频创建销毁的东西全部走池子,用完归还而不是销毁。池子的关键细节是"归还时要彻底重置状态",我见过太多因为忘了重置而导致"复用的子弹带着上一次的速度飞出去"的诡异 bug。
还有一个实践建议:在开发期就把 GC 分配监控打开,让每次分配都能在 Profiler 里看到调用栈。等到上线前才发现每帧分配几十 KB,回头找就难了。
4.3 缓存友好:数据布局带来的数倍差距
这一条偏底层,但收益极大,值得单独说。
现代 CPU 从内存读数据不是按字节读,是按缓存行读,一行通常 64 字节。如果你的数据是"结构体数组"(每个对象把自己的所有字段放在一起),遍历时只用到其中一个字段,那你每次读 64 字节只用了 4 字节,96% 的带宽浪费在搬运无关数据上。
改成"数组的结构体"(把所有对象的同一个字段集中存成一个数组),遍历时就变成连续读取,缓存命中率接近 100%。同样的逻辑,性能差距经常是 3 到 8 倍。
// 结构体数组(AoS):每个元素 64 字节,只用到 position struct Unit { Vector3 pos; Quaternion rot; int hp; int team; ... } Unit[] units; // 数组的结构体(SoA):位置连续存放,遍历只碰位置 Vector3[] positions; Quaternion[] rotations; int[] hps;这在做大规模单位模拟、粒子系统、弹幕、流体时是决定性的。当然,不是所有地方都要这么改——业务逻辑代码用对象更清晰,只有那些"每帧遍历上万次"的热点路径才值得这么折腾。判断标准还是 Profiler,别凭感觉。
4.4 内存峰值与加载:两个不同的账
内存要分两个指标看:峰值和常驻。峰值决定会不会崩,常驻决定会不会被系统杀掉或者烫手。
峰值通常出现在场景切换、大招特效爆发、大量单位同屏这些时刻。压峰值的手段是错峰:特效分级加载、远处的资源延迟加载、切场景时先卸载再加载。
常驻内存主要被资源占着。纹理是大头,一张 2048×2048 的未压缩 RGBA 纹理就是 16 MB,十张就是 160 MB。所以纹理压缩不是可选项而是必选项,移动端用 ASTC 或 ETC2,桌面端用 BC 系列,压缩比通常在 4:1 到 8:1。再配合 mipmap,远处的小物体用低级别 mip,既能省带宽也能省缓存。
这里有个常见的踩坑:压缩格式选错会导致画质崩坏。带透明通道的 UI 图用不支持的压缩格式,边缘会出现明显的色块;法线贴图用普通颜色压缩,光照会变得很奇怪。选格式的时候一定要拿实际的图去对比,别只看压缩率。
5. 网络、加载与"手感":优化账本上的另外三栏
5.1 状态同步和帧同步,优化的着力点完全不同
状态同步是服务器算完结果,把状态发给客户端。优化的核心是"少发"和"发重要的":降低同步频率(比如从 30Hz 降到 20Hz)、只发变化的字段、按距离和重要性做优先级、对位置做量化和压缩。
帧同步是各客户端跑同样的逻辑,只同步输入。优化的核心是"确定性"和"输入延迟":保证浮点运算在不同设备上结果一致、处理断线重连时的追帧、以及把输入延迟压到玩家能接受的范围。
这两种方案的带宽特性差别很大。状态同步的带宽随实体数量线性增长,同屏一百个单位时带宽压力很大;帧同步的带宽基本恒定(只传输入),但对逻辑一致性的要求极高,一个浮点误差就可能导致整局不同步。
5.2 带宽预算:该省哪一刀
先给个量级参考:一个实时对战类项目,每玩家每秒的下行带宽控制在几十 KB 是比较健康的;超过一百多 KB 就要开始担心弱网用户了。
省带宽的常见手段,按收益排序:
- 量化。位置用 16 位定点数代替 32 位浮点,角度用 8 位或 16 位表示。一个三维位置从 12 字节降到 6 字节,直接省一半。
- 增量同步。只发和上一帧不同的字段,用位掩码标记哪些字段变了。
- 相关性过滤。视野外的、距离远的、不可见的实体降频或完全不发。
- 插值与预测。降低同步频率,客户端靠插值补足。20Hz 同步配插值,观感上和 30Hz 差别不大。
- 压缩。对批量数据做通用压缩,但注意压缩本身有 CPU 成本,别为了省带宽把 CPU 吃满。
这里有个必须讲清楚的取舍:插值缓冲越大,画面越平滑,但延迟越高。缓冲 100 毫秒意味着你看到的是 100 毫秒前的世界。对射击类游戏这是致命的,对策略类游戏无所谓。所以这个参数没有通用最优值,只能按品类调。
5.3 加载时间:玩家感知的等待
加载优化有两个层面:真实时间和感知时间。
真实时间靠技术:资源打包粒度、异步加载、流式加载、预加载关键资源、避免加载时的同步阻塞、把着色器预编译提前到加载阶段(这个特别重要,很多项目的首帧卡顿就是着色器现场编译导致的)。
感知时间靠设计:进度条要有真实的推进感(不是假的匀速走)、加载期间给点可看的内容(提示、原画、小互动)、分阶段加载(先让玩家进主界面,后台继续加载战斗资源)、避免"加载到 99% 卡住"这种事——这比直接加载 10 秒更让人抓狂。
有个技巧值得一试:把加载拆成"必要"和"可选"两段。必要资源加载完就放玩家进去,可选资源(比如远景模型、高品质特效)在后台继续流式加载。玩家的感知从"等了 15 秒"变成"等了 4 秒,进去之后远处慢慢变清晰"。
5.4 手感优化:从按键到画面的那条链路
手感这个词很玄,但拆开来看是一条明确的链路:输入采样 → 逻辑处理 → 动画过渡 → 渲染 → 显示。每个环节都会加几十毫秒。
- 输入采样频率不够,会导致按键被漏掉或者延迟一帧。高帧率下这个问题更明显,需要每帧都采样。
- 输入缓冲:玩家在动作后摇期间按下的指令,先缓存起来,后摇结束立刻执行。没这个机制,玩家会觉得"我明明按了但没反应"。
- 动画过渡:从待机到攻击,如果过渡时间设成 0.2 秒,玩家会觉得"按了半秒才动"。战斗动作的过渡通常在 0.05 到 0.1 秒,甚至直接用 0 帧过渡。
- 后摇取消:允许用移动或者下一个技能取消部分后摇,这是动作游戏"爽感"的核心来源之一。
- 打击反馈:命中瞬间的短暂停顿(几帧)、屏幕震动、特效、音效,这些不改变逻辑但极大影响体感。
我个人的经验是,手感问题的排查要看录像。把游戏画面和玩家的手部操作同时录下来,慢放对比,你能直接看出延迟在哪一段。光靠坐在那里"感觉一下",很容易归因错误。
6. 性能预算与停手时机:优化的边界在哪
6.1 先定预算表,再谈优化
没有预算表的优化就是无底洞。项目一开始就该定下这几件事:目标机型有哪些档位、每档的分辨率和帧率目标、内存上限、包体上限、首包大小、加载时间上限。
一张典型的预算表长这样:
| 项目 | 低端档 | 中端档 | 高端档 |
|---|---|---|---|
| 分辨率 | 720p | 1080p | 1080p |
| 目标帧率 | 30 | 60 | 60 |
| 单帧预算 | 33 ms | 16.7 ms | 16.7 ms |
| 峰值内存 | 800 MB | 1.2 GB | 1.8 GB |
| 首包大小 | 300 MB | 300 MB | 300 MB |
| 冷启动时间 | 8 s | 6 s | 5 s |
| Draw Call | 400 | 800 | 1200 |
有了这张表,讨论就从"感觉有点卡"变成"我们超了 4 毫秒,超在渲染上"。前者没法推进,后者可以直接派活。
6.2 过早优化与优化债务
业界有句老话,大意是" premature optimization is the root of all evil "。这话经常被误读成"不要优化",其实它说的是不要在还没搞清楚瓶颈在哪的时候,为了想象中的性能问题去牺牲代码清晰度。
我见过一个项目,为了"性能",把所有的数据都写成手写的内存池加裸指针,结果三个月里出了七个内存越界 bug,团队三个人轮流查了两周。最后测下来性能只比朴素实现快 8%,而那 8% 在一个 GPU 瓶颈的项目里根本体现不出来。
反过来,另一种极端是"先写完再说,反正能跑"。这种项目的结局通常是上线前一个月发现帧率只有 25,然后开始大规模重构,改一处崩三处。
比较务实的做法是:在架构层面留好余地,在实现层面保持朴素。比如资源加载从一开始就走异步接口、对象创建从一开始就走工厂(方便后面换成池子)、UI 从一开始就用数据驱动(方便后面做批量刷新)。这些都是低成本的"预留接口",不需要提前把代码写成天书。
6.3 什么时候该停手
优化有一条收益递减曲线:前 20% 的工作量能拿到 60% 的收益,后面 80% 的工作量去抠剩下的 40%,而且每一点收益都要拿可维护性去换。
我判断停手的标准大概有三条:
- 目标机型的中位数体验达标了。不是旗舰机跑满帧,而是占用户量最大的那档设备上,1% Low 不低于目标帧率的 70%。看设备分布数据,别看自己的开发机。
- 再优化会明显损害代码可读性或迭代效率。如果某个优化让后续每次改需求都要多花半天,那它得带来非常显著的收益才值得。
- 瓶颈已经转移到别的地方。帧率已经稳了,但加载还是 20 秒,那继续抠渲染就是在做无用功。
6.4 上线之后才是真正的考场
开发机的性能永远代表不了真实用户。上线之后你会遇到:五年前的老机型、后台开着一堆应用的手机、发热降频的设备、存储空间快满导致 IO 变慢的情况。
所以上线前必须准备几件事:画质分级选项(让玩家自己选流畅还是画质)、动态分辨率(帧率掉了自动降内部分辨率)、帧率上限设置(省电模式锁 30 帧)、以及性能数据上报(记录崩溃、卡顿、机型、帧率分布)。
数据上报这块要克制,别什么都传。重点是:卡顿发生的场景 ID、当时的帧时间、设备型号、内存占用。有了这些,你才能知道"是所有人的第三章都卡,还是只有某几款机型卡"。
我自己的习惯是每次大版本更新后,先看一周的卡顿上报数据,按机型分组排序。经常会出现某个特定型号的帧率明显低于同类配置,一查是驱动或者某个特性的兼容问题。这种问题在开发机上永远复现不出来,只能靠线上数据捞。
最后分享一个我觉得最有用的小习惯:给项目做一个"性能回归场景"。固定的一段跑图路线、固定的战斗场景、固定的 UI 操作序列,每次提交前跑一遍,记录帧时间和内存。不用很精确,能看出趋势就够了。很多性能退化不是某一次大改造成的,而是几十次小提交一点点累积起来的,等到发现的时候已经很难定位是哪一次了。有了这个基线,任何一次明显退化都会立刻暴露,查起来也快得多。