脚本优化策略
掌握 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、某函数排第一」。打开层级一看,经常是密密麻麻的小条目:GetComponent、Update.ScriptRunBehaviourUpdate、偶尔还有 GC.Alloc。它们不像渲染那样直观,却能在场景变大、角色变多时悄悄吃掉帧预算。
这章要解决的,是脚本层最常见的写法陷阱:明明逻辑没错,却因为每帧重复查找、空回调堆积、运行时搜场景而变慢。Profiler 已经告诉你「慢在脚本」——接下来需要的是可落地的改法,而不是再盲改一轮。
没有这些策略会怎样?你会在 Update 里一遍遍问引擎「这个组件在哪」,挂着几十个什么都不干的空函数,还在运行时满场景找一个叫 "Player" 的对象——CPU 花在「找东西」和「被叫起来却什么都不做」上,真正玩法逻辑反而挤不进 16.6ms。
组件引用:查一次,用很多遍
↡在 Awake/Start 或 Inspector 序列化字段里保存组件引用,避免在 Update 等热路径重复调用 GetComponent 的写法。 是脚本优化第一课。GetComponent<T>() 要从托管 C# 穿过引擎原生层查找组件——单次不贵,每帧 × 对象数量就贵了。
两种推荐写法:Inspector 拖引用(零运行时查找)或 Awake 里查一次存字段。下面是对照——左边每帧查,右边只查一次。
public class Mover : MonoBehaviour {
void Update() {
var rb = GetComponent<Rigidbody>();
rb.velocity = transform.forward * 5f;
}
}public class Mover : MonoBehaviour {
[SerializeField] Rigidbody _rb; // Inspector 拖引用
// 或 void Awake() => _rb = GetComponent<Rigidbody>();
void Update() {
_rb.velocity = transform.forward * 5f;
}
}空回调:体量为零,成本不为零
只要脚本启用且定义了 Update / LateUpdate / FixedUpdate,引擎的 PlayerLoop 每帧仍会调用,哪怕函数体是空的。一百个空 Update 就是一百次调度开销。
↡MonoBehaviour 上定义了但函数体为空的 Update/LateUpdate/FixedUpdate;引擎仍按帧调度,造成无意义的 CPU 开销。 在合并场景、挂了大量 prefab 时特别常见——某个模板里留了空函数,复制几十份就放大成热点。
修法:删掉不用的回调;需要「偶尔更新」的改用事件或集中调度(见后文自定义 Update 层)。
选对更新机制:每帧、挂起还是定间隔
不是每件事都值得放进 Update。三种内置机制各管一类节奏:
↡MonoBehaviour 每帧在主线程调用的回调,与帧率同步,适合输入与逐帧动画。 跟帧率走; ↡用 yield return 把执行挂起、下一帧或延迟后再继续的协程,适合分步流程与等待。 可以「等 0.5 秒再继续」; ↡按固定秒数重复调用某方法的引擎 API,底层用字符串反射查找方法。 适合低频轮询,但不如显式事件干净。
已销毁对象:Unity 的「假 null」
↡Unity 重载 UnityEngine.Object 的 == 运算符,原生对象销毁后 C# 包装可能仍非 C# null,用 == null 才能判活。
和纯 C# 不一样:对象被 Destroy 后,引用有时在 C# 里还不是 null,但 Unity 的
== null 会返回 true。
// 纯 C# 判空 — 销毁后可能仍「有引用」
if (target != null)
target.DoSomething(); // 可能对已销毁对象调用
// Update 里每帧 Find
var player = GameObject.Find("Player");// Unity 重载的 == 能识别已销毁对象
if (target == null) return;
target.DoSomething();
// Awake 缓存一次
Transform _player;
void Awake() => _player = GameObject.Find("Player").transform;别在运行时搜场景:Find 与 SendMessage
↡按名称或标签从场景根遍历层级树查找 GameObject 的 API,节点越多开销越大。 和 ↡按方法名字符串向对象发消息,依赖反射,慢且难维护。 都会触发场景遍历。
用直接引用、事件、接口或消息总线替代——启动时注册一次,运行时 O(1) 调用。
动手:三种更新机制怎么选
猜一猜:把「每 2 秒刷新一次排行榜」写进
Update里用Time.deltaTime累加,和用InvokeRepeating(..., 2f, 2f),Profiler 里哪条 GC Alloc 更高、哪条主线程更稳?
① Update:每帧同步
角色移动、读输入、跟摄像机——必须与帧率同步的逻辑放
Update。检查项目里是否有空 Update 可删;Profiler 里
ScriptRunBehaviourUpdate 过高时数一下启用脚本数量。
代码对照:热路径写法全集
下面按热点类型给出「慢 vs 快」对照——Unity 项目只有 C# 一侧,两栏都是同一逻辑的不同写法。
Find 与 SendMessage
void Update() {
var go = GameObject.FindWithTag("Enemy");
go?.SendMessage("OnAlert", SendMessageOptions.DontRequireReceiver);
}public class EnemyRegistry : MonoBehaviour {
public static EnemyRegistry Instance { get; private set; }
readonly List<Enemy> _enemies = new();
void Awake() => Instance = this;
public void Register(Enemy e) => _enemies.Add(e);
public void AlertAll() { foreach (var e in _enemies) e.OnAlert(); }
}距离比较与容器
↡Vector3 长度的平方,比较远近时避免 magnitude 内部的开方运算。
在判断「进入范围」时比 magnitude 省一次 sqrt。热循环里优先数组或预分配容量的
List,减少 GC。
foreach (var t in _targets) { // List 每帧 new 更糟
if ((t.position - transform.position).magnitude < 5f)
Hit(t);
}const float RangeSq = 5f * 5f;
for (int i = 0; i < _targets.Length; i++) {
if (( _targets[i].position - transform.position).sqrMagnitude < RangeSq)
Hit(_targets[i]);
}场景加载:Additive 与 Async
↡在保留当前场景的同时叠加加载另一场景,适合分区大地图或 UI 层。
不卸当前关;
↡异步加载场景,主线程可分帧推进进度,避免切换卡顿。
用 LoadSceneAsync 配合进度条。
// 同步切换 — 大场景一帧卡死
SceneManager.LoadScene("Level2");IEnumerator LoadAdditiveAsync() {
var op = SceneManager.LoadSceneAsync("Level2", LoadSceneMode.Additive);
op.allowSceneActivation = false;
while (op.progress < 0.9f) { UpdateBar(op.progress); yield return null; }
op.allowSceneActivation = true;
}集中自定义 Update 层
↡由单一管理器在一条 Update 里遍历对象并调用自定义 Tick,替代大量 MonoBehaviour.Update 以减少引擎调度开销。 适合大量 NPC、子弹、计时器——一次引擎回调,内部循环 N 次逻辑。
// 100 个脚本各有一个 Update
public class NpcA : MonoBehaviour {
void Update() { Tick(); }
}public interface ITickable { void Tick(float dt); }
public class TickManager : MonoBehaviour {
readonly List<ITickable> _items = new();
void Update() {
float dt = Time.deltaTime;
for (int i = 0; i < _items.Count; i++) _items[i].Tick(dt);
}
}容易踩的坑
小结
- 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(问答型) 比较 magnitude 与 sqrMagnitude 判断进入 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 代码对照。