脚本优化策略

掌握 Unity C# 脚本层常见性能陷阱与写法——缓存组件、选对 Update 机制、避开 Find 与 GC。

学习目标

  • 能把 Profiler 里常见的脚本热点(GetComponent、空 Update、Find)改写成缓存引用与集中调度写法
  • 能根据「多久跑一次」在 Update、Coroutine、InvokeRepeating 之间做出取舍,并解释各自的开销来源
  • 能回答:100 个挂着空 Update() 的脚本和 1 个管理器里循环 100 次自定义 Tick(),Profiler 上 CPU 分布会有什么差别?为什么?

原书边界:Chapter 2

Packt 第三版把本章命名为 Scripting Strategies。官方目录列出的核心范围如下;这些条目共同构成本章的一对一边界:

  • Obtaining components using the fastest method:纳入本章实验与验收,不拆到别章重复计数。
  • Removing empty callback definitions:纳入本章实验与验收,不拆到别章重复计数。
  • Caching component references:纳入本章实验与验收,不拆到别章重复计数。
  • Sharing calculation output:纳入本章实验与验收,不拆到别章重复计数。
  • Update, coroutines, and InvokeRepeating:纳入本章实验与验收,不拆到别章重复计数。
  • Faster GameObject null reference checks:纳入本章实验与验收,不拆到别章重复计数。
  • Avoid retrieving string properties from GameObjects:纳入本章实验与验收,不拆到别章重复计数。
  • Using appropriate data structures:纳入本章实验与验收,不拆到别章重复计数。
  • Avoiding re-parenting transforms at runtime:纳入本章实验与验收,不拆到别章重复计数。
  • Considering caching transform changes:纳入本章实验与验收,不拆到别章重复计数。
  • Avoiding Find() and SendMessage() at runtime:纳入本章实验与验收,不拆到别章重复计数。
  • Disabling unused scripts and objects:纳入本章实验与验收,不拆到别章重复计数。
  • Using distance-squared over distance:纳入本章实验与验收,不拆到别章重复计数。
  • Minimizing deserialization behavior:纳入本章实验与验收,不拆到别章重复计数。
  • Loading scenes additively and asynchronously:纳入本章实验与验收,不拆到别章重复计数。
  • Creating a custom Update() layer:纳入本章实验与验收,不拆到别章重复计数。

把高频回调中的查找、分配、字符串桥接与重复计算移到初始化、事件或集中调度层。新版 Unity 的增量 GC、Awaitable 与 Entities 不会自动修复低效 MonoBehaviour 热路径;先以 Profiler 证据选择改法。 因此,本章既保留 2019/2020 语境,也明确标出迁移到现代 Unity 时哪些是稳定原理、哪些只是版本相关接口。

脚本写得不对,Profiler 也会红

上一章你已经会用 Profiler 量出「主线程 18ms、某函数排第一」。打开层级一看,经常是密密麻麻的小条目:GetComponentUpdate.ScriptRunBehaviourUpdate、偶尔还有 GC.Alloc。它们不像渲染那样直观,却能在场景变大、角色变多时悄悄吃掉帧预算。

这章要解决的,是脚本层最常见的写法陷阱:明明逻辑没错,却因为每帧重复查找、空回调堆积、运行时搜场景而变慢。Profiler 已经告诉你「慢在脚本」——接下来需要的是可落地的改法,而不是再盲改一轮。

没有这些策略会怎样?你会在 Update 里一遍遍问引擎「这个组件在哪」,挂着几十个什么都不干的空函数,还在运行时满场景找一个叫 "Player" 的对象——CPU 花在「找东西」和「被叫起来却什么都不做」上,真正玩法逻辑反而挤不进 16.6ms。

组件引用:查一次,用很多遍

是脚本优化第一课。GetComponent<T>() 要从托管 C# 穿过引擎原生层查找组件——单次不贵,每帧 × 对象数量就贵了。

❌ 每帧 GetComponentUpdate()GetComponent<T>()Native 查找 + 装箱×60fps✅ 缓存 / 序列化引用Awake()_rb = GetComponent 一次Update()_rb 字段在 Awake/Start 或 Inspector 序列化字段缓存引用,Update 里直接读字段
GetComponent 穿越托管/原生边界有固定开销;每帧重复查找会堆成 CPU 热点。

两种推荐写法:Inspector 拖引用(零运行时查找)或 Awake 里查一次存字段。下面是对照——左边每帧查,右边只查一次。

public class Mover : MonoBehaviour {
    void Update() {
        var rb = GetComponent<Rigidbody>();
        rb.velocity = transform.forward * 5f;
    }
}

空回调:体量为零,成本不为零

只要脚本启用且定义了 Update / LateUpdate / FixedUpdate,引擎的 PlayerLoop 每帧仍会调用,哪怕函数体是空的。一百个空 Update 就是一百次调度开销。

Unity 引擎PlayerLoop 调度表Update()LateUpdate()FixedUpdate()空函数仍注册 ↓空函数仍注册 ↓MonoBehaviour 脚本void Update() void LateUpdate() — 体量为零,调度成本不为零100 个空 Update ≈ 100 次跨边界调用;删掉脚本或改用集中调度
即使函数体为空,只要脚本挂着且启用了对应回调,引擎每帧仍会遍历并调用。

在合并场景、挂了大量 prefab 时特别常见——某个模板里留了空函数,复制几十份就放大成热点。

