Appearance
1 分钟快答
Awake 和 OnEnable 谁先?
标准答案
同一个脚本实例里,通常是 Awake 先于 OnEnable。
最常见顺序是:
c
Awake -> OnEnable -> Start -> Update但要注意一个细节:Awake 是“脚本实例被加载后的初始化”,OnEnable 是“对象激活并且组件启用时的回调”。所以如果脚本组件一开始是禁用的,可能只调用 Awake,等你把组件 enabled = true 时才调用 OnEnable。
底层理解
Awake 一生只调用一次,适合做自身初始化,比如缓存组件、初始化字段。
OnEnable 可以调用很多次,比如对象池取出对象、SetActive(true)、组件重新启用时都会触发,所以适合注册事件、开启监听、刷新状态。
如果 GameObject 一开始就是 inactive,通常不会立刻进入生命周期,等 SetActive(true) 后才会触发 Awake -> OnEnable。
代码示例
c
using UnityEngine; // 引入 Unity 的基础 API。
public class LifeCycleOrderDemo : MonoBehaviour // 定义一个用于观察生命周期顺序的脚本。
{ // 类开始。
private void Awake() // Awake 在脚本实例初始化时调用一次。
{ // Awake 方法开始。
Debug.Log("1. Awake:先初始化自身数据"); // 输出 Awake 的调用时机。
} // Awake 方法结束。
private void OnEnable() // OnEnable 在对象激活并且组件启用时调用。
{ // OnEnable 方法开始。
Debug.Log("2. OnEnable:对象启用,可以注册事件"); // 输出 OnEnable 的调用时机。
} // OnEnable 方法结束。
private void Start() // Start 在第一次 Update 前调用。
{ // Start 方法开始。
Debug.Log("3. Start:比 Awake 和 OnEnable 更晚"); // 输出 Start 的调用时机。
} // Start 方法结束。
private void OnDisable() // OnDisable 在对象禁用或组件禁用时调用。
{ // OnDisable 方法开始。
Debug.Log("4. OnDisable:这里适合取消事件订阅"); // 输出 OnDisable 的调用时机。
} // OnDisable 方法结束。
} // 类结束。面试说法
我会这样答:默认情况下 Awake 比 OnEnable 先执行。Awake 做一次性初始化,OnEnable 做每次启用时的逻辑,比如事件订阅。真正要注意的是,如果组件 disabled,Awake 可能已经执行但 OnEnable 不执行;如果 GameObject inactive,生命周期会延后到激活时才开始。
Start 什么时候执行?
标准答案
Start 会在脚本第一次启用后、第一次 Update 之前执行一次。
常见顺序是:
c
Awake -> OnEnable -> Start -> Update所以面试里可以直接说:Start 比 Awake 和 OnEnable 晚,它不是对象创建瞬间执行,而是在对象真正启用后,进入第一帧更新前执行。
底层原理
Awake 更像“对象实例初始化”,OnEnable 是“启用回调”,Start 是“首次启用后的首帧初始化”。
要注意几个坑:
Start一个脚本实例只调用一次。OnEnable可以反复调用。- 脚本组件一开始
enabled = false,可能Awake已经执行,但Start会等到组件启用后才执行。 GameObject一开始 inactive,生命周期通常会整体延后到SetActive(true)。- 对象池对象第一次取出可能会走
Start,后面反复取出一般只走OnEnable。
代码示例
c
using UnityEngine; // 引入 Unity 引擎 API。
public class StartTimingDemo : MonoBehaviour // 定义一个生命周期测试脚本。
{ // 类开始。
private void Awake() // Awake 会比 Start 更早执行。
{ // Awake 方法开始。
Debug.Log("1. Awake:初始化自身字段和组件引用"); // 输出 Awake 的执行顺序。
} // Awake 方法结束。
private void OnEnable() // OnEnable 会在对象和组件启用时执行。
{ // OnEnable 方法开始。
Debug.Log("2. OnEnable:启用时调用,可多次触发"); // 输出 OnEnable 的执行顺序。
} // OnEnable 方法结束。
private void Start() // Start 会在第一次 Update 之前执行一次。
{ // Start 方法开始。
Debug.Log("3. Start:第一次 Update 前执行,只执行一次"); // 输出 Start 的执行顺序。
} // Start 方法结束。
private void Update() // Update 会在 Start 之后每帧执行。
{ // Update 方法开始。
Debug.Log("4. Update:Start 之后才开始每帧逻辑"); // 输出 Update 的执行顺序。
} // Update 方法结束。
} // 类结束。项目里怎么用
我一般把“自身组件缓存”放 Awake,比如 GetComponent;把“事件订阅、对象池取出刷新”放 OnEnable;把“依赖别的对象已经 Awake 后的初始化”放 Start。
但如果是很重要的系统初始化,比如资源管理器、网络管理器、战斗管理器,不建议靠多个脚本的 Start 顺序硬凑,最好做一个明确的 Init() 流程或启动器。
面试总结
一句话背法:Start 在脚本第一次启用后、第一次 Update 前执行一次;它晚于 Awake 和 OnEnable,适合做首帧前初始化,但不要用它隐式保证复杂模块顺序。
FixedUpdate 默认多久一次?
标准答案
FixedUpdate 默认每 0.02 秒执行一次,也就是理论上每秒 50 次。
在 Unity 里这个值叫:
c
Time.fixedDeltaTime默认值通常是:
c
0.02f可以在 Project Settings -> Time -> Fixed Timestep 里修改。
底层原理
FixedUpdate 不是“每帧执行一次”,它是按照固定时间步长驱动的,主要服务于物理系统。
比如默认 fixedDeltaTime = 0.02f:
- 第
0.00s可能跑一次物理。 - 第
0.02s再跑一次。 - 第
0.04s再跑一次。 - 所以一秒大约跑
50次。
但渲染帧率是不稳定的:
- 如果游戏跑
60 FPS,Update大概每0.0167s一次。 - 如果
FixedUpdate是0.02s一次,它就不可能和每个Update一一对应。 - 如果某一帧特别卡,Unity 可能在下一帧里连续补跑多次
FixedUpdate。 - 如果渲染帧特别快,也可能某一帧没有
FixedUpdate。
代码示例
c
using UnityEngine; // 引入 Unity 引擎 API。
public class FixedUpdateDemo : MonoBehaviour // 定义一个演示 FixedUpdate 的脚本。
{ // 类开始。
private Rigidbody _rigidbody; // 保存 Rigidbody 组件引用。
private void Awake() // Awake 在对象初始化时调用。
{ // Awake 方法开始。
_rigidbody = GetComponent<Rigidbody>(); // 获取当前物体身上的 Rigidbody。
} // Awake 方法结束。
private void Start() // Start 在第一次 Update 前调用。
{ // Start 方法开始。
Debug.Log(Time.fixedDeltaTime); // 输出当前 FixedUpdate 的固定时间步长。
} // Start 方法结束。
private void Update() // Update 按渲染帧调用。
{ // Update 方法开始。
float horizontal = Input.GetAxisRaw("Horizontal"); // 在 Update 里读取玩家输入。
} // Update 方法结束。
private void FixedUpdate() // FixedUpdate 按固定物理时间步调用。
{ // FixedUpdate 方法开始。
Vector3 force = Vector3.forward * 10f; // 创建一个向前的力。
_rigidbody.AddForce(force); // 在 FixedUpdate 里对 Rigidbody 施加物理力。
} // FixedUpdate 方法结束。
} // 类结束。Unity 项目里怎么用
一般规则是:
Update:读输入、普通逻辑、UI、动画参数。FixedUpdate:Rigidbody移动、AddForce、物理检测、和物理强相关的逻辑。LateUpdate:相机跟随、依赖前面更新结果的表现逻辑。
面试里可以补一句:FixedUpdate 的步长越小,物理越精细,但 CPU 压力也越大;低端机上如果物理对象多,过高的物理频率会让 Physics.Simulate 占用变高。
deltaTime 为什么不能用于物理力?
标准答案
deltaTime 不适合直接用于物理力,核心原因是:deltaTime 属于渲染帧时间,而 Unity 物理系统是按固定物理步长 fixedDeltaTime 推进的。
尤其是这句常见错误:
c
rb.AddForce(force * Time.deltaTime);问题在于:AddForce 本身就是“施加力”,物理引擎会在固定物理步里帮你做积分。如果你再手动乘 deltaTime,就容易把力缩小,甚至让效果跟帧率波动绑定。
底层原理
物理力不是直接移动距离,而是影响加速度:
c
F = m * a然后物理引擎内部大概会做:
c
velocity += acceleration * fixedDeltaTime
position += velocity * fixedDeltaTime所以你调用:
c
rb.AddForce(force);Unity 物理系统会在 FixedUpdate 的固定步长里处理它。
如果你写成:
c
rb.AddForce(force * Time.deltaTime);就相当于提前把力乘了一次时间,物理系统后面又按固定步长积分一次,语义就乱了。
正确用法
c
using UnityEngine; // 引入 Unity 引擎 API。
public class PhysicsForceDemo : MonoBehaviour // 定义一个物理移动演示脚本。
{ // 类开始。
private Rigidbody _rb; // 保存 Rigidbody 组件引用。
private Vector3 _moveInput; // 保存从 Update 读取到的输入方向。
public float force = 30f; // 暴露一个力的大小给 Inspector 调整。
public float moveSpeed = 6f; // 暴露一个手动移动速度给 Inspector 调整。
private void Awake() // Awake 用来初始化自身依赖。
{ // Awake 方法开始。
_rb = GetComponent<Rigidbody>(); // 获取当前物体身上的 Rigidbody。
} // Awake 方法结束。
private void Update() // Update 跟随渲染帧,适合读取输入。
{ // Update 方法开始。
float x = Input.GetAxisRaw("Horizontal"); // 读取左右输入。
float z = Input.GetAxisRaw("Vertical"); // 读取前后输入。
_moveInput = new Vector3(x, 0f, z).normalized; // 把输入保存下来,等 FixedUpdate 使用。
} // Update 方法结束。
private void FixedUpdate() // FixedUpdate 跟随固定物理步,适合操作 Rigidbody。
{ // FixedUpdate 方法开始。
Vector3 forceDir = _moveInput * force; // 计算要施加的力,不乘 deltaTime。
_rb.AddForce(forceDir, ForceMode.Force); // 正确:交给物理引擎按 fixedDeltaTime 积分。
Vector3 target = _rb.position + _moveInput * moveSpeed * Time.fixedDeltaTime; // 如果手动算位移,才乘 fixedDeltaTime。
_rb.MovePosition(target); // 用 MovePosition 移动 Rigidbody 到目标位置。
} // FixedUpdate 方法结束。
} // 类结束。面试补充
严格说,在 FixedUpdate 里 Time.deltaTime 通常会表现为固定步长,但我面试时会强调语义:物理逻辑用 FixedUpdate 和 fixedDeltaTime,普通渲染帧逻辑用 Update 和 deltaTime。
deltaTime 适合:
- 普通
Transform位移 - UI 动画
- 非物理插值
- 冷却计时
fixedDeltaTime 适合:
Rigidbody相关逻辑- 手动计算物理位移
- 物理检测节奏
MovePosition这种物理移动目标计算
协程是不是多线程?
标准答案
协程不是多线程。
Unity 协程是在主线程上分帧执行的“协作式流程”,本质是 IEnumerator 状态机。它不会自动创建新线程,也不会让代码跑到另一个 CPU 核上。
所以这句话要背牢:
c
Coroutine != Thread协程可以暂停、等待、下一帧继续,但它仍然在 Unity 主线程执行。
底层原理
C# 里写:
c
yield return null;编译后大概会变成一个状态机对象。Unity 调用 StartCoroutine() 后,会保存这个 IEnumerator,然后在合适的时机调用它的 MoveNext()。
流程大概是:
StartCoroutine -> MoveNext -> 执行到 yield -> 暂停 -> 等条件满足 -> 再次 MoveNext所以协程更像“可以暂停的函数”,不是“后台线程”。
如果你在协程里写一个很重的循环,它照样会卡主线程:
c
using System.Collections; // 引入 IEnumerator 所在命名空间。
using UnityEngine; // 引入 Unity 引擎 API。
public class CoroutineThreadDemo : MonoBehaviour // 定义一个协程演示脚本。
{ // 类开始。
private void Start() // Start 在第一次 Update 前执行。
{ // Start 方法开始。
StartCoroutine(WaitAndPrint()); // 启动一个协程,但没有创建新线程。
} // Start 方法结束。
private IEnumerator WaitAndPrint() // 定义一个协程方法,返回 IEnumerator。
{ // 协程方法开始。
Debug.Log("第一帧执行,仍然在主线程"); // 这行代码在 Unity 主线程执行。
yield return null; // 暂停到下一帧继续执行。
Debug.Log("下一帧继续执行,还是主线程"); // 下一帧恢复后仍然在 Unity 主线程执行。
yield return new WaitForSeconds(1f); // 等待 1 秒后继续执行。
Debug.Log("等待结束后继续执行,依旧不是新线程"); // 等待结束后继续在主线程执行。
} // 协程方法结束。
} // 类结束。为什么协程里大计算也会卡
c
using System.Collections; // 引入 IEnumerator 所在命名空间。
using UnityEngine; // 引入 Unity 引擎 API。
public class BadCoroutineDemo : MonoBehaviour // 定义一个错误使用协程的例子。
{ // 类开始。
private IEnumerator HeavyWork() // 定义一个看似异步的协程。
{ // 协程方法开始。
for (int i = 0; i < 100000000; i++) // 执行一个非常大的循环。
{ // 循环体开始。
float value = Mathf.Sqrt(i); // 做一次计算,这仍然发生在主线程。
} // 循环体结束。
yield return null; // 只有循环结束后,才会把控制权还给 Unity。
} // 协程方法结束。
} // 类结束。这段会卡,因为 yield return null 在循环后面,前面的大循环必须一次性跑完。
Unity 项目里怎么用
协程适合:
- 等待几秒后执行逻辑。
- 等待动画播放结束。
- 等待
AsyncOperation加载完成。 - 把少量任务分帧执行。
- 做流程控制,比如新手引导、技能前摇、UI 打开动画。
协程不适合:
- 大量寻路计算。
- 大量压缩解压。
- 大量 JSON 解析。
- CPU 密集型算法。
- 真正需要后台并行的工作。
这些更适合 Thread、Task、Job System,但后台线程不能直接操作大部分 Unity API,比如 Transform、GameObject、Instantiate 等,结果要派发回主线程处理。
面试总结
我会这样说:协程不是线程,它是 Unity 主线程上的 IEnumerator 调度机制。yield 只是把执行权暂时交还给引擎,等下一帧或等待条件满足后继续执行。它能让流程写起来像异步,但不能让耗时计算自动并行。
yield return null 等几帧?
标准答案
yield return null 通常就是“等 1 帧”。
更准确地说:当前协程执行到 yield return null 后会暂停,Unity 会在下一帧再恢复这个协程,从 yield return null 后面的代码继续执行。
它不是等待 0 秒,也不是等待固定的 0.02 秒,而是等到下一帧。
底层原理
协程本质是 IEnumerator 状态机。执行到:
c
yield return null;时,协程会把当前执行位置保存下来,然后把控制权还给 Unity 主循环。下一帧 Unity 再调用这个协程的 MoveNext(),于是继续往下执行。
所以流程是:
c
当前帧执行 -> 遇到 yield return null -> 暂停 -> 下一帧 MoveNext -> 继续执行代码示例
c
using System.Collections; // 引入 IEnumerator,用来写协程。
using UnityEngine; // 引入 Unity 引擎 API。
public class YieldNullDemo : MonoBehaviour // 定义一个演示 yield return null 的脚本。
{ // 类开始。
private void Start() // Start 在第一次 Update 前执行。
{ // Start 方法开始。
StartCoroutine(TestYieldNull()); // 启动协程。
} // Start 方法结束。
private IEnumerator TestYieldNull() // 定义一个协程方法。
{ // 协程方法开始。
Debug.Log("A 当前帧:" + Time.frameCount); // 当前帧立刻输出 A。
yield return null; // 暂停协程,等到下一帧继续。
Debug.Log("B 下一帧:" + Time.frameCount); // 下一帧恢复后输出 B。
yield return null; // 再暂停一次,再等下一帧。
Debug.Log("C 再下一帧:" + Time.frameCount); // 再下一帧恢复后输出 C。
} // 协程方法结束。
} // 类结束。如果你想等 N 帧,可以这样写:
c
using System.Collections; // 引入 IEnumerator。
using UnityEngine; // 引入 Unity API。
public class WaitFrameDemo : MonoBehaviour // 定义一个等待帧数的脚本。
{ // 类开始。
private IEnumerator WaitFrames(int frameCount) // 定义一个等待指定帧数的协程。
{ // 协程方法开始。
for (int i = 0; i < frameCount; i++) // 循环指定次数。
{ // 循环体开始。
yield return null; // 每次 yield return null 等一帧。
} // 循环体结束。
Debug.Log("等待帧数结束"); // 等完指定帧数后继续执行。
} // 协程方法结束。
} // 类结束。面试补充
yield return null 和 WaitForSeconds 不一样:
yield return null:等下一帧。yield return new WaitForSeconds(1f):等游戏时间 1 秒,受Time.timeScale影响。yield return new WaitForSecondsRealtime(1f):等真实时间 1 秒,不受Time.timeScale影响。
如果 Time.timeScale = 0,游戏逻辑时间停止了,但只要画面还在刷新,yield return null 这种“等下一帧”的协程通常仍然可以继续。
面试总结
一句话背法:yield return null 是让协程暂停到下一帧继续执行,通常可以理解为等 1 帧,但这个“一帧”的真实时间取决于当前帧率,不是固定秒数。
List 是链表吗?
标准答案
List<T> 不是链表。
C# 里的 List<T> 底层是动态数组,也就是内部维护了一个 T[] 数组。真正的链表是 LinkedList<T>。
所以面试可以直接说:
List<T> 是动态数组,不是链表;它支持按下标 O(1) 访问,但中间插入删除通常是 O(n)。
底层原理
List<T> 内部大概有几个核心字段:
c
using System; // 引入基础命名空间。
using System.Collections.Generic; // 引入 List<T> 所在命名空间。
public class ListDemo // 定义一个演示 List 的类。
{ // 类开始。
public void Test() // 定义一个测试方法。
{ // 方法开始。
List<int> list = new List<int>(4); // 创建一个初始容量为 4 的动态数组。
list.Add(10); // 往 List 尾部添加元素 10。
list.Add(20); // 往 List 尾部添加元素 20。
list.Add(30); // 往 List 尾部添加元素 30。
int value = list[1]; // 通过下标直接访问元素,时间复杂度是 O(1)。
list.Insert(1, 99); // 在中间插入元素,后面的元素需要整体后移。
list.RemoveAt(2); // 删除中间元素,后面的元素需要整体前移。
} // 方法结束。
} // 类结束。List<T> 的特点:
list[i]很快,因为数组可以通过下标直接算地址。Add到尾部通常很快,平均是O(1)。- 容量不够时会扩容,申请更大的数组,再把旧元素复制过去。
- 中间插入和删除慢,因为要移动后面的元素。
Count是当前元素数量。Capacity是底层数组容量。
和链表的区别
真正链表是这样:
c
using System.Collections.Generic; // 引入 LinkedList<T> 所在命名空间。
public class LinkedListDemo // 定义一个链表演示类。
{ // 类开始。
public void Test() // 定义一个测试方法。
{ // 方法开始。
LinkedList<int> list = new LinkedList<int>(); // 创建一个真正的双向链表。
LinkedListNode<int> first = list.AddLast(10); // 添加第一个节点,并保存节点引用。
LinkedListNode<int> second = list.AddLast(20); // 添加第二个节点,并保存节点引用。
list.AddAfter(first, 15); // 在已知节点后插入新节点,插入本身很快。
list.Remove(second); // 删除已知节点,删除本身也很快。
} // 方法结束。
} // 类结束。LinkedList<T> 的节点通常分散在堆上,每个节点保存:
- 当前值
Value - 前一个节点
Previous - 后一个节点
Next
它的插入删除在“已经拿到节点引用”的情况下很快,但随机访问不快,因为它不能像数组一样 list[i] 直接跳到第 i 个,只能遍历。
Unity 项目里怎么选
大多数情况下,Unity 项目里更常用 List<T>,因为:
- 遍历快,缓存友好。
- 适合存怪物列表、子弹列表、UI Item 列表。
- 可以通过预设
Capacity减少扩容和 GC。 - 随机访问方便。
例如对象池里常这样做:
c
using System.Collections.Generic; // 引入 List<T>。
using UnityEngine; // 引入 Unity API。
public class BulletPool : MonoBehaviour // 定义一个简单子弹池。
{ // 类开始。
private readonly List<GameObject> _bullets = new List<GameObject>(128); // 预设容量,减少运行时扩容。
public void AddBullet(GameObject bullet) // 添加一个子弹对象到列表。
{ // 方法开始。
_bullets.Add(bullet); // 尾部追加,平均时间复杂度是 O(1)。
} // 方法结束。
} // 类结束。面试总结
一句话背法:List<T> 不是链表,它底层是动态数组;优点是随机访问快、遍历快、尾部追加快,缺点是扩容要拷贝,中间插入删除要移动元素。真正链表是 LinkedList<T>。
Dictionary 是有序的吗?
标准答案
Dictionary<TKey, TValue> 不应该认为是有序的。
面试里最稳的说法是:Dictionary 是哈希表,它的目标是通过 key 快速查找 value,不是维护插入顺序或排序顺序。即使某些 .NET 版本里遍历结果“看起来像插入顺序”,也不要把它当成业务保证。
底层原理
Dictionary 底层大概由两部分组成:
buckets:桶数组,根据key.GetHashCode()定位。entries:实际存储 key、value、hash、next 的数组。
查找过程大概是:
c
key -> hashCode -> bucket -> entry -> Equals 比较 -> 找到 value所以它关心的是“怎么快速找到 key”,不是“怎么按顺序遍历”。
代码示例
c
using System; // 引入 Console 所在命名空间。
using System.Collections.Generic; // 引入 Dictionary 所在命名空间。
public class DictionaryOrderDemo // 定义一个演示 Dictionary 顺序的类。
{ // 类开始。
public void PrintItems() // 定义一个打印 Dictionary 的方法。
{ // 方法开始。
Dictionary<int, string> dict = new Dictionary<int, string>(); // 创建一个 Dictionary。
dict.Add(3, "three"); // 添加 key 为 3 的元素。
dict.Add(1, "one"); // 添加 key 为 1 的元素。
dict.Add(2, "two"); // 添加 key 为 2 的元素。
foreach (KeyValuePair<int, string> pair in dict) // 遍历 Dictionary,但不要依赖遍历顺序。
{ // 循环体开始。
Console.WriteLine(pair.Key + " = " + pair.Value); // 输出当前键值对。
} // 循环体结束。
} // 方法结束。
} // 类结束。这段代码可能某次输出看起来是 3, 1, 2,也可能在不同运行时、不同版本、删除再添加、扩容后表现不同。重点是:业务逻辑不能依赖这个顺序。
需要顺序怎么办
如果你要按 key 排序,可以用:
c
using System; // 引入 Console。
using System.Collections.Generic; // 引入 SortedDictionary。
public class SortedDictionaryDemo // 定义一个排序字典演示类。
{ // 类开始。
public void PrintSortedItems() // 定义一个按 key 排序输出的方法。
{ // 方法开始。
SortedDictionary<int, string> dict = new SortedDictionary<int, string>(); // 创建一个按 key 排序的字典。
dict.Add(3, "three"); // 添加 key 为 3 的元素。
dict.Add(1, "one"); // 添加 key 为 1 的元素。
dict.Add(2, "two"); // 添加 key 为 2 的元素。
foreach (KeyValuePair<int, string> pair in dict) // 遍历时会按 key 的排序规则输出。
{ // 循环体开始。
Console.WriteLine(pair.Key + " = " + pair.Value); // 输出排序后的键值对。
} // 循环体结束。
} // 方法结束。
} // 类结束。如果你要保持插入顺序,常见做法是 Dictionary + List:
c
using System; // 引入 Console。
using System.Collections.Generic; // 引入 Dictionary 和 List。
public class OrderedByInsertDemo // 定义一个插入顺序演示类。
{ // 类开始。
private readonly Dictionary<int, string> _dict = new Dictionary<int, string>(); // 用 Dictionary 做快速查找。
private readonly List<int> _keys = new List<int>(); // 用 List 单独记录插入顺序。
public void Add(int key, string value) // 添加一个键值对。
{ // 方法开始。
if (_dict.ContainsKey(key)) return; // 如果 key 已存在,就避免重复插入顺序列表。
_dict.Add(key, value); // 把 key 和 value 放入字典。
_keys.Add(key); // 把 key 按插入顺序记录到列表。
} // 方法结束。
public void PrintByInsertOrder() // 按插入顺序打印。
{ // 方法开始。
foreach (int key in _keys) // 遍历插入顺序列表。
{ // 循环体开始。
Console.WriteLine(key + " = " + _dict[key]); // 通过 key 到字典中快速取 value。
} // 循环体结束。
} // 方法结束。
} // 类结束。Unity 项目里怎么用
在 Unity 里,Dictionary 很适合做:
- 配置表
id -> config - 资源缓存
path -> handle - 对象索引
entityId -> entity - Buff 查询
buffId -> buffInstance
但如果是 UI 展示顺序,比如背包格子、任务列表、排行榜、技能栏,就不要直接遍历 Dictionary。应该单独用 List 保存顺序,或者每次展示前排序。
面试总结
一句话背法:Dictionary 不是有序集合,它是哈希表;遍历顺序来自内部实现,不应该作为业务逻辑依赖。需要顺序就显式用 List 维护插入顺序,或者用 SortedDictionary / 排序后的 keys。
HashSet 能重复吗?
标准答案
HashSet<T> 不能存重复元素。
它的核心作用就是“去重”。如果你往 HashSet 里添加一个已经存在的元素,Add 不会再次插入,并且会返回 false。
底层原理
HashSet<T> 底层可以理解成“只有 key、没有 value 的哈希表”。
添加元素时大概会做:
元素 -> GetHashCode -> 找桶 bucket -> 在桶里用 Equals 比较 -> 存在就拒绝,不存在就插入注意:哈希值相同不一定代表重复,因为可能只是哈希冲突。真正判断重复,还要看 Equals 是否为 true。
代码示例
c
using System; // 引入 Console 所在命名空间。
using System.Collections.Generic; // 引入 HashSet 所在命名空间。
public class HashSetDemo // 定义一个 HashSet 演示类。
{ // 类开始。
public void Test() // 定义一个测试方法。
{ // 方法开始。
HashSet<int> set = new HashSet<int>(); // 创建一个 int 类型的 HashSet。
bool first = set.Add(10); // 第一次添加 10,会成功,返回 true。
bool second = set.Add(10); // 第二次添加 10,会失败,返回 false。
bool third = set.Add(20); // 添加 20,会成功,返回 true。
Console.WriteLine(first); // 输出 true。
Console.WriteLine(second); // 输出 false。
Console.WriteLine(third); // 输出 true。
Console.WriteLine(set.Count); // 输出 2,因为 10 只会保存一份。
} // 方法结束。
} // 类结束。自定义类型的坑
如果 HashSet 里放的是自定义类型,要正确重写 Equals 和 GetHashCode,否则你以为重复的对象,HashSet 可能认为它们不重复。
c
using System; // 引入基础类型。
using System.Collections.Generic; // 引入 HashSet。
public class PlayerId // 定义一个玩家 ID 类型。
{ // 类开始。
public int Id; // 保存玩家唯一 ID。
public override bool Equals(object obj) // 重写 Equals,用来判断两个对象是否相等。
{ // Equals 方法开始。
PlayerId other = obj as PlayerId; // 尝试把 obj 转成 PlayerId。
if (other == null) return false; // 如果转换失败,说明不是同类对象。
return Id == other.Id; // 如果 ID 相同,就认为是同一个玩家。
} // Equals 方法结束。
public override int GetHashCode() // 重写 GetHashCode,用来提供哈希值。
{ // GetHashCode 方法开始。
return Id.GetHashCode(); // 用 Id 的哈希值作为当前对象的哈希值。
} // GetHashCode 方法结束。
} // 类结束。
public class PlayerSetDemo // 定义一个玩家集合演示类。
{ // 类开始。
public void Test() // 定义测试方法。
{ // 方法开始。
HashSet<PlayerId> players = new HashSet<PlayerId>(); // 创建玩家 HashSet。
players.Add(new PlayerId { Id = 1001 }); // 添加 ID 为 1001 的玩家。
bool added = players.Add(new PlayerId { Id = 1001 }); // 再添加相同 ID 的玩家,会被认为重复。
Console.WriteLine(added); // 输出 false。
} // 方法结束。
} // 类结束。Unity 项目里怎么用
HashSet 很适合做:
- 技能范围内目标去重。
- BFS / A* 里的
visited集合。 - 已加载资源路径去重。
- 已触发过的新手引导步骤记录。
- 防止同一事件重复处理。
但如果你要统计重复次数,比如“每个怪物被命中了几次”,HashSet 就不合适,应该用:
c
Dictionary<int, int>也就是:
目标 ID -> 命中次数面试总结
一句话背法:HashSet<T> 不能重复,Add 重复元素会返回 false;重复的判断依赖 GetHashCode 和 Equals,哈希冲突不等于重复。它适合去重和快速判断是否存在,但不适合保存顺序或统计重复次数。
struct 默认在栈上吗?
标准答案
struct 默认在栈上,这句话不严谨,甚至容易答错。
准确说法是:struct 是值类型,但值类型不等于一定在栈上。它存在哪里,取决于这个值被谁持有。
底层原理
值类型和引用类型,描述的是“语义”:
struct是值类型,变量通常直接表示数据本身。class是引用类型,变量通常保存对象引用。- 栈和堆是运行时的内存存储位置。
所以不能简单说:struct 一定在栈上,class 一定在堆上。
几种常见情况:
- 方法里的局部
struct,可能在栈上,也可能被 JIT 优化到寄存器。 class里的struct字段,会内嵌在堆对象里面。struct[]数组里的元素,会内嵌在数组对象里面,而数组对象在堆上。List<struct>底层是数组,所以元素也在数组里。struct被转成object或接口时,会发生装箱,复制到堆上的 boxed object。- 闭包、协程、
async里捕获的局部变量,可能被提升到编译器生成的状态机对象里,而状态机对象通常在堆上。
代码示例
c
using UnityEngine; // 引入 Unity API。
public struct MyPoint // 定义一个 struct,它是值类型。
{ // struct 开始。
public int X; // 定义 X 坐标字段。
public int Y; // 定义 Y 坐标字段。
} // struct 结束。
public class Holder // 定义一个 class,它是引用类型。
{ // class 开始。
public MyPoint Point; // struct 字段会内嵌在 Holder 对象内部。
} // class 结束。
public class StructMemoryDemo : MonoBehaviour // 定义一个演示脚本。
{ // 类开始。
private void Start() // Start 在第一次 Update 前执行。
{ // Start 方法开始。
MyPoint localPoint = new MyPoint(); // 局部 struct 可能在栈上,也可能被优化到寄存器。
localPoint.X = 1; // 修改局部 struct 的 X 字段。
localPoint.Y = 2; // 修改局部 struct 的 Y 字段。
Holder holder = new Holder(); // 创建 class 对象,Holder 对象在托管堆上。
holder.Point = localPoint; // struct 值会复制进 Holder 对象内部。
MyPoint[] points = new MyPoint[3]; // 创建 struct 数组,数组对象在托管堆上。
points[0] = localPoint; // struct 元素按值存放在数组对象内部。
object boxed = localPoint; // 发生装箱,把 struct 复制到堆上的 object 盒子里。
Debug.Log(boxed); // 输出 boxed 对象,注意这里已经不是原来的局部值本身。
} // Start 方法结束。
} // 类结束。Unity 项目里怎么理解
Unity 里很多常见类型都是 struct:
Vector3QuaternionColorBoundsRaycastHit
它们是值类型,意味着赋值、传参、属性返回时经常会复制。
例如:
c
using UnityEngine; // 引入 Unity API。
public class VectorCopyDemo : MonoBehaviour // 定义一个演示 Vector3 复制语义的脚本。
{ // 类开始。
private void Start() // Start 方法。
{ // 方法开始。
Vector3 a = transform.position; // 读取 position,会得到一个 Vector3 值副本。
a.x = 10f; // 修改副本的 x,不会自动改回 Transform。
transform.position = a; // 把修改后的副本重新赋值回去,Transform 才会变化。
} // 方法结束。
} // 类结束。所以面试里可以补一句:struct 的重点不是“栈还是堆”,而是“值语义和复制语义”。在 Unity 里,要注意大 struct 的频繁复制,也要注意装箱导致 GC Alloc。
常见坑
不要这样背:
struct 在栈上,class 在堆上更好的背法是:
struct 是值类型,通常内嵌在它所在的存储位置;这个位置可能是栈、堆对象、数组对象,也可能因为装箱变成堆上的对象。string 可变吗?
标准答案
string 不可变。
但要注意:string 是引用类型,不是值类型。它“看起来像能改”,其实是因为变量引用指向了新的字符串对象,原来的字符串对象没有被原地修改。
比如:
c
string s = "abc";
s += "d";执行后不是把 "abc" 改成 "abcd",而是创建了一个新的 "abcd",然后让 s 指向新对象。
底层原理
string 在 C# 里是引用类型,本质是 System.String。但是字符串对象创建后,内容不能被修改。
这样设计有几个好处:
- 方便共享,比如字符串驻留池可以让相同字面量共享对象。
- 更安全,比如文件路径、网络地址、配置 key 不会被别人悄悄改掉。
- 更适合多线程共享,因为内容不会变。
- 适合做
Dictionary的 key,因为哈希值和内容稳定。 - 简化运行时和编译器优化。
代码示例
c
using System; // 引入 Console 所在命名空间。
public class StringImmutableDemo // 定义一个字符串不可变演示类。
{ // 类开始。
public void Test() // 定义测试方法。
{ // 方法开始。
string a = "Hello"; // 创建字符串对象 Hello,并让 a 引用它。
string b = a; // 让 b 也引用同一个字符串对象。
a += " World"; // 创建新的字符串 Hello World,并让 a 指向新对象。
Console.WriteLine(a); // 输出 Hello World。
Console.WriteLine(b); // 输出 Hello,因为原来的字符串没有被修改。
} // 方法结束。
} // 类结束。频繁拼接为什么有 GC 风险
c
using System.Text; // 引入 StringBuilder。
using UnityEngine; // 引入 Unity API。
public class StringBuilderDemo : MonoBehaviour // 定义一个字符串拼接演示脚本。
{ // 类开始。
private readonly StringBuilder _builder = new StringBuilder(128); // 创建 StringBuilder,并预设容量减少扩容。
private void Update() // Update 每帧执行。
{ // Update 方法开始。
_builder.Clear(); // 清空已有内容,复用内部缓冲区。
_builder.Append("HP: "); // 追加固定文本。
_builder.Append(100); // 追加血量数字。
string text = _builder.ToString(); // 最终生成需要显示的字符串。
Debug.Log(text); // 输出字符串,实际项目里频繁日志也要谨慎。
} // Update 方法结束。
} // 类结束。如果在 Update 里大量 + 拼接字符串,会不断产生临时字符串对象,Unity 中可能表现为 GC Alloc。这种场景更适合用 StringBuilder、缓存字符串,或者降低刷新频率。
面试总结
一句话背法:string 是引用类型,但不可变;所谓修改字符串,本质是创建新字符串并改变变量引用。不可变带来共享、安全、线程友好和哈希稳定,但高频拼接会产生临时对象,所以 Unity 里要注意 GC。
const 和 readonly 谁运行时赋值?
标准答案
readonly 是运行时赋值,const 是编译期常量。
更准确地说:
const:必须在声明时赋值,而且值必须在编译期就能确定。readonly:可以在字段声明处赋值,也可以在构造函数里赋值。static readonly:可以在字段声明处赋值,也可以在静态构造函数里赋值。
底层原理
const 会被编译器直接替换成字面量。
比如:
c
public const int MaxHp = 100;其他地方使用 MaxHp,编译后很可能直接变成 100。所以 const 没有什么“运行时赋值”的过程。
readonly 是字段,它真的存在于对象或类型里,只是 C# 语法限制它只能在声明处或构造函数里赋值。构造完成后,就不能再给这个字段重新赋值了。
代码示例
c
using System; // 引入 Guid 和 Console 所在命名空间。
public class PlayerConfig // 定义一个玩家配置类。
{ // 类开始。
public const int MaxLevel = 100; // const 是编译期常量,声明时必须赋值。
public readonly int StartHp; // readonly 字段可以在构造函数里赋值。
public static readonly string RuntimeId; // static readonly 可以在静态构造函数里赋值。
static PlayerConfig() // 静态构造函数在类型初始化时执行。
{ // 静态构造函数开始。
RuntimeId = Guid.NewGuid().ToString(); // static readonly 可以使用运行时计算结果赋值。
} // 静态构造函数结束。
public PlayerConfig(int startHp) // 实例构造函数在 new 对象时执行。
{ // 实例构造函数开始。
StartHp = startHp; // readonly 实例字段可以在构造函数里赋值。
} // 实例构造函数结束。
public void ChangeHp() // 定义一个普通方法。
{ // 方法开始。
Console.WriteLine(StartHp); // 普通方法里只能读取 readonly 字段。
} // 方法结束。
} // 类结束。引用类型 readonly 的坑
readonly 修饰引用类型时,限制的是“引用不能重新赋值”,不是“对象内容完全不能变”。
c
using System.Collections.Generic; // 引入 List。
public class ReadonlyReferenceDemo // 定义一个 readonly 引用类型演示类。
{ // 类开始。
private readonly List<int> _numbers = new List<int>(); // readonly 限制 _numbers 不能指向另一个 List。
public void AddNumber() // 定义添加数字的方法。
{ // 方法开始。
_numbers.Add(1); // 可以修改 List 内部内容,因为 readonly 不等于深不可变。
} // 方法结束。
} // 类结束。Unity 项目里怎么用
我一般这样选:
const:真正永远不变的编译期常量,比如数学常量、固定字符串 key。readonly:运行时才能确定,但初始化后不想再改的字段,比如构造参数、运行时 ID、依赖对象。static readonly:全局共享但运行时初始化的只读数据,比如Guid、复杂对象、数组、配置缓存。
还要注意:public const 跨程序集有坑。比如库里把 const int Version = 1 改成 2,但调用方没重新编译,调用方可能还拿旧的 1。这种对外暴露的常量,很多时候用 static readonly 更稳。
面试总结
一句话背法:const 是编译期常量,会被编译器替换;readonly 是运行时字段,可以在声明或构造函数里赋值,之后不能再给字段重新赋值。引用类型 readonly 只限制引用本身,不保证对象内部不可变。
event 能从外部直接触发吗?
标准答案
event 不能从外部直接触发。
在声明事件的类外部,event 只能做两件事:
+=订阅事件-=取消订阅事件
不能从外部:
Invoke()触发事件= null清空事件=直接覆盖整个事件委托链
底层原理
event 可以理解成“对委托加了一层访问限制”。
如果你写:
c
public event Action Died;外部只能访问它的 add/remove 能力,也就是订阅和取消订阅。真正触发事件的权力,只在声明这个事件的类内部。
代码示例
c
using System; // 引入 Action 委托所在命名空间。
using UnityEngine; // 引入 Unity 引擎 API。
public class Player // 定义事件发布者 Player。
{ // 类开始。
public event Action Died; // 定义死亡事件,外部只能 += 或 -=。
public void TakeDamage(int damage) // 定义受伤方法。
{ // 方法开始。
if (damage <= 0) return; // 如果伤害小于等于 0,就不处理。
Die(); // 触发死亡流程。
} // 方法结束。
private void Die() // 定义内部死亡方法。
{ // 方法开始。
Died?.Invoke(); // 在类内部可以触发 event。
} // 方法结束。
} // 类结束。
public class PlayerUI : MonoBehaviour // 定义事件订阅者 PlayerUI。
{ // 类开始。
private Player _player; // 保存 Player 引用。
private void OnEnable() // 对象启用时调用。
{ // 方法开始。
_player = new Player(); // 创建一个 Player 示例对象。
_player.Died += OnPlayerDied; // 外部可以订阅事件。
} // 方法结束。
private void OnDisable() // 对象禁用时调用。
{ // 方法开始。
_player.Died -= OnPlayerDied; // 外部可以取消订阅事件。
} // 方法结束。
private void OnPlayerDied() // 定义事件回调。
{ // 方法开始。
Debug.Log("玩家死亡,刷新 UI"); // 收到事件后刷新 UI。
} // 方法结束。
} // 类结束。下面这种外部调用会编译失败:
c
using System; // 引入 Action。
public class BadEventUsage // 定义一个错误使用事件的类。
{ // 类开始。
public void Test(Player player) // 定义测试方法。
{ // 方法开始。
player.Died += OnDied; // 正确:外部可以订阅。
player.Died -= OnDied; // 正确:外部可以取消订阅。
// player.Died?.Invoke(); // 错误:外部不能直接触发 event。
// player.Died = null; // 错误:外部不能清空或覆盖 event。
} // 方法结束。
private void OnDied() // 定义事件回调。
{ // 方法开始。
Console.WriteLine("Died"); // 输出事件消息。
} // 方法结束。
} // 类结束。和 Action 字段的区别
如果你写的是 public Action Died;,那外部就可以乱来:
c
using System; // 引入 Action。
public class BadPlayer // 定义一个不安全的事件发布者。
{ // 类开始。
public Action Died; // 这是普通 public 委托字段,不是 event。
} // 类结束。
public class BadCaller // 定义一个外部调用者。
{ // 类开始。
public void Test(BadPlayer player) // 定义测试方法。
{ // 方法开始。
player.Died?.Invoke(); // 外部可以直接触发,破坏封装。
player.Died = null; // 外部可以直接清空所有监听。
} // 方法结束。
} // 类结束。这就是为什么面试里要强调:event 比 public delegate field 更安全,它把“触发事件”的权力留给发布者。
Unity 项目里怎么用
Unity 中常见写法是:
OnEnable里订阅事件。OnDisable里取消订阅事件。- 事件发布者内部用
RaiseXXX()或业务方法触发事件。 - 不要让 UI 或其他外部模块直接触发角色死亡、任务完成、背包变化这种业务事件。
面试总结
一句话背法:event 外部不能直接触发,只能 += 和 -=;只有声明事件的类内部可以 Invoke。它的作用是封装委托,防止外部伪造事件、清空事件或覆盖整个监听列表。
GetComponent 能每帧调吗?
标准答案
GetComponent 能每帧调,但不推荐每帧大量调用。
更准确地说:偶尔在 Update 里调一次不会立刻出大问题,但如果很多对象都在每帧反复 GetComponent,比如几百个怪物、子弹、UI Item 都这么写,就会造成明显 CPU 开销。项目里一般应该在 Awake、Start、OnEnable 或 Inspector 里缓存组件引用。
底层原理
GetComponent<T>() 不是普通字段访问,它需要在当前 GameObject 挂载的组件列表里按类型查找目标组件。
所以这两种写法成本完全不同:
c
_rigidbody.AddForce(force);这是字段引用访问,比较直接。
c
GetComponent<Rigidbody>().AddForce(force);这会先查组件,再调用方法。一个对象少量调用没事,但 对象数 * 帧率 * 查找次数 一放大,就会变成热路径开销。
不推荐写法
c
using UnityEngine; // 引入 Unity 引擎 API。
public class BadMove : MonoBehaviour // 定义一个不推荐的移动脚本。
{ // 类开始。
private void Update() // Update 每帧执行。
{ // Update 方法开始。
Vector3 force = Vector3.forward * 10f; // 创建一个向前的力。
GetComponent<Rigidbody>().AddForce(force); // 不推荐:每帧都重新查找 Rigidbody。
} // Update 方法结束。
} // 类结束。推荐写法
c
using UnityEngine; // 引入 Unity 引擎 API。
[RequireComponent(typeof(Rigidbody))] // 要求当前 GameObject 必须挂 Rigidbody,减少漏挂组件风险。
public class GoodMove : MonoBehaviour // 定义一个推荐的移动脚本。
{ // 类开始。
private Rigidbody _rigidbody; // 缓存 Rigidbody 引用。
private void Awake() // Awake 适合初始化自身组件引用。
{ // Awake 方法开始。
_rigidbody = GetComponent<Rigidbody>(); // 只查找一次 Rigidbody。
} // Awake 方法结束。
private void FixedUpdate() // FixedUpdate 适合处理 Rigidbody 物理逻辑。
{ // FixedUpdate 方法开始。
Vector3 force = Vector3.forward * 10f; // 创建一个向前的力。
_rigidbody.AddForce(force); // 推荐:直接使用缓存好的 Rigidbody。
} // FixedUpdate 方法结束。
} // 类结束。Unity 项目里怎么答
我会说:GetComponent 不是绝对不能每帧调,而是不要在性能热路径里滥用。角色、子弹、怪物、ScrollView Item 这类大量对象,组件引用应该缓存。可选组件可以用 TryGetComponent,更清晰也避免反复查找。真正优化前,我会用 Profiler 看 BehaviourUpdate 和脚本函数耗时,再决定是否值得改。
Camera.main 为什么慎用?
标准答案
Camera.main 能用,但要慎用,尤其不要在 Update、LateUpdate 这种每帧热路径里大量调用。
原因是:Camera.main 不是一个普通字段,它会根据 MainCamera 标签去找主相机。偶尔初始化时取一次可以,但如果很多 UI 血条、点击检测、世界坐标转屏幕坐标都每帧调用,就会把查找成本放大。
底层原理
Camera.main 的语义是:返回带有 MainCamera 标签的启用相机。
它的问题主要有几个:
- 有查找或内部获取成本,不如字段引用直接。
- 如果相机没打
MainCamera标签,会返回null。 - 如果场景里有多个
MainCamera,结果容易不清晰。 - 多相机场景里,比如 UI 相机、小地图相机、特效相机,不一定应该用主相机。
- 场景切换、相机切换后,旧缓存可能失效,需要重新绑定。
不推荐写法
c
using UnityEngine; // 引入 Unity 引擎 API。
public class BadHealthBar : MonoBehaviour // 定义一个不推荐的血条跟随脚本。
{ // 类开始。
public Transform target; // 保存血条要跟随的世界目标。
private void LateUpdate() // LateUpdate 每帧在 Update 后执行。
{ // LateUpdate 方法开始。
Vector3 screenPos = Camera.main.WorldToScreenPoint(target.position); // 不推荐:每帧都通过 Camera.main 查主相机。
transform.position = screenPos; // 把血条移动到屏幕坐标位置。
} // LateUpdate 方法结束。
} // 类结束。推荐写法
c
using UnityEngine; // 引入 Unity 引擎 API。
public class GoodHealthBar : MonoBehaviour // 定义一个推荐的血条跟随脚本。
{ // 类开始。
[SerializeField] private Camera _mainCamera; // 在 Inspector 里显式指定相机,避免每帧 Camera.main。
[SerializeField] private Transform _target; // 在 Inspector 里指定血条跟随目标。
private void Awake() // Awake 适合做引用初始化。
{ // Awake 方法开始。
if (_mainCamera == null) // 如果 Inspector 没有手动拖相机。
{ // if 代码块开始。
_mainCamera = Camera.main; // 初始化阶段兜底取一次主相机。
} // if 代码块结束。
} // Awake 方法结束。
private void LateUpdate() // LateUpdate 适合做血条、相机相关表现同步。
{ // LateUpdate 方法开始。
if (_mainCamera == null || _target == null) return; // 引用缺失时直接返回,避免空引用异常。
Vector3 screenPos = _mainCamera.WorldToScreenPoint(_target.position); // 使用缓存相机做世界坐标到屏幕坐标转换。
transform.position = screenPos; // 更新血条屏幕位置。
} // LateUpdate 方法结束。
} // 类结束。面试总结
我会这样答:Camera.main 不是不能用,而是不要在高频逻辑里依赖它。初始化阶段兜底取一次可以,但正式项目里更推荐序列化引用、缓存引用,或者由相机管理器、UI 管理器统一传入。尤其在多相机、Cinemachine、场景切换项目里,应该明确使用哪一台 Camera。
Resources 为什么不推荐大量使用?
标准答案
Resources 不是不能用,而是不推荐“大量使用”。它适合放少量兜底资源、默认配置、小图标这类“永远跟包走”的资源;但如果把大量 UI、角色、场景、特效、音频都放进去,会带来包体变大、路径脆弱、资源卸载困难、热更新困难、加载卡顿等问题。
底层原因
Resources 目录下的资源通常会被 Unity 收进最终包体里,运行时通过字符串路径加载:
c
Resources.Load<GameObject>("UI/MainPanel");问题在于它没有完整的“资源管理系统”能力。它不知道你的资源要不要分包、能不能远程更新、谁引用了它、什么时候可以卸载。路径还是字符串,资源改名或移动后,编译期不一定报错,可能运行时才发现加载失败。
Unity 工程实践
正式项目里,我一般不会让业务代码到处直接写 Resources.Load。如果临时用,也至少封装一层,统一路径、缓存、错误日志和释放策略。大型项目更推荐 AssetBundle 或 Addressables,因为它们更适合做依赖管理、异步加载、版本对比、引用计数和热更新。
错误示例:不要每帧加载
c
using UnityEngine; // 引入 Unity 引擎 API。
public class BadResourcesUse : MonoBehaviour // 定义一个错误示范脚本。
{ // 类开始。
private void Update() // 每帧都会执行一次。
{ // Update 方法开始。
GameObject prefab = Resources.Load<GameObject>("Bullet"); // 每帧按字符串路径同步查找资源,容易卡顿。
Instantiate(prefab); // 每帧实例化对象,会造成 GC 和性能压力。
} // Update 方法结束。
} // 类结束。更好的写法:集中封装和缓存
c
using System.Collections.Generic; // 引入 Dictionary,用来缓存已经加载过的资源。
using UnityEngine; // 引入 Unity 引擎 API。
public class SimpleResourcesManager : MonoBehaviour // 定义一个简单的 Resources 管理器。
{ // 类开始。
private readonly Dictionary<string, Object> cache = new Dictionary<string, Object>(); // 用路径缓存资源,避免重复 Load。
public T LoadCached<T>(string path) where T : Object // 泛型加载方法,只允许加载 UnityEngine.Object 类型资源。
{ // 方法开始。
if (cache.TryGetValue(path, out Object cached)) // 如果缓存里已经有这个路径的资源。
{ // if 开始。
return cached as T; // 直接返回缓存资源,避免重复加载。
} // if 结束。
T asset = Resources.Load<T>(path); // 第一次加载时才真正调用 Resources.Load。
if (asset == null) // 如果资源路径错误或资源不存在。
{ // if 开始。
Debug.LogError("Resources load failed: " + path); // 输出错误日志,方便定位问题。
return null; // 加载失败时返回空。
} // if 结束。
cache[path] = asset; // 把加载到的资源放进缓存。
return asset; // 返回加载成功的资源。
} // 方法结束。
public void ClearCache() // 清空当前管理器记录的缓存引用。
{ // 方法开始。
cache.Clear(); // 清掉字典引用,但资源是否释放还取决于外部是否仍然持有引用。
Resources.UnloadUnusedAssets(); // 请求 Unity 卸载未被引用的资源,但这个操作可能比较重。
} // 方法结束。
} // 类结束。面试加分说法
我不会简单说“Resources 不好”,而是会说:Resources 缺少大型项目需要的依赖管理、引用计数、分包、版本、热更新和可观测性。少量兜底资源可以用,大量生产资源应该走 Addressables 或自研资源管理层。
Destroy 是立即销毁吗?
标准答案
Destroy 不是“立刻从内存里消失”。在 Unity 里,Destroy(obj) 会把对象标记为待销毁,真正销毁通常发生在当前 Update 循环结束之后、本帧渲染之前。Unity 官方文档也说明:实际销毁会延后到当前更新循环之后处理。
底层原理
Unity 对象可以理解成两层:
C# 托管对象壳:你代码里的变量引用。
Unity 原生对象:引擎底层真正管理的 GameObject、Component、Texture 等对象。
调用 Destroy 主要是告诉 Unity:“这个原生对象可以销毁了”。但 C# 变量本身不一定马上变成真正的 null,它可能还保留着一个托管引用,只是 Unity 重载了 ==,所以原生对象销毁后,它会表现得像 null。这就是有时候你看到“对象像是 null,但引用又还在”的原因。
代码示例
c
using System.Collections; // 引入 IEnumerator,用来写协程。
using UnityEngine; // 引入 Unity 引擎 API。
public class DestroyTimingDemo : MonoBehaviour // 定义一个演示 Destroy 时机的脚本。
{ // 类开始。
[SerializeField] private GameObject target; // 在 Inspector 里拖入一个要销毁的对象。
private IEnumerator Start() // Start 写成协程,方便等一帧观察变化。
{ // 协程开始。
Debug.Log(target == null); // 销毁前一般是 false,表示对象还存在。
Destroy(target); // 标记 target 待销毁,不是立刻从内存中完全消失。
Debug.Log(target == null); // 同一帧里不要依赖这个结果做复杂逻辑。
yield return null; // 等待一帧,让 Unity 有机会处理销毁队列。
Debug.Log(target == null); // 下一帧通常会表现为 null。
} // 协程结束。
} // 类结束。Unity 工程实践
如果是普通对象销毁,用 Destroy。如果是子弹、特效、伤害数字这种高频创建销毁对象,最好用对象池:SetActive(false) 回收,重置状态后复用,不要频繁 Destroy / Instantiate。
DestroyImmediate 才是立即销毁,但它主要用于编辑器工具。运行时滥用可能在遍历对象、组件、子节点时把结构直接改掉,容易出奇怪问题。
一句话面试版
Destroy 是延迟销毁,它会把对象加入 Unity 的销毁队列,通常在当前 Update 结束后、渲染前统一处理;C# 引用和 Unity 原生对象不是一回事,所以销毁后还要注意“托管壳”和“原生对象”的区别。
sharedMaterial 改了会影响谁?
标准答案
sharedMaterial 改了会影响所有使用同一个材质资源的物体。 比如场景里 100 个怪物都用同一个 Enemy.mat,你写:
c
renderer.sharedMaterial.color = Color.red; // 修改共享材质资源,所有使用它的对象都会变红。那么不是只改当前这个怪物,而是所有引用这个 Enemy.mat 的 Renderer 都可能一起变。
底层原理
sharedMaterial 拿到的是 Project 里的共享 Material Asset。多个 Renderer 可能都指向同一份材质资产,所以你改的是“公共模板”。
renderer.material 通常会给当前 Renderer 创建一份材质实例,只影响当前物体,但代价是多一份材质实例,可能增加内存和破坏合批。
单个对象临时改颜色、闪白、阵营色,项目里更推荐 MaterialPropertyBlock。
错误写法
c
using UnityEngine; // 引入 Unity 引擎 API。
public class BadSharedMaterialDemo : MonoBehaviour // 定义一个错误示范脚本。
{ // 类开始。
[SerializeField] private Renderer targetRenderer; // 在 Inspector 里拖入目标 Renderer。
private void Start() // Start 在脚本第一次启用后执行。
{ // 方法开始。
targetRenderer.sharedMaterial.color = Color.red; // 修改共享材质,会影响所有使用同一材质的物体。
} // 方法结束。
} // 类结束。推荐写法:MaterialPropertyBlock
c
using UnityEngine; // 引入 Unity 引擎 API。
public class GoodMaterialPropertyBlockDemo : MonoBehaviour // 定义一个推荐示范脚本。
{ // 类开始。
[SerializeField] private Renderer targetRenderer; // 保存要修改显示效果的 Renderer。
private MaterialPropertyBlock block; // 保存属性块,避免每次 new。
private void Awake() // Awake 在对象初始化时执行。
{ // 方法开始。
block = new MaterialPropertyBlock(); // 创建材质属性块实例。
} // 方法结束。
public void SetColor(Color color) // 对外提供设置颜色的方法。
{ // 方法开始。
targetRenderer.GetPropertyBlock(block); // 读取当前 Renderer 已有的属性块数据。
block.SetColor("_BaseColor", color); // 设置 URP/HDRP 常用颜色属性,Built-in 常见是 "_Color"。
targetRenderer.SetPropertyBlock(block); // 把属性块应用到当前 Renderer,不修改共享材质。
} // 方法结束。
} // 类结束。面试加分说法
我会这样答:sharedMaterial 适合做编辑器批量工具或全局统一修改;运行时如果只是单个对象表现差异,不应该直接改它。少量临时实例可以用 material,大量单位改属性优先用 MaterialPropertyBlock,同时还要关注合批、内存和 SRP Batcher 的实际表现。
Update 里 new 对象有什么风险?
标准答案
Update 里频繁 new 的核心风险是:每帧产生托管堆分配,也就是 Profiler 里的 GC Alloc。单次分配可能很小,但 60 FPS 下每秒都会累积垃圾对象,最后触发 GC,造成主线程某一帧突然卡一下。
底层原理
C# 里的引用类型对象、数组、字符串、闭包、装箱等通常会进入 Managed Heap。Unity 的 GC 会在合适时机扫描这些对象,把没用的对象回收。问题是游戏是实时程序,一帧预算可能只有 16.6ms,如果 GC 在主线程上产生尖峰,就会表现为掉帧、卡顿、手感变差。
但不是所有 new 都一定产生 GC,比如局部 new Vector3(...) 是值类型,通常不是主要问题。面试时要说:重点看 Profiler 的 GC Alloc,不要凭感觉优化。
错误写法
c
using UnityEngine; // 引入 Unity 引擎 API。
public class BadUpdateAlloc : MonoBehaviour // 定义一个会在 Update 中产生 GC 的示例脚本。
{ // 类开始。
private void Update() // Update 每帧都会执行。
{ // 方法开始。
Collider[] hits = Physics.OverlapSphere(transform.position, 5f); // 每帧返回新数组,容易产生 GC Alloc。
string text = "Hit Count: " + hits.Length; // 字符串拼接会创建新字符串。
Debug.Log(text); // 每帧输出日志也会带来明显性能开销。
} // 方法结束。
} // 类结束。优化写法
c
using UnityEngine; // 引入 Unity 引擎 API。
public class GoodUpdateNoAlloc : MonoBehaviour // 定义一个减少每帧分配的示例脚本。
{ // 类开始。
private readonly Collider[] hitBuffer = new Collider[32]; // 预先创建数组缓冲区,后续每帧复用。
private readonly float detectRadius = 5f; // 缓存检测半径,避免魔法数字散落。
private void Update() // Update 每帧执行。
{ // 方法开始。
int hitCount = Physics.OverlapSphereNonAlloc(transform.position, detectRadius, hitBuffer); // 使用 NonAlloc API,把结果写入已有数组。
for (int i = 0; i < hitCount; i++) // 遍历本次实际命中的数量。
{ // for 开始。
Collider hit = hitBuffer[i]; // 读取缓存数组里的碰撞体引用。
if (hit == null) // 防御式判断,避免对象已销毁导致异常。
{ // if 开始。
continue; // 跳过无效对象。
} // if 结束。
HandleHit(hit); // 处理命中的对象。
} // for 结束。
} // 方法结束。
private void HandleHit(Collider hit) // 单独封装命中处理逻辑。
{ // 方法开始。
// 这里写具体业务逻辑,例如技能范围检测、怪物感知、拾取检测。
} // 方法结束。
} // 类结束。Unity 项目里常见来源
高频风险包括:new List、new Dictionary、new array、字符串拼接、LINQ、匿名函数闭包、装箱、foreach 在某些集合上的枚举器分配、Physics.RaycastAll / OverlapSphere 返回数组、频繁 Instantiate / Destroy。
优化思路是:缓存组件、复用集合、预分配容量、对象池、使用 NonAlloc API、少在热路径用 LINQ,战斗和 UI 滚动这种热路径尽量做到 0 B/frame。
一句话面试版
Update 里频繁 new 的问题不是创建本身,而是持续制造 GC Alloc,让托管堆增长并触发 GC 尖峰;我会用 Profiler 定位具体分配行,再用缓存、对象池、NonAlloc API 和预分配来优化。
Canvas 重建会影响性能吗?
标准答案
Canvas 重建会影响性能,尤其是“大 Canvas + 高频变化”的情况。 比如一个血条数字每帧变化,如果它和大量静态背景、按钮、文本都放在同一个 Canvas 下,就可能让整个 Canvas 重新参与计算,CPU 开销会明显上升。
底层原理
UGUI 渲染不是每个 UI 单独直接画,而是 Canvas 会把下面的 Graphic 收集起来,生成网格、排序、合批,再提交给渲染系统。 当 UI 的文字、图片、颜色、尺寸、位置、层级发生变化时,对应 UI 会被标记为 dirty,之后在渲染前触发重建。
主要有三类成本:
Layout Rebuild:重新计算布局,比如 LayoutGroup、ContentSizeFitter。
Graphic Rebuild:重新生成 Image、Text、TMP 的顶点数据。
Canvas.BuildBatch:重新分析材质、贴图、层级顺序,生成 UI 批次。
错误写法
c
using TMPro; // 引入 TMP 文本组件命名空间。
using UnityEngine; // 引入 Unity 引擎 API。
using UnityEngine.UI; // 引入 UGUI 组件命名空间。
public class BadHpView : MonoBehaviour // 定义一个不推荐的血条 UI 脚本。
{ // 类开始。
[SerializeField] private Slider hpSlider; // 保存血条 Slider 引用。
[SerializeField] private TMP_Text hpText; // 保存血量文本引用。
[SerializeField] private int hp = 100; // 模拟当前血量。
private void Update() // Update 每帧执行。
{ // 方法开始。
hpSlider.value = hp / 100f; // 每帧设置 Slider,可能导致 UI 被频繁标脏。
hpText.text = hp.ToString(); // 每帧改文本,会触发文本网格重建。
} // 方法结束。
} // 类结束。推荐写法
c
using TMPro; // 引入 TMP 文本组件命名空间。
using UnityEngine; // 引入 Unity 引擎 API。
using UnityEngine.UI; // 引入 UGUI 组件命名空间。
public class GoodHpView : MonoBehaviour // 定义一个更合理的血条 UI 脚本。
{ // 类开始。
[SerializeField] private Slider hpSlider; // 缓存 Slider 引用,避免运行时查找。
[SerializeField] private TMP_Text hpText; // 缓存 TMP_Text 引用,避免运行时查找。
private int lastHp = -1; // 记录上一次显示的血量。
private int lastMaxHp = -1; // 记录上一次显示的最大血量。
public void Refresh(int hp, int maxHp) // 由血量变化事件触发刷新。
{ // 方法开始。
if (hp == lastHp && maxHp == lastMaxHp) // 如果数据没有变化。
{ // if 开始。
return; // 不刷新 UI,避免无意义重建。
} // if 结束。
lastHp = hp; // 更新缓存的当前血量。
lastMaxHp = maxHp; // 更新缓存的最大血量。
hpSlider.SetValueWithoutNotify((float)hp / maxHp); // 更新 Slider,但不触发额外回调。
hpText.SetText("{0}/{1}", hp, maxHp); // 使用 TMP SetText,减少字符串分配。
} // 方法结束。
} // 类结束。Unity 工程实践
优化时我会先看 Profiler:如果 Canvas.BuildBatch 高,通常说明单个 Canvas 下可绘制元素太多,或者 Canvas 被频繁标脏;如果 Canvas.SendWillRenderCanvases 高,可能是 Layout、Graphic、Text 重建成本高。
常用处理方式是:静态 UI 和动态 UI 分 Canvas;血条、倒计时、滚动列表这种频繁变化区域单独放;减少 LayoutGroup + ContentSizeFitter 的运行时混用;文本只有变化时再刷新;大量列表用虚拟列表。
但 Canvas 也不是拆得越多越好。拆 Canvas 可以降低重建范围,但可能增加 DrawCall,所以要用 Profiler 看数据取平衡。
一句话面试版
Canvas 重建会影响性能,核心原因是 UI 一旦被标脏,Canvas 要重新做布局、网格生成和合批;优化思路是用 Profiler 定位 Canvas.BuildBatch / SendWillRenderCanvases,再把静态和动态 UI 分离,减少高频 UI 属性变化。