Skip to content

1 分钟快答

AwakeOnEnable 谁先?

unity-awake-onenable-order

标准答案

同一个脚本实例里,通常是 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 方法结束。 
} // 类结束。

面试说法

我会这样答:默认情况下 AwakeOnEnable 先执行。Awake 做一次性初始化,OnEnable 做每次启用时的逻辑,比如事件订阅。真正要注意的是,如果组件 disabled,Awake 可能已经执行但 OnEnable 不执行;如果 GameObject inactive,生命周期会延后到激活时才开始。

Start 什么时候执行?

unity-start-execution-time

标准答案

Start 会在脚本第一次启用后、第一次 Update 之前执行一次。

常见顺序是:

c
Awake -> OnEnable -> Start -> Update

所以面试里可以直接说:StartAwakeOnEnable 晚,它不是对象创建瞬间执行,而是在对象真正启用后,进入第一帧更新前执行。

底层原理

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 前执行一次;它晚于 AwakeOnEnable,适合做首帧前初始化,但不要用它隐式保证复杂模块顺序。

FixedUpdate 默认多久一次?

unity-fixedupdate-default-interval

标准答案

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 FPSUpdate 大概每 0.0167s 一次。
  • 如果 FixedUpdate0.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、动画参数。
  • FixedUpdateRigidbody 移动、AddForce、物理检测、和物理强相关的逻辑。
  • LateUpdate:相机跟随、依赖前面更新结果的表现逻辑。

面试里可以补一句:FixedUpdate 的步长越小,物理越精细,但 CPU 压力也越大;低端机上如果物理对象多,过高的物理频率会让 Physics.Simulate 占用变高。

deltaTime 为什么不能用于物理力?

unity-deltatime-physics-force

标准答案

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 方法结束。 
} // 类结束。

面试补充

严格说,在 FixedUpdateTime.deltaTime 通常会表现为固定步长,但我面试时会强调语义:物理逻辑用 FixedUpdatefixedDeltaTime,普通渲染帧逻辑用 UpdatedeltaTime

deltaTime 适合:

  • 普通 Transform 位移
  • UI 动画
  • 非物理插值
  • 冷却计时

fixedDeltaTime 适合:

  • Rigidbody 相关逻辑
  • 手动计算物理位移
  • 物理检测节奏
  • MovePosition 这种物理移动目标计算

协程是不是多线程?

unity-coroutine-not-thread

标准答案

协程不是多线程。

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 密集型算法。
  • 真正需要后台并行的工作。

这些更适合 ThreadTask、Job System,但后台线程不能直接操作大部分 Unity API,比如 TransformGameObjectInstantiate 等,结果要派发回主线程处理。

面试总结

我会这样说:协程不是线程,它是 Unity 主线程上的 IEnumerator 调度机制。yield 只是把执行权暂时交还给引擎,等下一帧或等待条件满足后继续执行。它能让流程写起来像异步,但不能让耗时计算自动并行。

yield return null 等几帧?

unity-yield-return-null-frames

标准答案

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 nullWaitForSeconds 不一样:

  • 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 是链表吗?

csharp-list-not-linked-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 是有序的吗?

csharp-dictionary-not-ordered

标准答案

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 能重复吗?

csharp-hashset-no-duplicates

标准答案

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 里放的是自定义类型,要正确重写 EqualsGetHashCode,否则你以为重复的对象,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;重复的判断依赖 GetHashCodeEquals,哈希冲突不等于重复。它适合去重和快速判断是否存在,但不适合保存顺序或统计重复次数。

struct 默认在栈上吗?

csharp-struct-stack-or-heap

标准答案

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

  • Vector3
  • Quaternion
  • Color
  • Bounds
  • RaycastHit

它们是值类型,意味着赋值、传参、属性返回时经常会复制。

例如:

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 可变吗?

csharp-string-immutable

标准答案

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。

constreadonly 谁运行时赋值?

csharp-const-readonly-runtime-assignment

标准答案

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 能从外部直接触发吗?

csharp-event-external-invoke

标准答案

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; // 外部可以直接清空所有监听。 
    } // 方法结束。 
} // 类结束。

这就是为什么面试里要强调:eventpublic delegate field 更安全,它把“触发事件”的权力留给发布者。

