Unity 常用 API 与核心类全解:从生命周期到协程,附性能避坑清单(Unity 6 适配)
适用引擎版本:Unity 6(6000.x)/ Unity 6.3 LTS,同时向下兼容 2021/2022 LTS
语言:C# | 覆盖范围:内置渲染管线、URP 均适用
更新时间:2026-09-18
这篇做了四件事:
- 每个 API 都给出「签名 → 最小可用示例 → 常见坑 → 性能代价」,拒绝只列不解释。
- 补齐并修正了常见资料里缺失或写错的部分 ,例如
Random.Range整数版与浮点版的边界语义不同、Mathf.Round采用的是"银行家舍入"、协程在MonoBehaviour禁用时并不会停止等。 - 适配 Unity 6 的 API 变更 ,例如
Rigidbody.velocity已更名为linearVelocity,FindObjectOfType已弃用。 - 增加横向对比表和一张性能避坑清单,让这篇文章可以当中速手册用,也可以当代码 Review 的检查表用。
阅读建议:初学可以按顺序读第 2~9 节;已经会写代码的,直接跳到第 6、12、16、18、20 节和末尾的面试速答。
1. 全文知识地图
| 分类 | 涉及类型 | 你主要用它做什么 |
|---|---|---|
| 调试输出 | Debug |
打印日志、警告、错误,定位问题 |
| 生命周期 | MonoBehaviour 事件函数 |
在正确的时间点执行正确的事 |
| 对象与组件 | GameObject、Component、Object |
查找、激活、增删组件、实例化与销毁 |
| 空间变换 | Transform、Vector2/3、Quaternion |
位置、旋转、缩放、方向与距离计算 |
| 输入交互 | Input、OnMouseXXX |
键盘、鼠标、触摸、手柄输入 |
| 通信 | SendMessage 系列、UnityEvent |
脚本之间传递消息 |
| 动画 | Animator |
状态机切换、参数驱动、动画事件 |
| 时间 | Time |
帧无关的移动、暂停、倒计时 |
| 数学与随机 | Mathf、Random |
插值、取整、三角函数、随机数 |
| 异步 | Coroutine、Invoke、Awaitable |
延时、定时、分帧执行 |
| 物理 | Rigidbody、Collider |
力的施加、碰撞与触发器事件 |
一句话总纲:Unity 脚本 = 在生命周期函数的正确时间点,用 Transform / Vector 计算空间,用 Component 拿到底层能力,用 Time 抹平帧率差异,用协程或 Invoke 处理时间维度。
2. 调试输出:Debug
三个方法的使用边界要分清,否则控制台会被淹没,真正的问题反而看不见。
| 方法 | 控制台颜色 | 使用场景 |
|---|---|---|
Debug.Log() |
白色 | 常规流程、变量值跟踪 |
Debug.LogWarning() |
黄色 | 可继续运行但需要关注的情况 |
Debug.LogError() |
红色 | 异常、必须修复的错误,会带上堆栈 |
2.1 最小示例
csharp
public class PlayerController : MonoBehaviour
{
[SerializeField] private int playerHealth = 100;
private void Start()
{
// 推荐用 $ 插值,可读性远好于字符串拼接
Debug.Log($"游戏开始,角色生命值为:{playerHealth}");
}
private void TakeDamage(int damage)
{
playerHealth -= damage;
if (playerHealth <= 20 && playerHealth > 0)
Debug.LogWarning($"生命值偏低:{playerHealth}");
if (playerHealth <= 0)
Debug.LogError("玩家死亡!请检查死亡流程", this); // 第二个参数把报错定位到指定对象
}
}
2.2 坑与优化
- 正式包里残留大量
Debug.Log会明显拖慢帧率。 字符串拼接 + 堆栈采集本身就是开销。 - 不要用
"a" + b + "c"拼日志,用$"..."插值(编译器会走DefaultInterpolatedStringHandler,减少临时字符串)。
生产环境的正确做法是包一层可条件编译的日志门面:
csharp
public static class Log
{
// 只在编辑器 / Development Build 中保留,正式包会被编译期剥离
[System.Diagnostics.Conditional("UNITY_EDITOR")]
[System.Diagnostics.Conditional("DEVELOPMENT_BUILD")]
public static void Info(object msg) => UnityEngine.Debug.Log(msg);
}
[Conditional] 的妙处是:调用点连同参数求值一起被编译器删除 ,比在方法内部写 if (debug) 更彻底(后者仍会执行字符串拼接)。
3. 生命周期函数:Unity 脚本的核心骨架
生命周期函数由引擎自动调用,写错时间点是新手 80% 的 Bug 来源。
3.1 完整执行顺序
| 顺序 | 函数 | 触发时机 | 调用次数 |
|---|---|---|---|
| 1 | Reset() |
编辑器中组件被添加或被 Reset 时 | 仅编辑器 |
| 2 | Awake() |
对象被实例化 / 场景加载时 | 一次 |
| 3 | OnEnable() |
每次 enabled = true 或对象被激活 |
多次 |
| 4 | Start() |
首次 Update() 之前 |
一次 |
| 5 | FixedUpdate() |
固定时间步长(默认 0.02s) | 多次 |
| 6 | OnTriggerXXX() |
触发器进出、停留(物理步内) | 多次 |
| 7 | OnCollisionXXX() |
碰撞进出、停留(物理步内) | 多次 |
| 8 | Update() |
每帧一次 | 多次 |
| 9 | 协程 yield return null 之后 |
每帧 Update 之后恢复 | 多次 |
| 10 | LateUpdate() |
所有 Update() 之后 |
多次 |
| 11 | OnGUI() |
GUI 事件渲染,每帧通常两次 | 多次 |
| 12 | OnDrawGizmos() |
编辑器 Scene 视图绘制 | 仅编辑器 |
| 13 | OnDisable() |
enabled = false 或对象被销毁 |
多次 |
| 14 | OnDestroy() |
对象被真正销毁 | 一次 |
| 15 | OnApplicationQuit() |
应用退出 | 一次 |
移动端还需要关注:OnApplicationPause(bool)(切后台)、OnApplicationFocus(bool)(切焦点)。
关键结论:同一个对象内是 Awake → OnEnable → Start;而场景中所有对象的 Awake、OnEnable 都执行完毕后,才会执行第一个 Start。 这就是"在 Awake 里初始化,在 Start 里访问别人"这条惯例的由来。
但要注意:不同脚本之间 Awake 的执行顺序是不保证的。
3.2 Awake vs Start
| 对比项 | Awake() |
Start() |
|---|---|---|
| 调用时机 | 对象实例化后立即 | 首次 Update 前,且晚于所有 Awake |
| 对象被禁用时 | 仍然调用(只要对象被创建) | 不调用,直到对象被激活 |
| 依赖其他对象 | 不安全 | 相对安全 |
| 典型用途 | 缓存自身组件引用、初始化字段 | 注册事件、开启协程、访问外部对象 |
csharp
public class Enemy : MonoBehaviour
{
[SerializeField] private float speed = 5f;
private Rigidbody rb;
private Transform target;
private void Awake()
{
// 只碰自己身上的东西,最安全
if (!TryGetComponent(out rb))
Debug.LogError("Enemy 缺少 Rigidbody 组件", this);
}
private void Start()
{
// 现在可以安全地找外部对象了
target = GameObject.FindWithTag("Player")?.transform;
}
}
3.3 Update / FixedUpdate / LateUpdate 对比
这是面试必问、实战必踩的一组函数。
| 对比项 | Update() |
FixedUpdate() |
LateUpdate() |
|---|---|---|---|
| 调用频率 | 每帧一次,随帧率变化 | 固定间隔(由 Fixed Timestep 决定) | 每帧一次,晚于所有 Update |
| 时间字段 | Time.deltaTime |
Time.fixedDeltaTime(此处读 deltaTime 返回的也是它) |
Time.deltaTime |
Time.timeScale = 0 时 |
仍然执行 | 停止执行 | 仍然执行 |
| 主要用途 | 输入检测、UI、非物理逻辑 | 刚体受力、物理相关运动 | 相机跟随、IK、依赖他人最终位置的逻辑 |
csharp
public class PlayerMovement : MonoBehaviour
{
[SerializeField] private float moveSpeed = 5f;
private Vector3 moveInput;
private void Update()
{
// 1) 在 Update 里读输入
float h = Input.GetAxisRaw("Horizontal");
float v = Input.GetAxisRaw("Vertical");
// ClampMagnitude 修复斜向移动速度 1.414 倍的经典 Bug
moveInput = Vector3.ClampMagnitude(new Vector3(h, 0f, v), 1f);
}
private void FixedUpdate()
{
// 2) 在 FixedUpdate 里用输入(如果用 Rigidbody 驱动,详见第 18 节)
transform.Translate(moveInput * (moveSpeed * Time.fixedDeltaTime), Space.World);
}
}
"读输入用 Update,用输入驱动物理用 FixedUpdate" 这条分工是必须记住的。
LateUpdate 的经典用法是相机跟随,保证相机在角色移动之后再计算位置,否则画面会抖动:
csharp
public class CameraFollow : MonoBehaviour
{
[SerializeField] private Transform target;
[SerializeField] private Vector3 offset = new Vector3(0f, 2f, -5f);
[SerializeField] private float smoothSpeed = 8f;
private void LateUpdate()
{
Vector3 desired = target.position + offset;
// 用 deltaTime 归一化,避免帧率影响跟随速度
transform.position = Vector3.Lerp(transform.position, desired, smoothSpeed * Time.deltaTime);
}
}
注意原文示例中直接写
Lerp(..., smoothSpeed),smoothSpeed = 0.125f时在高帧率下会明显偏慢、低帧率下又过快。任何跟时间相关的插值都应该乘Time.deltaTime。
3.4 OnDestroy 与资源释放
csharp
public class EventSubscriber : MonoBehaviour
{
private void OnEnable() => GameEvents.OnPlayerDied += HandlePlayerDied;
private void OnDisable() => GameEvents.OnPlayerDied -= HandlePlayerDied;
// 注意:OnDisable 是解绑的最佳位置,OnDestroy 有时会来不及
}
坑 :OnDestroy 在 OnDisable 之后调用;如果对象在销毁前先被 SetActive(false),OnDisable 会先跑一次。事件订阅一定要成对解绑,否则会内存泄漏并拿到已销毁对象。
4. GameObject:场景中的基本单位
GameObject 本身只是一个容器,功能全部来自挂载的组件。
4.1 查找对象
| 方法 | 查找范围 | 性能 | 建议 |
|---|---|---|---|
GameObject.Find("Name") |
全场景激活对象,按名字 | 差,O(n) | 只在初始化时用一次 |
GameObject.FindWithTag("Tag") |
全场景按标签 | 中 | 标签需在 Tag Manager 预定义 |
GameObject.FindGameObjectsWithTag("Tag") |
返回全部同标签对象 | 中,每次分配数组 | 初始化时用,勿每帧调用 |
transform.Find("Child/Sub") |
仅直接子节点的相对路径 | 好 | 层级内查找首选 |
FindAnyObjectByType<T>() |
任意一个 T 类型对象 | 较好,找到即停 | Unity 2023.1+ 推荐 |
FindFirstObjectByType<T>() |
第一个 T 类型对象(有序) | 中 | 需要确定性结果时用 |
FindObjectsByType<T>(FindObjectsSortMode.None) |
全部 T 类型对象 | 较好 | 替代已弃用的 FindObjectsOfType |
csharp
// 已弃用(Unity 2023.1 起)
// var gm = FindObjectOfType<GameManager>();
// 推荐写法
var gm = FindFirstObjectByType<GameManager>();
为什么旧方法被弃用? 因为
FindObjectOfType在返回前会按InstanceID排序,这个排序占了绝大部分耗时(部分测试中超过 90%),而绝大多数场景根本不需要这个顺序。
更好的做法 :能拖引用就拖引用,能单例就单例,能事件就事件,不要用字符串查找作为主要手段。
csharp
public class UIManager : MonoBehaviour
{
// 首选:Inspector 拖引用,零运行时查找开销
[SerializeField] private GameObject pausePanel;
public static UIManager Instance { get; private set; }
private void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.Escape))
pausePanel.SetActive(!pausePanel.activeSelf);
}
}
4.2 激活状态
csharp
gameObject.SetActive(false); // 关闭自身及所有子物体
bool self = gameObject.activeSelf; // 只看自己
bool inHierarchy = gameObject.activeInHierarchy; // 自己 + 所有父级都激活才为 true
注意点
- 父物体关闭后,子物体
activeSelf仍可能为true,但activeInHierarchy为false。判断"实际是否显示"要用后者。 - 对象被
SetActive(false)后,其上的Update、协程都会停止(详见第 16 节)。 - 频繁
SetActive会触发大量 OnEnable/OnDisable 与 Canvas 重建;纯 UI 显隐建议改用CanvasGroup.alpha,或只关闭Renderer。
4.3 动态增删组件
csharp
var rb = gameObject.AddComponent<Rigidbody>(); // 同类型组件重复添加会抛异常
Destroy(GetComponent<BoxCollider>()); // 删除组件
需要保证组件存在时,用特性声明依赖,避免运行时才发现问题:
csharp
[RequireComponent(typeof(Rigidbody))]
[DisallowMultipleComponent]
public class PhysicsObject : MonoBehaviour { }
4.4 标签比较
csharp
// 差:== 会分配字符串并做完整比较
if (other.gameObject.tag == "Player") { }
// 好:CompareTag 只比较标签索引,零 GC
if (other.CompareTag("Player")) { }
5. MonoBehaviour 基类
继承 MonoBehaviour 才具备"被引擎驱动"的能力:生命周期回调、协程、Inspector 序列化。
5.1 常用内置成员
| 成员 | 说明 |
|---|---|
gameObject |
当前脚本所在的对象 |
transform |
快捷访问 gameObject.transform |
enabled |
是否启用该脚本(不影响对象上的其他组件) |
name |
对象名(组件名即对象名) |
Invoke / InvokeRepeating / CancelInvoke |
延时调用 |
StartCoroutine / StopCoroutine / StopAllCoroutines |
协程 |
print() |
等价于 Debug.Log,可读性一般,不推荐 |
5.2 Inspector 暴露字段的正确姿势
csharp
public class Weapon : MonoBehaviour
{
// 不推荐:public 字段对全工程可见,容易被外部随意修改
// public float damage = 10f;
[Header("伤害参数")]
[SerializeField, Tooltip("单次伤害值"), Range(1f, 100f)]
private float damage = 10f;
[SerializeField, HideInInspector] private int debugId;
public float Damage => damage; // 需要外部读取时再开放只读属性
}
6. Component:组件查询与性能
组件查询是新手代码里最大的性能黑洞 ------因为随手就写在 Update 里了。
6.1 查询方法族
| 方法 | 作用范围 | 说明 |
|---|---|---|
GetComponent<T>() |
自身 | 最常用,找不到返回 null |
TryGetComponent<T>(out T comp) |
自身 | 无 GC 分配,2019.2+ 推荐 |
GetComponentInChildren<T>() |
自身 + 子物体(深度优先) | 包含自身 |
GetComponentInParent<T>() |
自身 + 父物体 | 包含自身 |
GetComponents<T>() |
自身(可多个) | 返回数组,必有分配 |
GetComponentsInChildren<T>() |
自身 + 全部子物体 | 层级深时有开销 |
GetComponentsInChildren<T>(true) |
同上,含未激活对象 | 默认不含未激活 |
6.2 示例
csharp
// 差:空引用风险,且每帧都在查找
void Update()
{
GetComponent<Rigidbody>().AddForce(Vector3.up);
}
// 好:Awake 缓存一次,并用 TryGetComponent 保证安全
private Rigidbody rb;
private void Awake()
{
if (!TryGetComponent(out rb))
{
Debug.LogError($"{name} 缺少 Rigidbody,脚本已禁用", this);
enabled = false;
}
}
private void FixedUpdate() => rb.AddForce(Vector3.up, ForceMode.Acceleration);
6.3 性能排行(从好到差)
- Inspector 拖引用 / 单例 / 事件 ------ 无运行时开销,首选
TryGetComponent(一次)并缓存 ------ 几乎无开销transform.Find、GetComponentInChildren------ 层级内可控GetComponentsInChildren------ 递归 + 数组分配GameObject.Find/FindObjectsByType------ 全场景遍历,永远不要放在Update里
经验法则:任何
GetComponent、Find、Camera.main、GetComponents出现的位置,只允许在Awake/Start里。
7. Transform:位置、旋转、缩放
Transform 是每个 GameObject 必带的组件,也是所有空间逻辑的入口。
7.1 属性一览
| 属性 | 坐标系 | 类型 | 说明 |
|---|---|---|---|
position |
世界 | Vector3 |
世界坐标位置 |
rotation |
世界 | Quaternion |
世界旋转(四元数) |
eulerAngles |
世界 | Vector3 |
世界欧拉角,读写有换算开销 |
localPosition |
父级局部 | Vector3 |
相对父物体的位置 |
localRotation |
父级局部 | Quaternion |
相对父物体的旋转 |
localScale |
父级局部 | Vector3 |
相对父物体的缩放 |
lossyScale |
世界 | Vector3 |
世界缩放,只读 |
forward / right / up |
世界 | Vector3 |
自身朝向单位向量 |
childCount / parent |
--- | --- | 层级信息 |
csharp
// 世界坐标与局部坐标的相互转换,做 UI/挂点计算时非常有用
Vector3 worldPos = transform.TransformPoint(localPoint);
Vector3 localPos = transform.InverseTransformPoint(worldPos);
7.2 移动:Translate
Translate 默认使用 Space.Self(自身坐标系),这是最容易搞错的一点。
csharp
// 沿自身前方移动(默认 Space.Self)
transform.Translate(Vector3.forward * speed * Time.deltaTime);
// 沿世界前方移动
transform.Translate(Vector3.forward * speed * Time.deltaTime, Space.World);
坑 :Translate 会直接修改 position / localPosition。如果对象带 Rigidbody 且非 Kinematic,不要用 Translate 移动------你会绕过物理求解,导致穿模、抖动、碰撞丢失。正确做法见第 18 节。
7.3 旋转:Rotate / RotateAround / LookAt
csharp
// 绕自身 Y 轴旋转
transform.Rotate(Vector3.up * rotateSpeed * Time.deltaTime);
// 绕世界 Y 轴旋转
transform.Rotate(Vector3.up * rotateSpeed * Time.deltaTime, Space.World);
// 绕某个中心点公转(行星绕恒星、敌人环绕玩家)
transform.RotateAround(target.position, Vector3.up, orbitSpeed * Time.deltaTime);
// 让 Z 轴朝向目标(炮塔、追踪)
transform.LookAt(target);
// 平滑朝向目标,避免瞬间转头
Quaternion targetRot = Quaternion.LookRotation(target.position - transform.position);
transform.rotation = Quaternion.Slerp(transform.rotation, targetRot, turnSpeed * Time.deltaTime);
坑
- 旋转角正负遵循右手螺旋定则:拇指指向轴的正方向,其余四指方向为正角。
- 四元数不能直接改
x/y/z,那四个分量互相耦合。要么整体赋值Quaternion.Euler(...),要么用Quaternion.Slerp/LookRotation组合。 - 欧拉角存在万向节死锁(Gimbal Lock),连续旋转建议始终使用四元数。
7.4 缩放
csharp
transform.localScale = new Vector3(2f, 1f, 1f);
// 运行时渐变放大(特效常用)
private void Update()
{
transform.localScale += Vector3.one * (growthSpeed * Time.deltaTime);
}
坑 :lossyScale 由所有父级的 localScale 累乘得到,因此无法直接设置 。若需要世界缩放固定为某值,必须反向推算 localScale。
8. Vector2 / Vector3:一切空间计算的基础
Vector2 用于 2D 与 UI 坐标,Vector3 用于 3D;两者 API 几乎对称。
8.1 运算与常用静态方法
| 成员 | 说明 | 典型用途 |
|---|---|---|
magnitude |
向量长度,含开方 | 需要真实距离时 |
sqrMagnitude |
长度平方,无开方 | 距离比较,性能更优 |
normalized |
单位向量 | 只要方向不要长度 |
Vector3.Distance(a, b) |
两点距离 | 判断是否进入范围 |
Vector3.Dot(a, b) |
点积 | 判断朝向关系(前 / 后 / 垂直) |
Vector3.Cross(a, b) |
叉积 | 求法线、判断左右 |
Vector3.Lerp / Slerp |
线性 / 球面插值 | 平滑移动、转向 |
Vector3.MoveTowards |
匀速靠近 | 有上限的追踪 |
Vector3.ClampMagnitude |
限制长度 | 限制移动速度上限 |
Vector3.Angle(a, b) |
夹角(度) | 视野判断、攻击角度 |
8.2 关键优化:用 sqrMagnitude 代替 Distance
csharp
// 有开方,开销大
if (Vector3.Distance(a, b) < detectRange) { }
// 无开方,等价的性能友好写法
if ((a - b).sqrMagnitude < detectRange * detectRange) { }
8.3 点积的实战意义
csharp
Vector3 toTarget = (target.position - transform.position).normalized;
if (Vector3.Dot(transform.forward, toTarget) > 0.7f)
Debug.Log("目标在正前方约 45 度扇形内"); // 0.7 ≈ cos45°
用点积做视野扇形判断,比 Angle 更便宜(无三角函数)。
8.4 坑
- 零向量归一化会返回零向量 ,不报错但要小心后续逻辑出现除零或方向为
(0,0,0)的异常:
csharp
if (dir.sqrMagnitude > 0.0001f) { dir = dir.normalized; }
Vector2与Vector3之间存在隐式转换 ,但Vector2 → Vector3会把 z 置 0,容易在混用 2D/3D API 时埋雷。- 斜向输入记得
ClampMagnitude,否则对角线速度是直线的 √2 倍。
9. Input:用户输入接口
重要前提 :
Input属于旧版 Input Manager 。若项目只启用了新 Input System(Project Settings → Player → Active Input Handling 设为 Input System Package (New) ),Input与OnMouseXXX系列全部失效 。需要兼容时请改为 Both,或统一迁移到新输入系统。
9.1 键盘
csharp
if (Input.GetKey(KeyCode.Space)) { } // 按住期间持续为 true
if (Input.GetKeyDown(KeyCode.Space)) { } // 仅按下那一帧
if (Input.GetKeyUp(KeyCode.Space)) { } // 仅松开那一帧
9.2 鼠标
csharp
if (Input.GetMouseButtonDown(0)) { } // 0 左键 / 1 右键 / 2 中键
if (Input.GetMouseButton(1)) { }
if (Input.GetMouseButtonUp(2)) { }
Vector3 screenPos = Input.mousePosition; // z 恒为 0
Vector3 worldPos = Camera.main.ScreenToWorldPoint(new Vector3(screenPos.x, screenPos.y, 10f));
Camera.main在 2020.2+ 内部做了缓存,但每帧调用仍有开销 ,请在Awake中缓存Camera引用。
9.3 轴输入(推荐用于移动)
csharp
float smooth = Input.GetAxis("Horizontal"); // -1 ~ 1,带平滑,适合角色移动
float raw = Input.GetAxisRaw("Horizontal"); // -1 / 0 / 1,无平滑,适合 UI 导航
9.4 触摸
csharp
if (Input.touchCount > 0)
{
Touch touch = Input.GetTouch(0);
if (touch.phase == TouchPhase.Began) { /* ... */ }
}
9.5 移动端与跨平台注意事项
- 移动平台应完全避免依赖
Input.GetKey,改用虚拟摇杆或Input.touchCount。 OnMouseXXX在移动端不可用。- 新输入系统下要读取手柄/多设备,建议直接使用 Input Actions 资源而非轮询。
10. 消息系统:SendMessage / BroadcastMessage / SendMessageUpwards
csharp
gameObject.SendMessage("TakeDamage", 10); // 自身所有脚本
gameObject.BroadcastMessage("OnEnemySpawn"); // 自身 + 所有子物体
gameObject.SendMessageUpwards("OnChildEvent"); // 自身 + 所有父物体
10.1 三者的差异
| 方法 | 传播方向 | 适用场景 |
|---|---|---|
SendMessage |
仅自身挂载的脚本 | 同一对象内组件通信 |
BroadcastMessage |
自身 → 所有子孙 | 通知整棵子树(如全体敌人进入战斗) |
SendMessageUpwards |
自身 → 所有祖先 | 子物体向父级汇报 |
10.2 坑(这也是它被诟病的原因)
- 基于反射 + 字符串匹配 ,性能比直接调用差一个数量级,且方法名写错不会有任何提示,静默失败。
- 不支持返回值,参数最多一个。
- 传播顺序依赖脚本挂载顺序与层级顺序,不可控。
- 递归查找子物体/父物体,层级深时开销明显。
10.3 替代方案
| 方案 | 性能 | 类型安全 | 适用 |
|---|---|---|---|
| 直接引用调用 | 最高 | 强 | 关系明确的组件 |
UnityEvent(Inspector 可视化) |
高 | 中 | 策划配置驱动的交互 |
C# event / Action |
高 | 强 | 全局广播、系统解耦 |
| ScriptableObject 事件通道 | 高 | 强 | 跨场景、模块化架构 |
SendMessage 系列 |
低 | 无 | 仅用于极简原型 |
csharp
// 更现代的事件写法
public static class GameEvents
{
public static event System.Action<int> OnPlayerDamaged;
public static void RaisePlayerDamaged(int amount) => OnPlayerDamaged?.Invoke(amount);
}
11. Animator:动画状态机
Animator 的核心是动画状态机:多个状态 + 过渡条件 + 参数。
11.1 参数驱动状态切换
csharp
public class PlayerAnimatorController : MonoBehaviour
{
private static readonly int SpeedHash = Animator.StringToHash("Speed");
private static readonly int IsGroundedHash = Animator.StringToHash("IsGrounded");
private static readonly int AttackHash = Animator.StringToHash("Attack");
private Animator animator;
private void Awake() => animator = GetComponent<Animator>();
private void Update()
{
float speed = Input.GetAxisRaw("Horizontal");
// 用 Hash 代替字符串,省去每次的字符串哈希计算
animator.SetFloat(SpeedHash, Mathf.Abs(speed), 0.1f, Time.deltaTime);
animator.SetBool(IsGroundedHash, true);
if (Input.GetKeyDown(KeyCode.J))
animator.SetTrigger(AttackHash);
}
}
11.2 直接播放与过渡
csharp
animator.Play("ExplosionAnimation"); // 立即切换,会打断当前过渡
animator.CrossFade("Run", 0.15f); // 带混合时长的平滑过渡,更自然
animator.Play("Attack", 0, 0f); // 第三参数为 normalizedTime,可重播
11.3 动画事件
在 Animation 窗口的关键帧上添加事件,动画播放到该帧时回调:
csharp
// 挂在带 Animator 的物体上,名字必须与动画事件中的函数名一致
private void OnAttackHitFrame()
{
// 触发伤害判定,保证"打到人的那一刻"与表现一致
HitDetector.Detect(attackPoint.position, attackRadius);
}
11.4 注意事项
- 状态机复杂度要克制,超过 10 个状态的单层机建议分层(Layer)+ 子状态机(Sub-State Machine)。
- 参数名统一管理,用
Animator.StringToHash缓存,避免字符串散落各处。 SetTrigger会自行复位,SetBool不会;忘记复位是"动画卡在攻击状态"的常见原因。- Animator 的
Update Mode可设为Animate Physics,与物理同步。
12. Time:抹平帧率差异的关键
| 属性 | 含义 | 受 timeScale 影响 |
|---|---|---|
Time.time |
游戏开始至今的缩放时间 | 是(暂停时不变) |
Time.deltaTime |
上一帧到当前帧的耗时 | 是 |
Time.unscaledDeltaTime |
真实帧间隔 | 否 |
Time.fixedDeltaTime |
物理固定步长(默认 0.02) | 是 |
Time.timeScale |
时间缩放系数(默认 1) | --- |
Time.realtimeSinceStartup |
真实运行时间 | 否 |
Time.frameCount |
累计帧数 | 否 |
12.1 为什么一切都要乘 deltaTime
csharp
// 错:帧率越高移动越快
transform.Translate(Vector3.forward * 5f);
// 对:每秒移动 5 个单位,与帧率无关
transform.Translate(Vector3.forward * (5f * Time.deltaTime));
12.2 暂停的正确姿势
csharp
public class PauseManager : MonoBehaviour
{
[SerializeField] private GameObject pauseMenu;
private bool isPaused;
private void Update()
{
if (!Input.GetKeyDown(KeyCode.Escape)) return;
isPaused = !isPaused;
Time.timeScale = isPaused ? 0f : 1f;
pauseMenu.SetActive(isPaused);
}
}
Time.timeScale = 0 的影响范围,必须清楚:
| 会停 | 不会停 |
|---|---|
FixedUpdate |
Update / LateUpdate |
WaitForSeconds 协程 |
yield return null 协程 |
Invoke / InvokeRepeating 计时 |
WaitForSecondsRealtime、WaitForEndOfFrame |
依赖 deltaTime 的自定义计时 |
基于帧数的自定义计时 |
| --- | AudioSource 播放、UI EventSystem 点击 |
由此推出两个高频 Bug:
- 暂停菜单弹出后,背景 UI 按钮依然可点击 ------ 因为 EventSystem 走的是 Update。
- 用
WaitForSeconds做的技能冷却,在暂停时不会走。如果希望"暂停也计时",改用WaitForSecondsRealtime。
13. Mathf:常用数学函数
13.1 取整三兄弟
csharp
Mathf.Floor(3.8f); // 3 向下取整(返回 float)
Mathf.Ceil(3.2f); // 4 向上取整(返回 float)
Mathf.Round(3.8f); // 4 四舍六入五取偶!
Mathf.RoundToInt(3.5f); // 4
必须知道的坑:Mathf.Round 采用"银行家舍入"(round half to even) ,遇到 .5 时返回偶数:
csharp
Debug.Log(Mathf.Round(10.5f)); // 10
Debug.Log(Mathf.Round(11.5f)); // 12
Debug.Log(Mathf.Round(-10.5f)); // -10
如果你的业务需要"传统四舍五入",要自己写:
csharp
int RoundHalfUp(float v) => Mathf.FloorToInt(v + 0.5f);
13.2 限制与插值
csharp
Mathf.Clamp(hp, 0f, maxHp); // 夹紧到区间
Mathf.Clamp01(t); // 夹紧到 0~1
Mathf.Lerp(0f, 100f, 0.5f); // 50,线性插值
Mathf.InverseLerp(0f, 100f, 25f); // 0.25,反向求比例
Mathf.MoveTowards(cur, target, speed * Time.deltaTime); // 匀速逼近
Mathf.SmoothStep(0f, 1f, t); // 带缓入缓出
Lerp 的经典误用 :Lerp(a, b, 0.1f) 每帧调用并不会"匀速接近",而是距离越近越慢的指数逼近 。想要严格匀速请用 MoveTowards,想要与帧率无关请用 1 - Mathf.Exp(-k * Time.deltaTime) 或直接把 k*deltaTime 作为 t。
13.3 三角函数与角度
csharp
float rad = 30f * Mathf.Deg2Rad; // 度 → 弧度
float deg = 0.5f * Mathf.Rad2Deg; // 弧度 → 度
Mathf.Sin(rad); Mathf.Cos(rad); Mathf.Tan(rad); // 参数必须是弧度
Mathf.Atan2(dy, dx) * Mathf.Rad2Deg; // 由方向求角度,比 Atan 更稳
正弦波的典型用法(漂浮、摆动、呼吸缩放):
csharp
// 物体上下漂浮
float y = baseY + Mathf.Sin(Time.time * frequency) * amplitude;
transform.position = new Vector3(transform.position.x, y, transform.position.z);
13.4 其他
csharp
Mathf.Abs(-5f); // 5
Mathf.Min(5f, 10f); // 5
Mathf.Max(5f, 10f); // 10
Mathf.Pow(2f, 3f); // 8 幂运算
Mathf.Sqrt(16f); // 4 平方根,参数必须非负
Mathf.Sign(-3f); // -1
Mathf.Approximately(a, b); // 浮点近似比较,替代 ==
Mathf.Repeat(t, 1f); // 循环取值,做循环背景
Mathf.PingPong(t, 1f); // 往返取值,做往复运动
浮点数不要用
==比较 ,用Mathf.Approximately或自行设置容差Mathf.Abs(a - b) < 1e-5f。
14. Random:随机数
14.1 两个重载的边界语义不同(高频错误点)
| 重载 | 返回范围 | 是否包含最大值 |
|---|---|---|
Random.Range(int min, int max) |
[min, max) |
不包含 max |
Random.Range(float min, float max) |
[min, max] |
包含 max |
csharp
int level = Random.Range(5, 11); // 5~10,想取到 10 必须写 11
float f = Random.Range(0f, 1f); // 0.0 ~ 1.0,可以取到 1.0
很多资料把浮点版也写成"不包含最大值",这是错的,会导致边界概率计算出现偏差。
14.2 常用随机值
csharp
Random.value; // 0.0~1.0 的随机浮点
Random.rotation; // 随机四元数旋转
Random.rotationUniform; // 均匀分布的随机旋转(更好的各向同性)
Random.insideUnitCircle; // 单位圆内随机 Vector2
Random.onUnitSphere; // 单位球面上随机 Vector3(均匀)
Random.insideUnitSphere; // 单位球内随机 Vector3
// 按权重抽取(掉落系统常用)
float r = Random.value;
string item = r < 0.7f ? "普通" : r < 0.95f ? "稀有" : "传说";
14.3 随机种子与可复现
csharp
Random.InitState(12345); // 全局种子,相同种子产生相同序列
用途 :关卡生成、回放系统、自动化测试。注意 :Random 是全局状态,任何模块调用都会影响序列;联机同步场景应使用独立的 System.Random 实例或种子化的确定性随机。
14.4 坑
- 大量调用
Random.Range会累积开销,能预先算好就不要放进Update。 - 需要用
System.Random做与 Unity 无关的确定性随机时,不要与UnityEngine.Random混用。
15. OnMouseXXX:鼠标回调事件
| 回调 | 触发时机 |
|---|---|
OnMouseEnter() |
鼠标进入碰撞体范围 |
OnMouseExit() |
鼠标离开碰撞体范围 |
OnMouseOver() |
鼠标停留在碰撞体上,每帧调用 |
OnMouseDown() |
在碰撞体上按下鼠标左键 |
OnMouseUp() |
按下的左键在碰撞体上释放 |
OnMouseDrag() |
按住左键在碰撞体上拖动,每帧调用 |
15.1 悬停高亮示例(含材质泄漏修复)
csharp
[RequireComponent(typeof(Collider))]
public class HoverHighlight : MonoBehaviour
{
[SerializeField] private Color hoverColor = Color.grey;
private Renderer rend;
private Material mat; // 实例化后的材质副本
private Color originalColor;
private void Awake()
{
rend = GetComponent<Renderer>();
mat = rend.material; // 访问 .material 会自动实例化,避免污染共享材质
originalColor = mat.color;
}
private void OnMouseEnter() => mat.color = hoverColor;
private void OnMouseExit() => mat.color = originalColor;
}
常见坑 :直接改
rend.sharedMaterial.color会修改所有使用该材质的物体;而反复访问rend.material会不断产生材质实例,造成泄漏。正确做法是Awake里取一次并缓存。
15.2 必须记住的四个前提
- 对象必须有 Collider,否则回调完全不触发。
- 对象不能在
Ignore Raycast层(Layer 2),该层会被鼠标事件的射线忽略。 - 前方不能有 Collider 遮挡,被挡住的对象收不到事件。
- 项目必须启用旧版 Input Manager (
Active Input Handling为 Input Manager (Old) 或 Both),否则回调失效------这是新输入系统项目里"鼠标悬停没反应"的头号原因。
15.3 性能说明
OnMouseOver / OnMouseDrag 是每帧回调 ,不要在里面做 Find、GetComponent 等重操作。另外 Unity 会为鼠标事件做射线检测,场景中 Collider 数量极多时也会带来开销。
16. 协程 Coroutine:分帧与延时执行
协程不是线程:它仍然在主线程执行,只是能被"切成分帧的片段"。因此协程内做耗时同步计算,照样卡帧。
16.1 基本结构
csharp
private IEnumerator Countdown(int from)
{
for (int i = from; i > 0; i--)
{
Debug.Log(i);
yield return new WaitForSeconds(1f); // 每秒一次,切回主线程
}
Debug.Log("开始!");
}
private void Start()
{
StartCoroutine(Countdown(3));
}
16.2 各种 yield 指令的语义
| yield 指令 | 恢复时机 | 受 timeScale 影响 |
|---|---|---|
yield return null |
下一帧 Update 之后 | 否 |
yield return new WaitForSeconds(t) |
t 秒后 | 是 |
yield return new WaitForSecondsRealtime(t) |
真实 t 秒后 | 否 |
yield return new WaitForFixedUpdate() |
下一个物理步之后 | 是 |
yield return new WaitForEndOfFrame() |
渲染完成后 | 否(不能用于打包后截图之外的帧内循环) |
yield return StartCoroutine(other) |
等待子协程结束 | --- |
yield break |
直接结束协程 | --- |
yield return new WaitUntil(() => flag) |
条件为真时 | 否 |
yield return new WaitWhile(() => flag) |
条件为假时 | 否 |
16.3 启动与停止
csharp
// 保存引用,才能精确停止
private Coroutine routine;
private void Start() => routine = StartCoroutine(Loop());
private void Stop()
{
if (routine != null) { StopCoroutine(routine); routine = null; }
}
停止协程的三种写法必须参数类型一致,否则停不掉:
csharp
StartCoroutine("Loop"); // 字符串启动 → StopCoroutine("Loop")
StartCoroutine(Loop()); // IEnumerator → StopCoroutine(routine)
16.4 协程的生命周期(重点,容易答错)
| 操作 | 协程是否停止 |
|---|---|
gameObject.SetActive(false) |
停止,且重新激活后不会恢复 |
monoBehaviour.enabled = false |
不停止,继续运行 |
Destroy(gameObject) |
停止 |
Time.timeScale = 0 |
不停止,但 WaitForSeconds 不再推进 |
还有个细节:SetActive(false) 之后,协程会先执行到下一个 yield 才真正停下 ,所以 yield 前的代码还是会跑完。
另外,对未激活对象调用 StartCoroutine 会直接报错:
Coroutine couldn't be started because the game object 'X' is inactive!
16.5 实战用法
csharp
// 1) 带取消的倒计时
private IEnumerator Cooldown(float cd, System.Action onReady)
{
float t = cd;
while (t > 0f)
{
t -= Time.deltaTime;
cooldownUI.fillAmount = t / cd;
yield return null; // 逐帧更新 UI
}
onReady?.Invoke();
}
// 2) 分帧处理大量数据,避免一次性卡顿
private IEnumerator SpawnWave(int count)
{
for (int i = 0; i < count; i++)
{
Instantiate(enemyPrefab, RandomPos(), Quaternion.identity);
if (i % 10 == 0) yield return null; // 每 10 个让出一帧
}
}
16.6 Unity 6 的替代方案:Awaitable / async-await
Unity 6 引入了 Awaitable,可以在 MonoBehaviour 中直接用 async/await 而不必依赖 UniTask 等第三方库:
csharp
private async Awaitable LoadAsync()
{
await Awaitable.WaitForSecondsAsync(1f); // 受 timeScale 影响
await Awaitable.NextFrameAsync(); // 等一帧
// 也可以用 Awaitable.EndOfFrameAsync() / Awaitable.FixedUpdateAsync()
Debug.Log("加载完成");
}
协程 vs async/await 怎么选?
| 维度 | Coroutine | async/await + Awaitable |
|---|---|---|
| 返回值 | 不支持 | 支持 await 结果 |
| 异常处理 | 无法 try/catch 跨 yield | 可用 try/catch |
| 耦合 | 必须挂在 MonoBehaviour 上 | 可写在纯 C# 类中 |
| 取消 | 手动 StopCoroutine | CancellationToken |
| 生态 | 资料最多,最稳 | Unity 6+,逐步成为主流 |
新项目建议:简单定时逻辑用协程,涉及网络 / 资源 / 需要返回值和异常处理的,用 async/await。
16.7 性能提示
- 每启动一个协程都会产生一次 GC 分配(编译器生成的迭代器对象),不要在高频循环里疯狂 StartCoroutine。
- 几乎每帧运行的协程,直接用
Update更划算。 - 嵌套协程会叠加内存开销,能合并就合并。
- 复用等待对象:
new WaitForSeconds(1f)缓存成静态字段,可显著减少 GC。
csharp
private static readonly WaitForSeconds OneSec = new WaitForSeconds(1f);
// ...
yield return OneSec;
17. Invoke:不写协程的延时调用
csharp
Invoke(nameof(Explode), 2f); // 2 秒后调用一次
InvokeRepeating(nameof(Tick), 1f, 0.5f); // 1 秒后开始,每 0.5 秒调用一次
CancelInvoke(); // 取消本脚本上全部待调用
CancelInvoke(nameof(Tick)); // 取消指定方法
17.1 特性与坑
- 基于字符串 + 反射查找方法 ,写错方法名不会报错,只是静默不执行。务必用
nameof()而非硬编码字符串。 - 不支持参数,需要传参请改用协程或 lambda 包装的状态变量。
- 受
Time.timeScale影响:暂停时 Invoke 计时停止,恢复后继续。 - 对象被
Destroy时自动取消;但对象仅被SetActive(false)时不会取消,重新激活后会继续计时。 - 建议在
OnDisable/OnDestroy里主动CancelInvoke(),避免复活后莫名其妙的回调。
17.2 与协程的取舍
| 需求 | 推荐 |
|---|---|
| 单次简单延时、无需参数 | Invoke |
| 重复定时、逻辑简单 | InvokeRepeating |
| 需要参数、需要中途等待/条件 | 协程 |
| 需要返回值、异常处理 | async/await |
注意:
Invoke(nameof(F), 0f)这类零延时 调用几乎会立即执行,暂停状态下也会触发------想彻底暂停请避免使用Invoke。
18. Rigidbody:物理驱动
18.1 Unity 6 的 API 变更(复制旧教程必踩)
| Unity 5 / 2022 及以前 | Unity 6(6000.x) |
|---|---|
rb.velocity |
rb.linearVelocity |
rb.drag |
rb.linearDamping |
rb.angularDrag |
rb.angularDamping |
rb.angularVelocity |
不变 |
Rigidbody.velocity在 Unity 6 中已被重命名,目的是与angularVelocity保持命名对称。旧写法会产生编译警告(部分版本直接报错),网络上大量 2023 年以前的教程代码在这里都会失效。
18.2 两种移动方式,按需求选
csharp
[RequireComponent(typeof(Rigidbody))]
public class PlayerMover : MonoBehaviour
{
[SerializeField] private float moveSpeed = 6f;
[SerializeField] private float jumpForce = 7f;
[SerializeField] private float maxSpeed = 8f;
private Rigidbody rb;
private Vector3 input;
private bool grounded;
private void Awake() => rb = GetComponent<Rigidbody>();
private void Update()
{
float h = Input.GetAxisRaw("Horizontal");
float v = Input.GetAxisRaw("Vertical");
input = Vector3.ClampMagnitude(new Vector3(h, 0f, v), 1f);
if (Input.GetKeyDown(KeyCode.Space) && grounded)
rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
}
private void FixedUpdate()
{
// 方式一:直接设速度 ------ 手感立刻响应,适合动作游戏
Vector3 flat = new Vector3(input.x * moveSpeed, rb.linearVelocity.y, input.z * moveSpeed);
if (flat.sqrMagnitude > maxSpeed * maxSpeed)
flat = flat.normalized * maxSpeed;
rb.linearVelocity = flat;
// 方式二:施加力 ------ 更有惯性和"重量感",适合拟真类(二选一)
// rb.AddForce(input * accel, ForceMode.Acceleration);
}
private void OnCollisionStay(Collision c) => grounded = true;
private void OnCollisionExit(Collision c) => grounded = false;
}
18.3 ForceMode 四种模式
| 模式 | 连续性 | 是否考虑质量 | 典型场景 |
|---|---|---|---|
Force |
持续 | 是 | 引擎推力、持续加速 |
Acceleration |
持续 | 否 | 与质量无关的恒定加速度 |
Impulse |
瞬时 | 是 | 跳跃、被击退、爆炸 |
VelocityChange |
瞬时 | 否 | 击退距离与质量无关时 |
记忆口诀 :持续推动用 Force,一次性冲击用 Impulse。
18.4 碰撞与触发器
| 对比项 | 碰撞 OnCollisionXXX |
触发器 OnTriggerXXX |
|---|---|---|
| Collider 的 Is Trigger | 不勾选 | 勾选 |
| 是否产生物理阻挡 | 是 | 否,直接穿过 |
| 参数类型 | Collision |
Collider |
| 回调 | Enter / Stay / Exit | Enter / Stay / Exit |
| 触发条件 | 双方都有 Collider,至少一方有非 Kinematic Rigidbody | 同上 |
csharp
private void OnCollisionEnter(Collision collision)
{
// 用相对速度判断撞击力度,做伤害/音效
float impact = collision.relativeVelocity.magnitude;
if (impact > 5f) AudioManager.PlayHit(impact);
Debug.Log($"撞到 {collision.gameObject.name},接触点 {collision.contactCount} 个");
}
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Pickup")) Destroy(other.gameObject);
}
18.5 物理调试的三个高频问题
| 现象 | 原因 | 解决 |
|---|---|---|
| 高速子弹穿墙 | 物理按离散步进计算,两帧之间越过了墙体 | 把 Rigidbody 的 Collision Detection 改为 Continuous / ContinuousDynamic |
| 移动物体带相机拍摄时抖动 | 物理步进频率低于渲染帧率 | Rigidbody 的 Interpolate 设为 Interpolate,或相机跟随放 LateUpdate |
| 角色被碰撞撞倒 | 胶囊体容易被撞翻 | Constraints → Freeze Rotation 勾选 X、Z |
18.6 铁律
- 物理相关一律放
FixedUpdate。 - 不要直接改带 Rigidbody 物体的
transform.position,会瞬移并破坏碰撞求解;需要位移用MovePosition(Kinematic 体质)或改linearVelocity。 - 静态场景物体不要挂 Rigidbody,用 Collider 即可。
- 数量多的小物体考虑 ECS/Jobs 或关闭不必要的插值。
19. 对象的创建与销毁
csharp
// 创建
var go = Instantiate(prefab, position, rotation);
var go2 = Instantiate(prefab, parent); // 指定父物体
var comp = Instantiate(componentRef, parent); // 也可以直接实例化组件
// 销毁
Destroy(go); // 帧末销毁
Destroy(go, 3f); // 3 秒后销毁(常用于特效、音效)
DestroyImmediate(go); // 立即销毁,仅限编辑器代码
DontDestroyOnLoad(go); // 跨场景保留
19.1 坑
Destroy是延迟到帧末 执行的,调用后当帧内对象依然存在,== null判断要小心:
csharp
Destroy(go);
if (go == null) { } // Unity 重载了 == ,这里为 true;但 go.transform 仍可访问
- 频繁
Instantiate/Destroy(子弹、粒子)会产生大量 GC,必须用对象池。 - 跨场景对象的重复创建是最常见的"游戏越玩越卡"原因。
19.2 极简对象池
csharp
public class SimplePool
{
private readonly Queue<GameObject> pool = new();
private readonly GameObject prefab;
public SimplePool(GameObject prefab, int prewarm = 10)
{
this.prefab = prefab;
for (int i = 0; i < prewarm; i++)
{
var go = Object.Instantiate(prefab);
go.SetActive(false);
pool.Enqueue(go);
}
}
public GameObject Get(Vector3 pos, Quaternion rot)
{
var go = pool.Count > 0 ? pool.Dequeue() : Object.Instantiate(prefab);
go.transform.SetPositionAndRotation(pos, rot);
go.SetActive(true);
return go;
}
public void Release(GameObject go)
{
go.SetActive(false);
pool.Enqueue(go);
}
}
Unity 2021+ 内置了
UnityEngine.Pool.ObjectPool<T>,也可以用官方版本替代手写。
20. 性能与 GC 避坑清单(可直接用于 Code Review)
| # | 反模式 | 正确做法 |
|---|---|---|
| 1 | Update 里 GetComponent / Find / Camera.main |
在 Awake 缓存为字段 |
| 2 | tag == "Player" |
CompareTag("Player") |
| 3 | 用 Vector3.Distance 只做范围比较 |
用 sqrMagnitude 省去开方 |
| 4 | 每帧字符串拼接日志 | 用 $"" 插值 + [Conditional] 日志门面 |
| 5 | 频繁 Instantiate / Destroy |
对象池 |
| 6 | UI 显隐反复 SetActive |
用 CanvasGroup.alpha / interactable |
| 7 | GetComponents / FindObjectsByType 每帧调用 |
初始化时查一次并缓存 |
| 8 | new WaitForSeconds(1f) 写在循环里 |
缓存为静态只读字段 |
| 9 | 空 Update() 遍布全场景脚本 |
移除空生命周期函数,用事件驱动 |
| 10 | 修改 sharedMaterial 或反复取 .material |
缓存材质实例,或明确用 sharedMaterial |
| 11 | 协程几乎每帧运行 | 直接改用 Update |
| 12 | 正式包保留上百条 Debug.Log |
编译期剥离 |
收益最高的三条 :缓存引用、用 sqrMagnitude、对象池。这三条能解决大部分中低端机的帧率问题。
21. 常见面试题速答
Q1:Awake 和 Start 有什么区别?
Awake 在对象实例化时调用,即使脚本 enabled = false 也会执行;Start 在首次 Update 前调用,且晚于场景中所有对象的 Awake。因此 Awake 做自身初始化,Start 做依赖外部对象的初始化。
Q2:Update 和 FixedUpdate 怎么分工?
Update 频率随帧率变化,用于输入与非物理逻辑;FixedUpdate 以固定步长执行,用于刚体受力等物理运算。输入应在 Update 中读取,在 FixedUpdate 中消费。
Q3:为什么移动要乘 Time.deltaTime?
让位移与时间成正比而非与帧数成正比,从而在不同帧率下速度一致。
Q4:协程和线程有什么区别?
协程运行在主线程,只是可被分帧暂停/恢复,不能利用多核,耗时同步计算照样卡帧;线程是真正并行,但不能直接调用 Unity API。
Q5:SetActive(false) 会停止协程吗?
会。而且重新激活后不会自动恢复。相反,仅设置 monoBehaviour.enabled = false 不会停止协程。
Q6:GetComponent 和 Find 的性能差距?
Find 需要遍历整个场景,GetComponent 只需查当前对象(含类型索引),两者都应在初始化阶段调用一次并缓存。TryGetComponent 相比 GetComponent 在"找不到"时不会产生 GC。
Q7:Random.Range(0, 10) 能取到 10 吗?
不能,整数版是 [min, max)。而浮点版 Random.Range(0f, 1f) 可以取到 1.0。
Q8:Unity 6 里 rb.velocity 为什么编译不过?
已重命名为 linearVelocity,同理 drag → linearDamping、angularDrag → angularDamping。
Q9:为什么我的 OnMouseDown 没反应?
四个检查点:是否有 Collider、是否在 Ignore Raycast 层、是否被其他 Collider 遮挡、项目是否只启用了新输入系统。
Q10:暂停游戏后 UI 按钮还能点,为什么?
Time.timeScale = 0 不会停止 Update,而 UGUI 的 EventSystem 依赖帧更新,因此需要在暂停时禁用 EventSystem 或对应的 GraphicRaycaster。
22. 结语
Unity 的 API 数量很多,但真正决定项目质量的不是"记得多少方法",而是"知道什么时候不该用"。回顾一下这篇文章里最重要的四个判断:
- 时间点决定一切 :
Awake拿引用、Start连外部、Update读输入、FixedUpdate推物理、LateUpdate跟相机。 - 引用一律缓存 :
GetComponent、Find、Camera.main只允许出现在初始化阶段。 - 帧率无关 :涉及时间的量一律乘
Time.deltaTime,暂停时牢记哪些会停、哪些不会停。 - 别和物理引擎抢方向盘 :带 Rigidbody 的对象用物理 API 移动,不要直接改
transform.position。
后续可以继续深入的方向:对象池与性能分析(Profiler / Memory Profiler)、新 Input System、Addressables 资源管理、以及 Unity 6 的 Awaitable 异步体系。
如果这篇帮你少踩了一个坑,欢迎点赞 + 收藏,评论区补充你遇到过的"奇葩 Bug",我会持续更新这份清单。
更新日志
| 日期 | 内容 |
|---|---|
| 2026-09-18 | 首版:覆盖 22 个主题,新增 Unity 6 API 变更说明、性能避坑清单、面试速答;修正 Random.Range 边界语义、Mathf.Round 舍入规则、协程停止条件等常见错误表述 |
参考
- Unity 官方文档:Script Execution Order、Coroutines、Rigidbody、Mathf.Round
- Unity 6 Release Notes(
Rigidbody.velocity重命名说明) - Unity Discussions / Stack Overflow 相关议题