
1. 先找病灶还是先吃药性能问题排查的顺序决定了你的天花板接手一个Unity项目帧率常年徘徊在二十几帧玩家一多直接卡成PPT。这种场景我估计做Unity的多少都遇到过。有意思的是当我打开项目检查脚本发现到处都是现成的“优化技巧”——对象池有对象池有缓存有LOD有但帧率就是上不去。折腾了三天把Profiler打开一看好家伙最吃性能的根本不是那些被反复优化的热点代码而是一个不起眼的Update循环里每帧做的字符串拼接和GetComponent调用。这里我想先说一个我后来一直坚持的观点脚本优化这件事第一步不是优化而是测量。你的直觉在性能问题上几乎永远不可靠不要跟我说“我觉得这个函数应该很慢”先跑一遍Profiler把数据拿出来再下结论。很多团队在项目末期疯狂加班“优化”最后发现方向上就错了根本原因是所有人都在凭感觉调代码而不是跟着数据走。这篇东西我打算按一条实战链路来讲从CPU侧的脚本运行时开销到内存和GC到脚本如何间接拖垮渲染和物理再到怎么用Profiler把问题钉死在具体行号。每个环节我都会给出可以直接照做的方案也会分享一些我踩过的坑——毕竟性能优化这门手艺真正的经验都长在坑里。适合看这篇东西的应该是有一定Unity基础、开始做中大型项目或者遭遇性能瓶颈的开发者。如果你刚接触Unity前100行可以先有个概念后面遇到问题再回来翻。2. 脚本运行时开销从Update、协程与MonoBehaviour生命周期说起2.1 Update之外的选择协程与Invoke的适用边界很多人写脚本凡是需要持续检测的逻辑第一反应就是塞进Update里。这个习惯本身没有错但问题是很多逻辑根本不需要每帧检测。举个例子一个伤害数值飘字的效果你希望它1.5秒后消失。常规写法是在Update里累加计时超过1.5秒就销毁。这个写法每帧都会触发一次Update回调哪怕这个对象游戏里同时有50个那就是每帧50次无意义的函数调用。而用协程的话写法是这样的IEnumerator DestroyAfterDelay(float delay) { yield return new WaitForSeconds(delay); Destroy(gameObject); }协程的好处是它在等待的这段时间里不会消耗每帧的调用开销yield之后协程会被挂起直到计时到了才恢复执行。这个差别在对象数量少的时候看不出来但一旦场景里有大量类似的逻辑——比如几十个技能CD、上百个飘字、若干AI的巡逻切换——省下来的Update调用次数是很可观的。Invoke同理适合作为一次性延迟调用。但Invoke有个麻烦的地方在于它用字符串绑定方法名重构时不安全而且Invoke的调用频率控制也不如协程直观。我的建议是凡是需要延迟、等待、分帧处理的逻辑优先协程凡是每帧必须同步的状态更新才用Update。另外提一个很多人不知道的细节MonoBehaviour的OnEnable、OnDisable、Start这些生命周期回调同样是有开销的。一个对象反复SetActive(true/false)会反复触发这些回调。如果你的对象池回收逻辑里不需要每帧更新可以考虑用一个开关变量标记状态而不是频繁SetActive。2.2 空Update与隐藏的MonoBehaviour陷阱空Update是那种最不起眼但最容易累积的浪费。场景里挂了几百个脚本每个脚本里都有一个空的Update——可能是之前调试留下来的可能是继承了一个基类而基类里定义了Update但什么都没写。每个空Update调用一次开销本身只有几微秒但几百个乘起来再乘以每帧的调用次数帧时间就被白白吃掉了好几百微秒。Unity的C#运行时对Update回调的处理是遍历所有激活的MonoBehaviour脚本并调用其Update方法这个调用不管你的方法体是不是空的都会执行一次方法绑定和调用。所以排查的时候用IDE的全局搜索把所有Update方法列出来逐个确认里面的逻辑是不是必须每帧执行。还有一种更隐蔽的情况是你把脚本挂到了预制体上但这个预制体被实例化了上千次而脚本里根本没有什么需要每帧更新的逻辑。这种脚本就不应该做成MonoBehaviour挂在物体上改成静态工具类或者用事件驱动的方式在需要时才通知能省下一大笔不必要的生命周期管理开销。2.3 帧率与刷新机制的取舍什么时候真的需要高频Update也有一种情况是某段逻辑确实需要很高的刷新频率但又不需要每秒都刷新几十次。这时候可以用时间门槛来做降频这个技巧在项目里经常被用到private float _lastCheckTime; void Update() { if (Time.time - _lastCheckTime 0.2f) return; // 每秒最多检测5次 _lastCheckTime Time.time; // 这里放你的检测逻辑 CheckSomething(); }这样既能保证定时检测又避免每帧都跑完整逻辑。对于像敌人索敌距离检测、UI提示刷新、NPC闲聊对话触发这类对时间精度要求不高的逻辑降频是完全可行的思路。但需要留意的是降频不适用于所有场景。例如角色移动、摄像机跟随这种直接关系到画面帧间连续性的逻辑是不能降频的否则会出现肉眼可见的卡顿和抖动。这类逻辑必须保持每帧执行优化方向应该是减少单次执行的开销而不是减少执行次数。2.4 一个实际的性能案例为什么同一个逻辑差了10倍我之前做过一个2D弹幕游戏里面有大量的子弹对象。最初版本里每个子弹脚本的Update方法长这样void Update() { transform.position _direction * _speed * Time.deltaTime; }这个写法本身没有太大问题但当场景里同时有2000颗子弹时每帧要执行2000次Update调用加上Transform的position setter会触发组件内部的状态变更通知整体开销就上去了。后来我改成了手动管理子弹列表在单个管理器脚本里用for循环统一更新所有子弹的位置并且把子弹的Transform缓存为字段private Transform _transform; void Awake() { _transform transform; }实测在同样2000颗子弹的战场上帧时间从原来的22毫秒降到了7毫秒左右。这里面有两层原因一是把2000次MonoBehaviour Update调用缩减成了一次管理器里的for循环二是避免了频繁访问transform属性导致的内部查找开销。这个故事的核心启示是脚本优化不等同于“把代码写短”而是要想办法减少框架层的调度开销。当你能够把对象的管理逻辑收拢到一个控制器里很多时候性能自然就上去了。3. 内存与GC脚本优化的头号隐形杀手3.1 GC Alloc是怎么拖垮帧率的Unity脚本优化的世界里GC Alloc是一个绕不开的话题。如果你用Profiler的CPU模块看过内存分配数据你会发现很多看起来人畜无害的代码行后面跟着一个刺眼的GC Alloc标记。这些分配本身可能很小但它们会触发托管堆的垃圾回收——而GC的触发很多时候是顿帧的罪魁祸首。简单解释一下原理C#的托管内存分配是在堆上进行的当堆上的空闲空间不足以满足新的分配请求时CLR会触发垃圾回收来清理不再被引用的对象。在这个过程中应用的所有线程都会暂停等GC整理完堆再恢复。Unity的IL2CPP和Mono运行时都有这个问题只是表现略有差异。如果一个帧内发生了大量分配GC被触发时就会造成肉眼可见的卡顿。那么问题来了什么样的代码会产生GC Alloc最常见的几类字符串拼接string.Format、a b这类操作都会产生新的字符串对象装箱把值类型如int、float转换为object或接口引用时会产生装箱LINQ操作Where、Select、OrderBy这些方法往往会捕获上下文并产生闭包对象Lambda表达式捕获外部变量当lambda内部引用了方法内的局部变量编译器会生成一个闭包对象来保存这些变量举个最常见的例子很多人在调试时喜欢写这样的日志Debug.Log(Player HP: playerHP / maxHP);这个字符串拼接看起来没问题但实际上它产生了三个字符串对象一个拼接后的新字符串、HP值的字符串表示、最大HP值的字符串表示。如果这句日志在Update里每帧执行那么每帧都会产生至少三个字符串对象持续分配下去GC迟早会被触发。3.2 字符串、LINQ与闭包像防贼一样防着它们既然知道了GC Alloc的来源优化思路就很清楚了在高频运行代码里尽量避免上述四种操作。字符串这块如果确实需要高频拼接可以用C#的StringBuilder或者Unity的高性能字节串方案NativeText——后者是非托管内存不会触发GC但使用复杂度稍高。日志输出方面可以用条件编译指令控制发布版本不打印日志[System.Diagnostics.Conditional(ENABLE_LOG)] static void LogMessage(string msg) { Debug.Log(msg); }这样发布版本编译时所有调用LogMessage的代码都会被编译器擦掉不会产生调用开销也不会产生字符串分配。LINQ的问题比较麻烦因为它的写法太方便了。我的建议是列表的排序、查找、筛选如果在Update或高频逻辑里出现全部改回手写循环。手写for循环跟LINQ的差距不只是内存分配上的LINQ的迭代器模式本身也会产生额外的调用开销。我做过一个简单的基准测试同样是查找一个List里的符合条件的元素手写for循环比LINQ的WhereFirst快了大约4-5倍。Lambda和闭包这块需要留意的是闭包捕获循环变量这个经典陷阱for (int i 0; i enemies.Count; i) { enemies[i].OnDamaged (damage) { // 引用了i Debug.Log(enemyNames[i] took damage); }; }这里lambda捕获了i和enemyNames编译器会为它们生成闭包对象。如果你在循环里执行了一万次就会产生一万个闭包对象。解决方案是把需要的值在循环体内拷贝成局部变量再给lambda用或者干脆把回调方法提取成类的方法。3.3 对象池的正确打开方式不只是“保存复用”这么简单对象池这个方案估计做Unity的都听过但很多人实现的对象池其实没那么高效。最常见的问题是对象池只管了实例化和销毁的开销但忽略了对象内部的状态重置。比如一个子弹池子弹被回收后它身上挂着的粒子特效可能还在播放碰撞体还处于激活状态下一次从池里取出来使用时就会出现“上一帧的遗留状态”和“新状态”混杂的bug。我实现对象池时会强制要求池中的对象实现一个IPoolable接口包含两个方法public interface IPoolable { void OnSpawn(); void OnDespawn(); }OnDespawn负责在回收时将对象恢复为初始状态——重置速度、清空特效、关闭碰撞体、复位本地坐标。OnSpawn则负责激活和必要的初始化。这样设计之后对象池的复用就不会带来状态脏数据而且每个对象自己清楚怎么重置管理器那边不需要写一堆关于具体类型的逻辑。对象池另外要注意的一个点是预加载的数量要按峰值的80%来设置而不是平均值。如果只是按照平均值来预加载高峰期瞬间涌出大量新对象时池子还是会发生实例化这一帧的顿卡恰恰是你的性能分析报告上最扎眼的那一根刺。3.4 缓存永远不嫌多从GetComponent谈起每次在Update里写GetComponentT()我心里都会咯噔一下。这玩意儿在Unity早期的版本里是实打实的重量级操作到了新版本虽然有所优化但依然不是免费的——它会遍历组件列表并做类型匹配然后返回缓存结果。Unity在同一个组件的连续读取上做了缓存但如果每帧反复获取不同组件开销依然不小。正确的做法无非是把获取结果缓存到Awake或Start里private Rigidbody2D _rb; void Awake() { _rb GetComponentRigidbody2D(); }这个道理很简单但在实际项目里就是因为这个写法和那个写法看起来差不多很多人就偷懒直接每次都在方法里取。当Profiler告诉你某个脚本慢的时候你打开看第一眼大概率就能看到类似的问题。除了GetComponenttransform属性也存在类似的缓存问题。Unity的transform访问走的是组件查询虽然速度比GetComponent快得多但也不如直接存字段来得快。我习惯在Awake里把所有高频字段都缓存下来包括Transform、Renderer、Collider甚至Animator——避免在Update里频繁触发组件系统的内部查询。4. 脚本与渲染、物理的协同优化你的脚本可能正在拖垮CPU4.1 当脚本调用成为渲染瓶颈SetPass与DrawCall背后的推手很多人在做性能分析时会把渲染和脚本当成两件独立的事。但实际上脚本逻辑会直接决定渲染卡不卡。举一个最常见的场景动态合并。Unity的合批机制——无论是静态合批还是动态合批——都要求参与合批的物体使用相同的材质和渲染参数。如果脚本在高频更新中修改了物体的颜色、材质参数就会破坏合批条件导致原本可以一次DrawCall画完的东西被拆成几十上百次DrawCall。我来用一个数字来说明问题一个场景里有300个物体理论上如果合批成功只需要几个DrawCall。但脚本在运行中如果修改了其中30个物体的颜色这30个物体就无法参与合批渲染批次直接暴涨到300每帧CPU在图形接口调用上的开销会成倍增长。所以脚本层面能做的第一个优化是尽量减少运行时的材质属性修改。如果确实需要修改优先使用MaterialPropertyBlock它可以绕过合批破坏的问题var block new MaterialPropertyBlock(); block.SetColor(_BaseColor, newColor); renderer.SetPropertyBlock(block);用MaterialPropertyBlock修改的是一个实例级别的材质参数不需要克隆材质也不会改变材质的同批次共享状态——前提是不同物体设置的参数一致才能继续合批。另一个隐藏较深的问题是脚本触发的对象创建和销毁在渲染线程上的开销。当脚本实例化一个新物体时Unity需要在渲染线程中为该物体创建对应的渲染数据。如果这一帧恰好创建了几十个物体渲染线程可能来不及处理帧率就会掉。这也是为什么对象池对渲染性能也有帮助——不只是省了Instantiate/Destroy的CPU开销还避免了渲染线程的突发性工作负载。4.2 物理查询的隐形开销OnTriggerStay为什么能省则省物理引擎这块脚本侧最常见的性能杀手有两个OnTriggerStay/OnCollisionStay和频繁的物理查询。OnTriggerStay和OnCollisionStay这两个回调在物理引擎的处理中是持续触发的——只要两个碰撞体还保持接触每帧都会调用。这意味着如果你在OnTriggerStay里做字符串比较或者其他逻辑等于每帧都在为这个碰撞关系额外付费。更麻烦的是物理引擎每帧都要判断“哪些碰撞体还在接触”这个判断本身是有开销的。优化的思路很直接能不能改成OnTriggerEnter一次性处理很多业务逻辑——比如进入区域、离开区域——只需要两个事件就能覆盖根本不需要Stay。对于确实需要持续检测的情况也可以配合时间门槛或者事件标志位来控制检测频率。物理查询方面Physics.Raycast是另一个高频使用点。如果一个物体每帧发射3条射线场景里有50个这样的物体就是150次射线检测——如果射线长度远、碰撞体多开销会非常明显。解决方案是在需要时用Physics.RaycastNonAlloc复用数组结果避免每次查询都产生数组分配。同时可以用LayerMask过滤掉不需要检测的层把查询范围缩小再缩小。4.3 协程与异步加载如何避免让主线程喘不过气资源加载是另一个常见卡顿来源。当你在主线程上用Resources.Load或AssetBundle.LoadAsset同步加载一个比较大的资源时——比如加载一个包含几十个贴图和动画的模型——主线程会陷入阻塞帧率直接掉到个位数。有人会说那我用异步加载Resources.LoadAsync不就行了没错异步加载确实能让主线程不卡死但这里有个坑异步加载完成后的回调仍然是在主线程上执行的如果你在回调里做了很多初始化工作——实例化物体、初始化组件、加载依赖资源——这些开销还是会集中在一帧里爆发。我的建议是把大资源的加载拆成多帧分步完成IEnumerator LoadBigAsset() { var request Resources.LoadAsync(BigModel); yield return request; // 第一帧实例化主体 var model Instantiate(request.asset) as GameObject; yield return null; // 第二帧初始化子物体和悬挂脚本 SetupModel(model); yield return null; // 第三帧处理材质和Shader SetupMaterials(model); }这样把本来集中在一帧的工作量摊到几帧里玩家几乎感知不到加载过程中的卡顿。这个技巧在加载关卡、大规模场景切换时特别有用。5. 用Unity Profiler把问题钉死在代码行号上5.1 分层定位CPU、GPU与渲染瓶颈的基础操作好了前面讲的都是纸上谈兵真正动手排查的时候你需要一个趁手的工具——Unity Profiler。很多人知道Profiler这个功能但真正用得熟练的并不多。我用过Unity Profiler四五年踩过不少冤枉路这里把最核心的操作流程分享出来。第一步是在编辑器里连上Unity Profiler跑一次目标场景。打开Window Analysis Profiler点Record之后跑个两三分钟正常玩法。跑完之后先看CPU Usage模块的层级视图——Chrome层级——找到消耗最大的函数。关键看两类节点一个是脚本Script相关的条目另一个是渲染Rendering相关的条目。如果脚本占总帧时间的比例超过30%说明脚本层是瓶颈应该追进去看具体是哪个MonoBehaviour的哪个方法在耗时。如果渲染占比高则需要查看DrawCall数量和三角面数——脚本层面的优化空间可能是降低合批的破坏频率。比较关键的还有Deep Profile功能它可以在函数级别统计每个方法的开销。但要注意Deep Profile会显著拖慢运行速度手机端根本跑不动所以一般建议在编辑器里用。如果要在真机上测Unity 2020之后提供的ProfilerRecorderAPI可以做到类似的效果但需要自己写代码配合采样门槛稍高。5.2 真机Profile为什么编辑器数据不能代表最终体验编辑器里的Profiler数据只能作为一个参考方向绝对不能用它来判断真机上的性能表现。原因在于编辑器的运行环境和真机有本质差异。最典型的一个差异是编辑器模式下Unity的渲染跑的是模拟图形设备很多GPU优化并不会生效而IL2CPP编译出的原生代码和编辑器里的Mono JIT运行方式在性能模型上差别也很大。所以同一个函数在编辑器和真机上的运行时间可能差出5到10倍。真机Profile的方法根据平台不同略有区别。Android上最简单的方式是开启USB调试用Unity Profiler的设备列表直接连接iOS上需要将Profiler的接口包集成到Xcode工程中配置稍微复杂一些。但不管哪个平台Release模式下的Profile数据才有参考价值Debug模式因为包含大量调试信息性能被严重拖累得出来的数据没有意义。5.3 帧时间拆解从22毫秒到9毫秒的一次实战排查这里我来还原一次实战的排查过程让大家感受一下整套流程怎么串联起来。项目是Android端的一个塔防游戏用Mid-range机型测试帧率只有45fps左右单帧耗时22毫秒左右目标是要控制在60fps也就是16.7毫秒以内。第一步用真机Release模式跑了一遍场景拿到Profiler数据。CPU模块显示脚本逻辑占10毫秒渲染占7毫秒物理占2毫秒其他杂项占3毫秒。脚本占比接近50%很明显脚本是主要瓶颈。第二步钻进脚本模块看层级。消耗排名前三的分别是Update方法里的一段字符串格式化占5毫秒一个OnTriggerStay逻辑占3毫秒一个高频的Resources.Load调用占2毫秒第三步逐个处理。字符串格式化是技能CD的文本显示逻辑直接改成StringBuilder复用实例开销降到0.2毫秒。OnTriggerStay是门口的检测陷阱发现触发频率可以收紧改成只有移动端检测省下2.5毫秒。Resources.Load是防御塔升级时加载模型资源改成异步加载并做了缓存省下1.5毫秒。第四步重新Profile帧时间从22毫秒降到11毫秒多。然后再看渲染发现DrawCall有接近400合批效果不理想。排查发现塔的升级逻辑里有动态改材质颜色的脚本换成MaterialPropertyBlock之后DrawCall降到180左右帧时间又压缩到9毫秒出头。整套下来帧率从45fps涨到了接近60fps玩家反馈的掉帧感明显消失而且发热也轻了不少。这个案例里最关键的一个认知是并没有用什么高深的黑魔法每一步都只是把该省的开销省掉而已。6. 脚本优化的常识清单与我的最终建议到这里该讲的技术细节基本都覆盖了。最后整理一份我的实用清单这些经验都是在项目中被反复验证过的可以直接在团队里推行。先说说我强烈建议列入Code Review规则的几条Update里禁止出现字符串拼接、LINQ、GetComponent、FindObjectOfType、Resources.Load所有高频访问的组件引用统一在Awake里缓存延迟逻辑统一走协程或定时器不在Update里自己做累加计时OnTriggerStay/OnCollisionStay这类回调里只做标志位赋值不写重逻辑对象池对象必须实现状态重置接口禁止回收后残留状态高频查找使用手写循环代替LINQDebug日志用条件编译包裹发布版默认关闭这些规则看起来严苛但执行起来其实并不困难。我见过很多项目在性能出问题之后团队花大量时间去拆东墙补西墙与其这样不如在一开始就把这类容易埋雷的写法限制住。性能优化的最好时机是写代码的时候而不是上线之前。另外想多说一句关于工具的习惯我几乎每次在编辑器里跑完一个功能模块都会顺手拍个Profiler快照看看这个模块的核心代码有没有产生垃圾分配。这个习惯坚持下来之后我就很少再看GC Alloc的数值了因为大部分出现在高频路径的分配源头在写代码的阶段就被规避掉了。形成这种直觉之后优化工作就变得轻松且可持续而不是每次上线前熬夜和Profiler较劲。脚本优化不是把代码压缩到最短的一行而是懂得到底哪里的开销值得花时间省——这也是从“会写Unity脚本”到“能掌控项目性能”之间最需要跨过的一道门槛。