Unity 项目里怎么用

Unity 中常见写法是:

  • OnEnable 里订阅事件。
  • OnDisable 里取消订阅事件。
  • 事件发布者内部用 RaiseXXX() 或业务方法触发事件。
  • 不要让 UI 或其他外部模块直接触发角色死亡、任务完成、背包变化这种业务事件。

面试总结

一句话背法:event 外部不能直接触发,只能 +=-=;只有声明事件的类内部可以 Invoke。它的作用是封装委托,防止外部伪造事件、清空事件或覆盖整个监听列表。

GetComponent 能每帧调吗?

unity-getcomponent-every-frame

标准答案

GetComponent 能每帧调,但不推荐每帧大量调用。

更准确地说:偶尔在 Update 里调一次不会立刻出大问题,但如果很多对象都在每帧反复 GetComponent,比如几百个怪物、子弹、UI Item 都这么写,就会造成明显 CPU 开销。项目里一般应该在 AwakeStartOnEnable 或 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 为什么慎用?

unity-camera-main-caution

标准答案

Camera.main 能用,但要慎用,尤其不要在 UpdateLateUpdate 这种每帧热路径里大量调用。

原因是: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 为什么不推荐大量使用?

unity-resources-not-recommended

标准答案

Resources 不是不能用,而是不推荐“大量使用”。它适合放少量兜底资源、默认配置、小图标这类“永远跟包走”的资源;但如果把大量 UI、角色、场景、特效、音频都放进去,会带来包体变大、路径脆弱、资源卸载困难、热更新困难、加载卡顿等问题。

底层原因

Resources 目录下的资源通常会被 Unity 收进最终包体里,运行时通过字符串路径加载:

c
Resources.Load<GameObject>("UI/MainPanel");

问题在于它没有完整的“资源管理系统”能力。它不知道你的资源要不要分包、能不能远程更新、谁引用了它、什么时候可以卸载。路径还是字符串,资源改名或移动后,编译期不一定报错,可能运行时才发现加载失败。

Unity 工程实践

正式项目里,我一般不会让业务代码到处直接写 Resources.Load。如果临时用,也至少封装一层,统一路径、缓存、错误日志和释放策略。大型项目更推荐 AssetBundleAddressables,因为它们更适合做依赖管理、异步加载、版本对比、引用计数和热更新。

错误示例:不要每帧加载

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 是立即销毁吗?

unity-destroy-not-immediate

标准答案

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 改了会影响谁?

unity-sharedmaterial-affects-who

标准答案

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 对象有什么风险?

unity-update-new-object-gc-risk

标准答案

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 Listnew Dictionarynew array、字符串拼接、LINQ、匿名函数闭包、装箱、foreach 在某些集合上的枚举器分配、Physics.RaycastAll / OverlapSphere 返回数组、频繁 Instantiate / Destroy

优化思路是:缓存组件、复用集合、预分配容量、对象池、使用 NonAlloc API、少在热路径用 LINQ,战斗和 UI 滚动这种热路径尽量做到 0 B/frame

一句话面试版

Update 里频繁 new 的问题不是创建本身,而是持续制造 GC Alloc,让托管堆增长并触发 GC 尖峰;我会用 Profiler 定位具体分配行,再用缓存、对象池、NonAlloc API 和预分配来优化。

Canvas 重建会影响性能吗?

unity-canvas-rebuild-performance-risk

标准答案

Canvas 重建会影响性能,尤其是“大 Canvas + 高频变化”的情况。 比如一个血条数字每帧变化,如果它和大量静态背景、按钮、文本都放在同一个 Canvas 下,就可能让整个 Canvas 重新参与计算,CPU 开销会明显上升。

底层原理

UGUI 渲染不是每个 UI 单独直接画,而是 Canvas 会把下面的 Graphic 收集起来,生成网格、排序、合批,再提交给渲染系统。 当 UI 的文字、图片、颜色、尺寸、位置、层级发生变化时,对应 UI 会被标记为 dirty,之后在渲染前触发重建。

主要有三类成本:

Layout Rebuild:重新计算布局,比如 LayoutGroupContentSizeFitter

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 属性变化。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0