修法:删掉不用的回调;需要「偶尔更新」的改用事件或集中调度(见后文自定义 Update 层)。

选对更新机制:每帧、挂起还是定间隔

不是每件事都值得放进 Update。三种内置机制各管一类节奏:

跟帧率走; 可以「等 0.5 秒再继续」; 适合低频轮询,但不如显式事件干净。

已销毁对象:Unity 的「假 null」

和纯 C# 不一样:对象被 Destroy 后,引用有时在 C# 里还不是 null,但 Unity 的 == null 会返回 true。

// 纯 C# 判空 — 销毁后可能仍「有引用」
if (target != null)
    target.DoSomething(); // 可能对已销毁对象调用
 
// Update 里每帧 Find
var player = GameObject.Find("Player");

别在运行时搜场景:Find 与 SendMessage

都会触发场景遍历。

场景层级遍历(O(n) 节点)Scene RootGroup 1ChildGroup 2ChildGroup 3ChildFind("Player")SendMessage每次调用从根遍历整棵树 + 字符串匹配;Awake 缓存引用或事件总线替代
GameObject.Find、FindWithTag、SendMessage 都依赖运行时搜索,节点越多越慢。

直接引用、事件、接口或消息总线替代——启动时注册一次,运行时 O(1) 调用。

动手:三种更新机制怎么选

猜一猜:把「每 2 秒刷新一次排行榜」写进 Update 里用 Time.deltaTime 累加,和用 InvokeRepeating(..., 2f, 2f),Profiler 里哪条 GC Alloc 更高、哪条主线程更稳?

分步1 / 3

① Update:每帧同步

角色移动、读输入、跟摄像机——必须与帧率同步的逻辑放 Update。检查项目里是否有空 Update 可删;Profiler 里 ScriptRunBehaviourUpdate 过高时数一下启用脚本数量。

代码对照:热路径写法全集

下面按热点类型给出「慢 vs 快」对照——Unity 项目只有 C# 一侧,两栏都是同一逻辑的不同写法。

Find 与 SendMessage

void Update() {
    var go = GameObject.FindWithTag("Enemy");
    go?.SendMessage("OnAlert", SendMessageOptions.DontRequireReceiver);
}

距离比较与容器

在判断「进入范围」时比 magnitude 省一次 sqrt。热循环里优先数组或预分配容量的 List,减少 GC。

foreach (var t in _targets) { // List 每帧 new 更糟
    if ((t.position - transform.position).magnitude < 5f)
        Hit(t);
}

场景加载:Additive 与 Async

不卸当前关; LoadSceneAsync 配合进度条。

// 同步切换 — 大场景一帧卡死
SceneManager.LoadScene("Level2");

集中自定义 Update 层

适合大量 NPC、子弹、计时器——一次引擎回调,内部循环 N 次逻辑

// 100 个脚本各有一个 Update
public class NpcA : MonoBehaviour {
    void Update() { Tick(); }
}

容易踩的坑

小结

  • GetComponent / Find:Awake 或序列化缓存,禁止热路径重复查找
  • 空 Update:删掉或合并到自定义 Update 层
  • Update / Coroutine / Invoke:按频率选机制,能事件驱动就别轮询
  • 距离判断sqrMagnitude;热循环注意 List 分配与 GC
  • 场景用 Additive + Async;改完用上一章基线在同场景复测

练习

问题 1(改代码型) 找一个在 Update 里调用 GetComponent 的脚本,改成 Awake 缓存或序列化字段。用 Profiler 对比改动前后该脚本的 Self/Total 时间,并记录 GC Alloc 是否变化。

问题 2(问答型) 为什么 100 个空 Update 通常比 1 个 TickManager 循环 100 次更慢?

问题 3(问答型) 比较 magnitudesqrMagnitude 判断进入 5 米范围时的差异;若范围改为 float range 变量,快写法应如何写?

名词解释

本章出现的专业名词,用大白话再讲一遍。

GetComponent 缓存

在 Awake/Start 或 Inspector 字段保存组件引用,避免 Update 里重复 GetComponent。详见「组件引用」一节。

空 MonoBehaviour 回调

函数体为空的 Update 等仍被引擎每帧调度。详见 EmptyCallbackDiagram。

Update

每帧主线程回调,与帧率同步。详见 Stepper 第 ① 步。

Coroutine(协程)

用 yield 挂起、延迟再继续的 IEnumerator 流程。详见 Stepper 第 ② 步。

InvokeRepeating

按固定间隔重复调用方法名的引擎 API。详见 Stepper 第 ③ 步。

Unity 空引用检查

Unity 重载 ==,销毁后对象用 == null 判活。详见「已销毁对象」一节。

GameObject.Find / FindWithTag

运行时遍历场景层级按名/标签查找。应用缓存引用替代。详见 FindSendMessageDiagram。

SendMessage

按字符串方法名发消息,慢且难维护。用接口/事件替代。

sqrMagnitude

向量长度平方,比较距离时省去 sqrt。详见距离比较代码对照。

Additive 加载

叠加加载场景不卸载当前关。详见场景加载代码对照。

Async 加载

LoadSceneAsync 分帧加载,避免同步卡顿。详见场景加载代码对照。

自定义 Update 层

单管理器 Tick 多个对象,减少引擎回调次数。详见集中 Update 代码对照。

资料与写作方式声明

本章以Packt《Unity Game Optimization》第3版权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…