Skip to content

C# / Unity 必背 ​

值类型和引用类型区别 ​

值类型和引用类型的核心区别:值类型保存“数据本身”,引用类型保存“对象地址引用”。

csharp-value-type-reference-type

1. 值类型是什么?

值类型变量里放的是值本身。

常见值类型:

int、float、double、bool、char、struct、enum、DateTime

比如:

c
int a = 10;
int b = a;
b = 20;

这里 b = a 是把 10 复制了一份给 b,所以修改 b 不影响 a。

2. 引用类型是什么?

引用类型变量里放的不是对象本身,而是指向对象的引用。

常见引用类型:

class、object、string、数组、接口、委托

比如两个变量指向同一个对象时,修改其中一个变量访问到的对象,另一个变量也能看到变化。

3. 面试不要只说“值类型在栈,引用类型在堆”

这句话不够严谨。

更准确的说法是:

值类型强调“复制的是数据”;引用类型强调“复制的是引用”。

值类型也可能作为字段存在于堆上的对象内部,比如:

c
class Player
{
    public int Hp;
}

Hp 是值类型,但它作为 Player 对象的一部分,跟着对象一起存储。

4. 代码演示

c
using System; // 引入 System 命名空间,用来使用 Console 输出
public struct PointStruct // 定义一个值类型结构体
{ // PointStruct 结构体开始
    public int X; // 定义一个整数字段 X
} // PointStruct 结构体结束
public class PointClass // 定义一个引用类型类
{ // PointClass 类开始
    public int X; // 定义一个整数字段 X
} // PointClass 类结束
public class Program // 定义程序入口类
{ // Program 类开始
    public static void Main() // 定义程序入口方法
    { // Main 方法开始
        PointStruct a = new PointStruct { X = 1 }; // 创建值类型变量 a,并把 X 设置为 1
        PointStruct b = a; // 把 a 赋值给 b,这里会复制一份完整数据
        b.X = 99; // 修改 b 的 X,不会影响 a
        Console.WriteLine(a.X); // 输出 1,因为 a 和 b 是两份数据
        PointClass c = new PointClass { X = 1 }; // 创建引用类型对象,并让 c 指向它
        PointClass d = c; // 把 c 赋值给 d,这里复制的是引用地址
        d.X = 99; // 修改 d 指向的对象,也就是 c 指向的同一个对象
        Console.WriteLine(c.X); // 输出 99,因为 c 和 d 指向同一个对象
    } // Main 方法结束
} // Program 类结束

5. 面试总结

可以这样回答:

TIP

值类型变量保存数据本身,赋值时复制值,修改副本不会影响原变量;引用类型变量保存对象引用,赋值时复制引用,多个变量可能指向同一个对象,所以修改对象内容会互相影响。值类型默认不能为 null,除非写成 Nullable<T>,比如 int?;引用类型可以为 null。另外值类型装箱成 object 时会产生对象分配,可能带来 GC 压力。

class 和 struct 区别 ​

class 和 struct 的核心区别:class 是引用类型,struct 是值类型。

csharp-class-struct-difference

1. 赋值区别

struct 赋值时复制的是整份数据。

class 赋值时复制的是引用,两个变量可能指向同一个对象。

2. 修改区别

struct:

c
using System; // 引入 System 命名空间,用于 Console 输出
public struct PointStruct // 定义一个值类型结构体
{ // PointStruct 结构体开始
    public int X; // 定义字段 X
} // PointStruct 结构体结束
public class PointClass // 定义一个引用类型类
{ // PointClass 类开始
    public int X; // 定义字段 X
} // PointClass 类结束
public class Program // 定义程序入口类
{ // Program 类开始
    public static void Main() // 定义主函数
    { // Main 方法开始
        PointStruct a = new PointStruct { X = 1 }; // 创建结构体变量 a
        PointStruct b = a; // 把 a 赋值给 b,会复制一份数据
        b.X = 99; // 修改 b 的字段
        Console.WriteLine(a.X); // 输出 1,因为 a 没被影响
        PointClass c = new PointClass { X = 1 }; // 创建类对象,并让 c 指向它
        PointClass d = c; // 把 c 赋值给 d,会复制引用
        d.X = 99; // 修改 d 指向对象的字段
        Console.WriteLine(c.X); // 输出 99,因为 c 和 d 指向同一个对象
    } // Main 方法结束
} // Program 类结束

3. 继承区别

class 可以继承其他类,可以做多态。

struct 不能继承其他结构体或类,本质上隐式继承自 System.ValueType,但它可以实现接口。

4. 默认值区别

class 的默认值是 null。

struct 的默认值不是 null,而是字段归零:

int 是 0,bool 是 false,引用字段是 null。

如果想让值类型也能表示空,要用:

c
int? hp = null; // int? 本质是 Nullable<int>

5. 性能区别

struct 小对象通常更轻,比如 Vector3、Color、Quaternion`。

但 struct 太大时,频繁赋值会复制整块数据,反而更贵。

另外,struct 转成 object 或接口时可能发生装箱,会产生堆分配和 GC 压力。

6. 面试高分回答

IMPORTANT

不要只说“struct 在栈,class 在堆”。更准确是:

struct 是值语义,赋值复制数据;class 是引用语义,赋值复制引用。class 适合有身份、有生命周期、需要继承和共享状态的对象;struct 适合小型、不可变倾向、表示一组数据的对象,比如坐标、颜色、范围等。

装箱拆箱 ​

装箱和拆箱是值类型与引用类型之间的转换。

csharp-boxing-unboxing

1. 装箱是什么?

装箱就是把值类型包装成引用类型。

比如:

c
int a = 10;
object obj = a;

a 是值类型,object 是引用类型。 当 int 赋值给 object 时,CLR 会在堆上创建一个对象,把 a 的值复制进去,然后让 obj 引用这个对象。

所以装箱的本质是:

创建堆对象 + 复制值 + 返回引用。

2. 拆箱是什么?

拆箱就是从装箱后的对象中取回值类型。

c
object obj = 10;
int b = (int)obj;

拆箱时会先检查 obj 里真实装的是不是 int。 如果类型对,就把值复制出来;如果类型不对,会抛 InvalidCastException。

3. 完整代码示例

c
using System; // 引入 System 命名空间,用于 Console 输出
public class Program // 定义程序入口类
{ // Program 类开始
    public static void Main() // 定义程序入口方法
    { // Main 方法开始
        int value = 10; // 定义一个 int 值类型变量
        object boxed = value; // 把 int 赋值给 object,这里发生装箱
        int unboxed = (int)boxed; // 把 object 强转回 int,这里发生拆箱
        Console.WriteLine(value); // 输出原始值类型变量
        Console.WriteLine(boxed); // 输出装箱后的 object 对象
        Console.WriteLine(unboxed); // 输出拆箱后得到的新 int 变量
    } // Main 方法结束
} // Program 类结束

4. 什么时候会发生装箱?

常见场景:

值类型赋给 object:

object obj = 10;

值类型赋给接口:

IComparable c = 10;

值类型放进非泛型集合:

c
ArrayList list = new ArrayList();
list.Add(10);

方法参数是 object:

c
void Log(object value)
{
}
Log(10);

5. 为什么面试和 Unity 都很重视?

因为装箱会产生堆分配。

在 Unity 里,如果每帧大量装箱,就可能出现 GC Alloc,最终导致 GC 触发,游戏卡顿。

比如高频 Update、日志输出、非泛型集合、接口调用、字符串格式化里混入值类型,都要小心。

6. 面试高分回答

IMPORTANT

装箱是值类型转成引用类型时发生的过程,CLR 会在堆上创建对象并复制值;拆箱是从装箱对象中取回值类型,过程会先做类型检查,再复制值。装箱会带来堆分配和 GC 压力,所以性能敏感代码里要尽量使用泛型集合、避免 object 参数和不必要的接口装箱。

GC 原理和 Unity GC 优化 ​

GC 原理:GC 是“自动回收托管内存”的机制。Unity GC 优化的核心不是频繁手动回收,而是减少运行时分配,尤其是每帧 GC Alloc。

csharp-unity-gc-principle-optimization

1. GC 大概怎么工作?

C# 里的对象通常分配在托管堆上,GC 会自动判断哪些对象已经“不可达”,然后回收它们。普通 .NET GC 会从静态字段、线程栈上的局部变量、GC Handle 等根对象开始扫描引用关系,能扫描到的是活对象,扫描不到的就是垃圾对象。Microsoft 文档也把这个过程描述为:GC 根据应用程序的 roots 建立可达对象图,不在图里的对象会被认为是垃圾并释放内存。

Microsoft Learn

可以理解成:

GC Roots -> 引用链 -> 活对象保留 -> 不可达对象回收

2. Unity 的 GC 有什么特点?

Unity 使用的是 Boehm-Demers-Weiser GC,Mono 和 IL2CPP 后端都使用它;Unity 默认使用增量式 GC,也就是把一次 GC 工作拆到多个帧里做,减少单帧大卡顿。

Unity Manual

但注意:增量 GC 不是让 GC 总成本消失,只是把一次长暂停拆成多次短暂停。Unity 文档也明确说,增量 GC 不会让垃圾回收整体更快,只是减少 GC spike。

Unity Manual

3. Unity GC 优化重点

最重要的是减少分配:

Update、LateUpdate、FixedUpdate 里不要频繁 new。 临时 List、数组、字符串、闭包、LINQ、装箱都可能造成 GC Alloc。 子弹、特效、伤害数字、UI Item 这类高频对象要用对象池。 字符串频繁拼接用 StringBuilder。 集合提前创建,后续 Clear() 复用。 用泛型集合,比如 List<int>,少用 ArrayList。 不要把优化停留在感觉上,要用 Profiler 的 GC Alloc 列验证。Unity 官方也建议用 CPU Usage Profiler、Memory Profiler 和 Memory Profiler package 跟踪分配,并尽量在目标平台 Development Build 上测。

Unity Manual

4. 代码示例

c
using System.Collections.Generic; // 引入泛型集合命名空间,用来复用 List 容器
using System.Text; // 引入 StringBuilder 命名空间,用来减少字符串拼接分配
using UnityEngine; // 引入 Unity 引擎命名空间,用来编写 MonoBehaviour 脚本
public class GcOptimizationDemo : MonoBehaviour // 定义一个演示 GC 优化的 Unity 脚本
{ // 类开始
    private readonly List<int> _cacheList = new List<int>(128); // 提前创建 List,避免每帧 new List
    private readonly StringBuilder _builder = new StringBuilder(128); // 提前创建 StringBuilder,避免频繁字符串拼接
    private void Update() // Unity 每帧调用 Update
    { // Update 方法开始
        OptimizedLogic(); // 每帧执行优化后的逻辑
    } // Update 方法结束
    private void OptimizedLogic() // 定义优化后的逻辑方法
    { // OptimizedLogic 方法开始
        _cacheList.Clear(); // 清空列表内容,但尽量复用内部数组容量
        _cacheList.Add(1); // 向复用列表中添加数据
        _cacheList.Add(2); // 继续添加数据,不创建新的 List 对象
        _builder.Clear(); // 清空 StringBuilder 内容,但复用已有缓冲区
        _builder.Append("Count: "); // 追加字符串片段,减少中间字符串对象
        _builder.Append(_cacheList.Count); // 追加数字内容,避免多次字符串拼接
        string text = _builder.ToString(); // 只在真正需要最终字符串时生成 string
        Debug.Log(text); // 输出结果,实际项目中高频日志也要谨慎使用
    } // OptimizedLogic 方法结束
} // 类结束

5. 面试高分回答

IMPORTANT

GC 负责管理托管堆,核心过程是从 GC Roots 出发扫描引用图,能到达的对象保留,不能到达的对象回收。Unity 里的 GC 使用 Boehm,并且默认使用增量式 GC,把回收工作分散到多帧,减少单帧尖峰。但优化 GC 的关键不是频繁调用 GC.Collect(),而是减少运行时分配,尤其是每帧 GC Alloc。我会通过对象池、缓存集合、复用字符串构建器、避免 LINQ/闭包/装箱、减少高频日志来降低分配,并用 Unity Profiler 在真机 Development Build 上验证优化前后的 GC Alloc 和帧耗时变化。

List 底层和扩容 ​

List<T> 底层本质是动态数组。

csharp-list-internals-growth

1. 底层是什么?

List<T> 内部主要靠一个 T[] 数组保存元素。

可以粗略理解成:

c
private T[] _items; // 真正存数据的底层数组
private int _size; // 当前已经存了多少个元素,也就是 Count
private int _version; // 集合版本号,用来检测遍历时是否被修改

Count 表示当前元素数量。 Capacity 表示底层数组容量。

比如:

c
List<int> list = new List<int>();
list.Add(10);
list.Add(20);

此时 Count 是 2,但 Capacity 可能是 4,因为底层数组预留了空间。

2. 扩容机制是什么?

当 Add 一个元素时,如果当前数组还有空位,就直接放进去。

如果容量不够,List<T> 会:

创建一个更大的新数组。 把旧数组里的元素复制过去。 让 _items 指向新数组。 再把新元素放进去。

常见扩容规律是:

c
0 -> 4 -> 8 -> 16 -> 32

也就是初始空数组,第一次添加时通常扩到 4,后面容量不够时大约翻倍。

3. 为什么 Add 平均是 O(1)?

单次普通 Add 是 O(1)。 但遇到扩容时,要复制旧元素,所以那一次是 O(n)。

不过扩容不是每次都发生,因此平均下来叫“摊还 O(1)”。

4. 插入和删除为什么慢?

List<T> 是连续数组。

如果你在中间插入:

c
list.Insert(1, 99);

后面的元素都要往后移动。

如果你在中间删除:

c
list.RemoveAt(1);

后面的元素都要往前移动。

所以中间插入、删除通常是 O(n)。

5. Clear 会不会释放数组?

Clear() 会把 Count 变成 0,但通常不会立刻释放底层数组容量。

这也是为什么对象池、临时列表复用时经常写:

c
list.Clear();

因为它可以复用已有容量,减少下次重新分配。

如果真的想缩小容量,可以用:

c
list.TrimExcess();

但不要频繁调用,因为缩容本身也可能带来复制和分配成本。

6. 代码示例

c
using System; // 引入 System 命名空间,用于 Console 输出
using System.Collections.Generic; // 引入泛型集合命名空间,用于使用 List<T>
public class Program // 定义程序入口类
{ // Program 类开始
    public static void Main() // 定义主函数
    { // Main 方法开始
        List<int> list = new List<int>(); // 创建一个空的 List<int>
        Console.WriteLine(list.Capacity); // 输出初始容量,通常是 0
        list.Add(1); // 添加第 1 个元素,可能触发第一次扩容
        Console.WriteLine(list.Capacity); // 输出容量,通常变成 4
        list.Add(2); // 添加第 2 个元素,不需要扩容
        list.Add(3); // 添加第 3 个元素,不需要扩容
        list.Add(4); // 添加第 4 个元素,不需要扩容
        list.Add(5); // 添加第 5 个元素,容量不够时会扩容
        Console.WriteLine(list.Capacity); // 输出容量,通常变成 8
        Console.WriteLine(list.Count); // 输出元素数量,结果是 5
    } // Main 方法结束
} // Program 类结束

7. 面试高分回答

IMPORTANT

List<T> 底层是数组,优点是随机访问快、缓存友好,list[i] 是 O(1)。它通过 Count 记录元素数量,通过 Capacity 表示底层数组容量。添加元素时如果容量够就是 O(1),容量不够会申请更大的数组并复制旧元素,通常按翻倍扩容,所以单次扩容是 O(n),但 Add 的均摊复杂度是 O(1)。中间插入和删除因为要移动元素,所以是 O(n)。在 Unity 里,如果知道大概数量,最好提前设置容量,避免运行时频繁扩容产生 GC 和性能抖动。

Dictionary 底层和哈希冲突 ​

Dictionary<TKey, TValue> 底层是哈希表,核心是 buckets 桶数组 + entries 数据数组。

csharp-dictionary-internals-hash-collision

1. Dictionary 底层大概有什么?

可以简化理解成:

c
int[] buckets;      // 桶数组,用来快速定位元素大概在哪
Entry[] entries;    // 数据数组,真正保存 key、value、hashCode、next

每个 Entry 大概包含:

c
hashCode // key 的哈希值
next     // 哈希冲突时,指向下一个 entry
key      // 键
value    // 值

所以 Dictionary 不是简单的二维数组,也不是直接链表,而是“数组 + 链式冲突”。

2. 查找过程

比如:

c
dict["apple"]

底层大概会做:

先调用 apple.GetHashCode() 得到哈希值。 再用哈希值算出桶下标。 去 buckets 找到对应的 entry。 如果这个桶里有多个 key,就沿着 next 往下找。 每个节点先比较 hashCode,再用 Equals 判断 key 是否真的相等。

注意:哈希值相同,不代表 key 一定相等。 所以一定还要调用 Equals。

3. 哈希冲突是什么?

哈希冲突就是:

不同的 key,算出来后落到了同一个桶里。

比如:

apple -> bucket 0orange -> bucket 0

它们桶位置一样,但 key 不一样。 这时候 Dictionary 会通过 next 把它们串起来。

查找时先进入桶,再沿链比较:

c
bucket[0] -> entry apple -> entry orange

4. 复杂度

哈希分布好时:

查找:平均 O(1) 插入:平均 O(1) 删除:平均 O(1)

哈希冲突严重时:

查找可能退化到 O(n)

所以 GetHashCode() 的质量很重要。

5. 代码演示:故意制造哈希冲突

c
using System; // 引入 System 命名空间,用于 Console 输出
using System.Collections.Generic; // 引入集合命名空间,用于 Dictionary 和 IEqualityComparer
public sealed class BadHashComparer : IEqualityComparer<string> // 定义一个故意制造哈希冲突的比较器
{ // BadHashComparer 类开始
    public bool Equals(string x, string y) // 定义 key 是否相等的判断逻辑
    { // Equals 方法开始
        return string.Equals(x, y, StringComparison.Ordinal); // 用字符串内容判断两个 key 是否真正相等
    } // Equals 方法结束
    public int GetHashCode(string obj) // 定义 key 的哈希值计算逻辑
    { // GetHashCode 方法开始
        return 1; // 故意让所有 key 的哈希值都一样,从而落到同一个桶
    } // GetHashCode 方法结束
} // BadHashComparer 类结束
public class Program // 定义程序入口类
{ // Program 类开始
    public static void Main() // 定义主函数
    { // Main 方法开始
        Dictionary<string, int> map = new Dictionary<string, int>(new BadHashComparer()); // 创建字典,并使用故意冲突的比较器
        map["apple"] = 10; // 添加 apple,底层会进入某个桶
        map["orange"] = 20; // 添加 orange,因为哈希相同,会和 apple 发生冲突
        map["banana"] = 30; // 添加 banana,同样会落到相同桶的冲突链上
        Console.WriteLine(map["orange"]); // 输出 20,说明即使冲突,也能通过 Equals 找到正确 key
    } // Main 方法结束
} // Program 类结束

6. 扩容时发生什么?

当元素越来越多,冲突概率升高,容量不够时,Dictionary 会扩容。

扩容大概会做:

创建更大的 buckets。 创建或调整 entries。 重新根据 hash 分配桶位置。 让冲突链重新组织。

所以扩容那一次比较贵,但不是每次插入都扩容,因此平均插入仍然接近 O(1)。

7. 面试高分回答

可以这样说:

NOTE

Dictionary 底层是哈希表,主要由桶数组和 entry 数组组成。添加或查找时,会先通过 GetHashCode() 算出哈希值,再映射到某个 bucket。真正的 key 和 value 存在 entries 里,entry 还保存 hashCode 和 next。如果多个 key 落到同一个桶,就通过 next 形成冲突链,查找时沿链先比 hash,再用 Equals 判断 key 是否真的相等。哈希分布好时查找接近 O(1),冲突严重时会退化到 O(n)。所以自定义 key 时必须保证相等对象 hash 相同,并且不要修改作为 key 的字段。

委托和事件区别 ​

委托是“方法引用类型”,事件是“基于委托的安全发布订阅机制”。

csharp-delegate-event-difference

1. 委托是什么?

委托可以理解成“能保存方法的变量”。

它可以指向一个方法,也可以通过 += 指向多个方法,形成多播委托。

比如:

c
Action callback = SomeMethod;
callback();

意思是:callback 这个变量里保存了 SomeMethod 这个方法,调用 callback() 就等于调用它指向的方法。

2. 事件是什么?

事件是对委托的一层封装。

事件通常用来做“发布者通知订阅者”。

比如玩家死亡:

Player 是发布者。 UI、Audio、BattleSystem 是订阅者。 玩家死亡时,Player 内部触发事件,外部系统收到通知。

3. 最大区别

如果你直接暴露委托字段,外部可以做这些危险操作:

c
player.OnDead = null;
player.OnDead();

也就是说,外部可以清空所有订阅者,甚至可以伪造事件触发。

但如果使用 event:

c
public event Action OnDead;

类外只能:

c
player.OnDead += SomeMethod;
player.OnDead -= SomeMethod;

不能在类外直接赋值覆盖,也不能在类外直接调用。

这就是 event 的核心价值:限制外部权限,保护事件触发权。

4. 代码对比

c
using System; // 引入 System 命名空间,用于使用 Action 委托
public class Player // 定义玩家类,作为事件发布者
{ // Player 类开始
    public Action BadOnDead; // 直接暴露委托字段,外部可以赋值、清空和调用
    public event Action GoodOnDead; // 使用 event 包装委托,外部只能订阅和取消订阅
    public void Die() // 定义玩家死亡方法
    { // Die 方法开始
        BadOnDead?.Invoke(); // 在类内部触发普通委托
        GoodOnDead?.Invoke(); // 在类内部触发事件
    } // Die 方法结束
} // Player 类结束
public class Program // 定义程序入口类
{ // Program 类开始
    public static void Main() // 定义主函数
    { // Main 方法开始
        Player player = new Player(); // 创建玩家对象
        player.BadOnDead += ShowDeathUI; // 给普通委托添加订阅方法
        player.GoodOnDead += ShowDeathUI; // 给事件添加订阅方法
        player.BadOnDead = null; // 普通委托可以被外部清空,很危险
        player.BadOnDead?.Invoke(); // 普通委托可以被外部直接调用,也很危险
        player.GoodOnDead -= ShowDeathUI; // 事件允许外部取消订阅
        player.GoodOnDead += ShowDeathUI; // 事件允许外部重新订阅
        player.Die(); // 只有 Player 内部逻辑决定什么时候触发死亡通知
    } // Main 方法结束
    private static void ShowDeathUI() // 定义死亡 UI 回调方法
    { // ShowDeathUI 方法开始
        Console.WriteLine("显示死亡界面"); // 输出死亡界面提示
    } // ShowDeathUI 方法结束
} // Program 类结束

5. 面试高分回答

NOTE

委托是一种类型,用来保存方法引用,可以调用,也可以组合成多播委托。事件是基于委托的封装,它限制了外部访问权限:外部只能 += 订阅和 -= 取消订阅,不能直接赋值覆盖,也不能直接触发事件。这样可以避免订阅列表被外部清空,也能保证事件只能由发布者内部触发。所以业务通知、状态变化、UI 刷新、Unity 中生命周期订阅,通常用 event 更安全。

协程原理和线程区别 ​

协程不是线程。协程是 Unity 主线程上的“分帧执行状态机”,线程是操作系统调度的并行执行流。

csharp-unity-coroutine-vs-thread

1. 协程原理

Unity 协程本质依赖 IEnumerator。

当你写:

c
StartCoroutine(MyCoroutine());

Unity 会拿到一个 IEnumerator 对象,然后在合适的时机不断调用它的 MoveNext()。

协程执行到 yield return 时会暂停,把等待条件交给 Unity。等条件满足后,Unity 下一次继续调用 MoveNext(),协程就从上次暂停的位置继续执行。

所以协程不是并行,而是:

执行一段 -> 暂停 -> 等待 -> 下一帧或指定时间后继续。

2. yield return null 是什么?

yield return null 表示:

当前协程暂停,下一帧继续执行。

它不是睡眠线程,也不是开新线程,只是告诉 Unity:

“我这一帧先停,下一帧再从这里往下跑。”

3. 和线程区别

协程通常运行在 Unity 主线程上,可以直接操作 Transform、GameObject、UI 等 Unity API。

线程是系统级执行流,可以真的和主线程并行,适合做文件 IO、网络、路径计算、大量纯数据计算。

但子线程通常不能直接操作 Unity 对象,比如不能在子线程里改:

c
transform.position

因为大多数 Unity API 不是线程安全的。

4. 代码示例

c
using System.Collections; // 引入协程需要的 IEnumerator 命名空间
using UnityEngine; // 引入 Unity 引擎命名空间
public class CoroutineDemo : MonoBehaviour // 定义一个 Unity 脚本类
{ // CoroutineDemo 类开始
    private void Start() // Unity 在脚本启用后调用 Start
    { // Start 方法开始
        StartCoroutine(PrintStepByStep()); // 启动协程,让逻辑分多帧执行
    } // Start 方法结束
    private IEnumerator PrintStepByStep() // 定义一个返回 IEnumerator 的协程方法
    { // 协程方法开始
        Debug.Log("第 1 步:当前帧执行"); // 当前帧立即输出第一句话
        yield return null; // 暂停协程,等到下一帧再继续执行
        Debug.Log("第 2 步:下一帧执行"); // 下一帧恢复后输出第二句话
        yield return new WaitForSeconds(1f); // 暂停协程,等待 1 秒游戏时间
        Debug.Log("第 3 步:1 秒后执行"); // 等待结束后继续输出第三句话
    } // 协程方法结束
} // CoroutineDemo 类结束

5. 面试高分回答

可以这样说:

IMPORTANT

Unity 协程不是线程,它一般仍然跑在主线程。协程方法返回 IEnumerator,C# 编译器会把带 yield 的方法编译成状态机。Unity 调用 StartCoroutine 后保存这个迭代器对象,每次推进时调用 MoveNext()。当执行到 yield return null、WaitForSeconds 等等待对象时,协程暂停;Unity 在后续帧判断等待条件满足后,再继续调用 MoveNext()。线程则是操作系统调度的执行单元,可以真正并行,但需要考虑线程安全,并且不能直接操作大多数 Unity API。协程适合分帧流程、延迟执行、动画流程控制;线程适合后台计算、IO、网络等不直接碰 Unity 对象的任务。

async/await 和协程区别 ​

async/await 和协程都能“暂停后继续”,但它们不是一套东西。协程偏 Unity 分帧流程,async/await 偏 C# 异步任务流程。

csharp-unity-async-await-vs-coroutine

1. 协程是什么?

Unity 协程基于 IEnumerator。

它由 Unity 的 PlayerLoop 推进,执行到 yield return 时暂停,等条件满足后再继续。

比如:

yield return null:下一帧继续。 yield return new WaitForSeconds(1f):等待 1 秒游戏时间。 yield break:结束协程。

协程通常仍然运行在主线程,所以它不是线程,也不会自动并行。

2. async/await 是什么?

async/await 是 C# 的异步语法,本质也是状态机。

执行到 await 时,当前方法挂起,等待一个 Task 或 awaiter 完成。完成后,后面的代码作为 continuation 继续执行。

比如:

await Task.Delay(1000):等待约 1 秒真实时间。 await SomeNetworkTask():等待网络任务完成。 await Task.Run(...):把 CPU 计算丢到线程池执行。

重点:await 本身不等于开线程。只有像 Task.Run 这种才通常会用线程池。

3. 最大区别

协程:

适合 Unity 内的分帧逻辑、动画流程、延迟执行、等待下一帧、等待游戏时间。 生命周期通常跟 MonoBehaviour 相关。 取消一般用 StopCoroutine。 不方便直接返回结果。 异常通常会被 Unity 打日志,协程停止。

async/await:

适合网络请求、文件 IO、异步资源流程、需要返回结果的任务。 可以用 Task<T> 返回结果。 可以用 try/catch 捕获异常。 可以用 CancellationToken 做取消。 不会自动跟随 GameObject 销毁,必须自己处理取消。

4. 代码对比

c
using System.Collections; // 引入 IEnumerator,用于编写 Unity 协程
using System.Threading; // 引入 CancellationTokenSource,用于取消 async 任务
using System.Threading.Tasks; // 引入 Task,用于 async/await 异步流程
using UnityEngine; // 引入 Unity 引擎命名空间
public class AsyncCoroutineDemo : MonoBehaviour // 定义一个 Unity 演示脚本
{ // 类开始
    private CancellationTokenSource _cts; // 保存取消令牌源,用于在对象销毁时取消异步任务
    private void Start() // Unity 在脚本启动时调用
    { // Start 方法开始
        _cts = new CancellationTokenSource(); // 创建取消令牌源
        StartCoroutine(CoroutineExample()); // 启动协程示例
        AsyncExample(_cts.Token); // 启动 async 示例
    } // Start 方法结束
    private IEnumerator CoroutineExample() // 定义协程方法
    { // 协程方法开始
        Debug.Log("协程:当前帧执行"); // 输出协程当前帧逻辑
        yield return null; // 暂停协程,下一帧继续
        Debug.Log("协程:下一帧继续"); // 输出协程下一帧逻辑
        yield return new WaitForSeconds(1f); // 等待 1 秒游戏时间,受 Time.timeScale 影响
        Debug.Log("协程:等待游戏时间后继续"); // 输出协程等待后的逻辑
    } // 协程方法结束
    private async void AsyncExample(CancellationToken token) // 定义 async 示例方法,Unity 入口处常见 async void
    { // AsyncExample 方法开始
        Debug.Log("async:开始执行"); // 输出 async 开始信息
        await Task.Delay(1000, token); // 等待约 1 秒真实时间,并支持取消
        if (token.IsCancellationRequested) // 判断任务是否已经被取消
        { // if 语句开始
            return; // 如果已经取消,就直接退出,避免继续访问销毁对象
        } // if 语句结束
        Debug.Log("async:Task 完成后继续"); // 输出 async 等待完成后的逻辑
    } // AsyncExample 方法结束
    private void OnDestroy() // Unity 在对象销毁时调用
    { // OnDestroy 方法开始
        _cts?.Cancel(); // 取消还没完成的 async 任务
        _cts?.Dispose(); // 释放 CancellationTokenSource 内部资源
    } // OnDestroy 方法结束
} // 类结束

5. 面试高分回答

可以这样说:

NOTE

协程和 async/await 都会被编译或表现为状态机,都能做到“执行到某个点暂停,之后再继续”。区别是 Unity 协程基于 IEnumerator 和 yield return,由 Unity PlayerLoop 在主线程上推进,适合游戏流程、分帧执行、等待下一帧或等待游戏时间。async/await 基于 Task 或 awaiter,适合异步 IO、网络、文件、需要返回值和异常传播的流程,可以用 Task<T> 返回结果,用 try/catch 处理异常,用 CancellationToken 取消。两者都不等于自动开线程,真正并行通常要用 Task.Run 或显式线程,而且后台线程不能直接操作大多数 Unity API。

Awake、Start、Update 执行顺序 ​

执行顺序:Awake -> OnEnable -> Start -> Update。

unity-awake-start-update-order

1. Awake 什么时候执行?

Awake 是脚本实例加载时最早执行的生命周期函数。

它适合做“自身初始化”:

拿自己身上的组件。 初始化字段。 建立内部数据结构。 不要太依赖其他对象已经初始化完。

比如:

c
using UnityEngine; // 引入 Unity 引擎命名空间
public class LifeCycleDemo : MonoBehaviour // 定义生命周期演示脚本
{ // 类开始
    private void Awake() // 脚本实例加载时调用
    { // Awake 方法开始
        Debug.Log("Awake:初始化自己"); // 输出 Awake 执行信息
    } // Awake 方法结束
    private void Start() // 第一次 Update 前调用
    { // Start 方法开始
        Debug.Log("Start:其他对象通常也 Awake 完了"); // 输出 Start 执行信息
    } // Start 方法结束
    private void Update() // 每帧调用
    { // Update 方法开始
        Debug.Log("Update:每帧执行"); // 输出 Update 执行信息
    } // Update 方法结束
} // 类结束

2. Start 什么时候执行?

Start 在第一次 Update 之前执行,而且通常只执行一次。

它适合做“依赖其他对象的初始化”。

因为场景启动时,Unity 会尽量先把场景里对象的 Awake 都执行完,再进入 Start 阶段。

所以面试里可以说:

Awake 更适合初始化自己。 Start 更适合等其他对象初始化后,再做关联逻辑。

3. Update 什么时候执行?

Update 是每帧执行一次。

它适合做:

输入检测。 普通逻辑轮询。 非物理移动。 状态刷新。

但不要在 Update 里频繁 new、频繁 Find、频繁 GetComponent,否则容易造成性能问题。

4. 多个脚本之间顺序要注意

单个脚本里:

Awake 一定早于 Start,Start 一定早于第一次 Update。

但多个脚本之间:

A.Awake 和 B.Awake 谁先执行,不要默认依赖。 A.Start 和 B.Start 谁先执行,也不要默认依赖。 如果必须控制顺序,可以用 Script Execution Order,或者自己设计初始化管理器。

5. 面试高分回答

IMPORTANT

Unity 生命周期里,Awake 最早,脚本实例加载时执行,适合初始化自身数据;对象启用后会执行 OnEnable;Start 在第一次 Update 之前执行一次,适合做依赖其他对象已经初始化完成的逻辑;Update 则是每帧调用。场景启动时通常所有 Awake 会先于 Start 执行,但不同对象之间的 Awake 先后顺序不要依赖,必要时要用脚本执行顺序或统一初始化流程来控制。

Update、FixedUpdate、LateUpdate 区别 ​

Update、FixedUpdate、LateUpdate 的区别:一个跟渲染帧走,一个跟物理步走,一个在本帧逻辑后收尾。

unity-update-fixed-lateupdate-difference

1. Update

Update 每个渲染帧调用一次。

帧率高,它调用得多;帧率低,它调用得少。

适合做:

读取输入。 普通逻辑更新。 UI 状态刷新。 非物理移动。

移动时通常乘:

c
Time.deltaTime

2. FixedUpdate

FixedUpdate 按固定物理时间步调用,不是每帧固定调用一次。

如果一帧很快,可能这一帧没有 FixedUpdate。 如果一帧很卡,Unity 可能补跑多次 FixedUpdate。

适合做:

Rigidbody.AddForce。 Rigidbody.MovePosition。 物理检测和物理移动。 依赖物理模拟的逻辑。

物理里通常使用:

c
Time.fixedDeltaTime

3. LateUpdate

LateUpdate 在本帧所有 Update 执行之后调用。

适合做依赖“本帧最终结果”的逻辑。

最典型就是相机跟随:

角色在 Update 里移动完。 相机在 LateUpdate 里再跟随。 这样相机拿到的是角色本帧最终位置,画面更稳定。

4. 代码示例

c
using UnityEngine; // 引入 Unity 引擎命名空间
public class UpdateOrderDemo : MonoBehaviour // 定义生命周期演示脚本
{ // 类开始
    public Rigidbody PlayerRigidbody; // 保存玩家刚体引用,用于物理移动
    public Transform CameraTransform; // 保存相机 Transform 引用,用于相机跟随
    public Transform Target; // 保存相机跟随目标引用
    private Vector3 _inputDirection; // 缓存输入方向,给 FixedUpdate 使用
    private void Update() // 每个渲染帧调用一次
    { // Update 方法开始
        float horizontal = Input.GetAxisRaw("Horizontal"); // 在 Update 中读取横向输入
        float vertical = Input.GetAxisRaw("Vertical"); // 在 Update 中读取纵向输入
        _inputDirection = new Vector3(horizontal, 0f, vertical).normalized; // 把输入方向缓存起来
    } // Update 方法结束
    private void FixedUpdate() // 按固定物理时间步调用
    { // FixedUpdate 方法开始
        Vector3 move = _inputDirection * 5f * Time.fixedDeltaTime; // 用 fixedDeltaTime 计算物理位移
        PlayerRigidbody.MovePosition(PlayerRigidbody.position + move); // 通过 Rigidbody 移动角色
    } // FixedUpdate 方法结束
    private void LateUpdate() // 在 Update 之后调用
    { // LateUpdate 方法开始
        Vector3 offset = new Vector3(0f, 5f, -8f); // 定义相机相对目标的偏移
        CameraTransform.position = Target.position + offset; // 在 LateUpdate 中根据目标最终位置更新相机
        CameraTransform.LookAt(Target); // 让相机看向目标
    } // LateUpdate 方法结束
} // 类结束

5. 面试高分回答

CAUTION

Update 是跟渲染帧走的,每帧调用一次,适合输入和普通逻辑;FixedUpdate 是跟固定物理时间步走的,一帧可能执行 0 次、1 次或多次,适合刚体、力、物理模拟;LateUpdate 在本帧 Update 之后执行,适合相机跟随、动画后处理、依赖本帧最终状态的逻辑。常见做法是 Update 读取输入,缓存输入结果,FixedUpdate 根据缓存做物理移动,LateUpdate 再做相机跟随。

Time.deltaTime 的作用 ​

Time.deltaTime 的作用:表示上一帧到当前帧经过的时间,单位是秒。它最常用于让移动、计时、冷却等逻辑不受帧率影响。

unity-time-deltatime-purpose

1. 为什么移动要乘 deltaTime?

因为我们通常写的速度是“每秒多少单位”。

比如角色速度是:

speed = 6f;

意思是每秒移动 6 米。

但游戏不是每秒只执行一次,而是每帧执行一次。

所以每一帧真正移动的距离应该是:

本帧位移 = 每秒速度 * 本帧耗时

也就是:

c
move = speed * Time.deltaTime;

60 FPS 时,deltaTime 大约是 0.0167。 30 FPS 时,deltaTime 大约是 0.0333。

帧率高,每帧走得少,但一秒帧数多。 帧率低,每帧走得多,但一秒帧数少。 最终一秒总距离接近一致。

2. 不乘会怎样?

如果你这样写:

c
transform.position += Vector3.forward * speed;

那就是“每帧移动 speed”。

60 FPS 每秒移动 60 次。 30 FPS 每秒移动 30 次。 所以高帧率机器角色会跑得更快。

3. 正确移动代码

c
using UnityEngine; // 引入 Unity 引擎命名空间
public class MoveDemo : MonoBehaviour // 定义角色移动脚本
{ // 类开始
    public float Speed = 6f; // 定义每秒移动速度
    private void Update() // 每一帧调用一次
    { // Update 方法开始
        float horizontal = Input.GetAxisRaw("Horizontal"); // 读取横向输入
        float vertical = Input.GetAxisRaw("Vertical"); // 读取纵向输入
        Vector3 direction = new Vector3(horizontal, 0f, vertical).normalized; // 计算标准化移动方向
        Vector3 offset = direction * Speed * Time.deltaTime; // 用每秒速度乘本帧耗时,得到本帧位移
        transform.position += offset; // 把本帧位移加到当前位置上
    } // Update 方法结束
} // 类结束

4. 计时器也常用 deltaTime

比如技能冷却:

c
using UnityEngine; // 引入 Unity 引擎命名空间
public class CooldownDemo : MonoBehaviour // 定义技能冷却演示脚本
{ // 类开始
    private float _cooldown = 3f; // 定义剩余冷却时间
    private void Update() // 每一帧调用一次
    { // Update 方法开始
        if (_cooldown > 0f) // 判断技能是否还在冷却中
        { // if 语句开始
            _cooldown -= Time.deltaTime; // 每帧减去本帧经过的时间
        } // if 语句结束
    } // Update 方法结束
} // 类结束

5. 常见区别

Time.deltaTime:

受 Time.timeScale 影响。 适合普通游戏逻辑、移动、冷却、动画计时。

Time.fixedDeltaTime:

用于 FixedUpdate。 适合物理时间步,比如刚体移动、力、物理模拟。

Time.unscaledDeltaTime:

不受 Time.timeScale 影响。 适合暂停菜单、UI 动画、真实时间倒计时。

6. 面试高分回答

IMPORTANT

可以这样说:

Time.deltaTime 表示上一帧到当前帧经过的秒数。因为 Update 的调用频率受帧率影响,如果移动时直接按“每帧速度”加位置,高帧率机器会移动更快。正确做法是把“每秒速度”乘以 deltaTime,得到“当前帧应该移动的距离”,从而让移动、冷却、计时等逻辑尽量做到帧率无关。物理逻辑一般放在 FixedUpdate,使用 Time.fixedDeltaTime;暂停时如果 UI 仍要动,可以用 Time.unscaledDeltaTime。

Rigidbody 和 CharacterController 区别 ​

Rigidbody 是物理刚体,CharacterController 是角色控制器。一个偏真实物理,一个偏稳定手感。

unity-rigidbody-charactercontroller-difference

1. Rigidbody 是什么?

Rigidbody 是 Unity 物理系统里的刚体组件。

它会受这些东西影响:

重力。 质量。 力。 速度。 碰撞反弹。 摩擦。 物理材质。

所以它适合做真实物理交互,比如箱子、球、载具、被击飞的敌人、可推动物体。

2. CharacterController 是什么?

CharacterController 是 Unity 提供的角色胶囊控制器。

它不是普通刚体,不会自动被重力拉下去,也不会自动被 AddForce 推走。

你通常需要自己算移动速度、重力、跳跃,然后调用:

controller.Move(move);

它适合做人形角色、第三人称角色、FPS 角色,因为手感稳定,不容易被物理系统乱推。

3. 最大区别

Rigidbody:

交给物理系统算。 适合真实碰撞、受力、反弹、击飞。 常在 FixedUpdate 里操作。 用 AddForce、velocity、MovePosition 等方式移动。

CharacterController:

主要由脚本控制。 适合稳定角色移动。 重力、跳跃、斜坡、速度要自己处理。 用 Move 或 SimpleMove 移动。

4. Rigidbody 移动示例

c
using UnityEngine; // 引入 Unity 引擎命名空间
public class RigidbodyMoveDemo : MonoBehaviour // 定义 Rigidbody 移动脚本
{ // 类开始
    public Rigidbody Rb; // 保存 Rigidbody 组件引用
    public float Speed = 5f; // 定义每秒移动速度
    private Vector3 _input; // 缓存输入方向
    private void Update() // 每帧调用,用来读取输入
    { // Update 方法开始
        float h = Input.GetAxisRaw("Horizontal"); // 读取横向输入
        float v = Input.GetAxisRaw("Vertical"); // 读取纵向输入
        _input = new Vector3(h, 0f, v).normalized; // 计算并缓存移动方向
    } // Update 方法结束
    private void FixedUpdate() // 固定物理帧调用,用来操作刚体
    { // FixedUpdate 方法开始
        Vector3 targetVelocity = _input * Speed; // 根据输入计算目标水平速度
        Rb.velocity = new Vector3(targetVelocity.x, Rb.velocity.y, targetVelocity.z); // 设置刚体速度并保留竖直速度
    } // FixedUpdate 方法结束
} // 类结束

5. CharacterController 移动示例

c
using UnityEngine; // 引入 Unity 引擎命名空间
public class CharacterControllerMoveDemo : MonoBehaviour // 定义 CharacterController 移动脚本
{ // 类开始
    public CharacterController Controller; // 保存 CharacterController 组件引用
    public float Speed = 5f; // 定义角色水平移动速度
    public float Gravity = -9.8f; // 定义重力加速度
    private float _verticalVelocity; // 保存竖直方向速度
    private void Update() // 每帧调用,用来控制角色移动
    { // Update 方法开始
        float h = Input.GetAxisRaw("Horizontal"); // 读取横向输入
        float v = Input.GetAxisRaw("Vertical"); // 读取纵向输入
        Vector3 move = new Vector3(h, 0f, v).normalized * Speed; // 计算水平移动速度
        if (Controller.isGrounded && _verticalVelocity < 0f) // 判断角色是否在地面且正在下落
        { // if 语句开始
            _verticalVelocity = -2f; // 给一个小的向下速度,让角色稳定贴地
        } // if 语句结束
        _verticalVelocity += Gravity * Time.deltaTime; // 手动累计重力速度
        move.y = _verticalVelocity; // 把竖直速度写入移动向量
        Controller.Move(move * Time.deltaTime); // 让控制器按本帧位移移动并处理碰撞
    } // Update 方法结束
} // 类结束

6. 面试高分回答

可以这样说:

CAUTION

Rigidbody 是物理刚体,移动和碰撞会进入 Unity 物理系统,受到质量、重力、力、摩擦、碰撞响应影响,适合需要真实物理效果的对象。CharacterController 是角色控制器,它本身不按刚体那套受力逻辑运动,而是通过脚本调用 Move 或 SimpleMove 来移动,并提供碰撞检测和贴地信息,适合需要稳定手感的角色移动。简单说,Rigidbody 是物理驱动,CharacterController 是脚本驱动;真实物理交互用 Rigidbody,精确可控的玩家角色常用 CharacterController。

Trigger 和 Collision 区别 ​

Collision 是真实碰撞,Trigger 是触发检测。

unity-trigger-collision-difference

1. Collision 是什么?

Collision 表示真实物理碰撞。

两个物体碰到后,会产生物理阻挡、接触点、法线、相对速度等信息。

比如:

角色撞墙。 箱子被推。 子弹打到墙。 物体落到地面。

常用回调:

c
OnCollisionEnter
OnCollisionStay
OnCollisionExit

2. Trigger 是什么?

Trigger 表示触发器检测。

它不会把物体挡住,只负责检测某个物体是否进入、停留、离开一个区域。

比如:

拾取金币。 进入传送门。 进入技能范围。 进入怪物仇恨范围。 触发剧情区域。

常用回调:

c
OnTriggerEnter
OnTriggerStay
OnTriggerExit

3. 触发条件区别

Collision:

双方都有 Collider。 双方的 Collider.isTrigger 都不能勾选。 通常至少一方要有 Rigidbody。 会产生真实物理接触和阻挡。

Trigger:

双方都有 Collider。 至少一方勾选 isTrigger。 通常至少一方要有 Rigidbody。 不会产生物理阻挡,只发送触发事件。

4. 代码示例

c
using UnityEngine; // 引入 Unity 引擎命名空间
public class TriggerCollisionDemo : MonoBehaviour // 定义触发和碰撞演示脚本
{ // 类开始
    private void OnTriggerEnter(Collider other) // 当其他 Collider 进入触发器时调用
    { // OnTriggerEnter 方法开始
        Debug.Log("Trigger:进入检测范围 " + other.name); // 输出进入触发区域的对象名字
    } // OnTriggerEnter 方法结束
    private void OnTriggerStay(Collider other) // 当其他 Collider 停留在触发器内时调用
    { // OnTriggerStay 方法开始
        Debug.Log("Trigger:持续停留 " + other.name); // 输出持续停留在触发区域的对象名字
    } // OnTriggerStay 方法结束
    private void OnTriggerExit(Collider other) // 当其他 Collider 离开触发器时调用
    { // OnTriggerExit 方法开始
        Debug.Log("Trigger:离开检测范围 " + other.name); // 输出离开触发区域的对象名字
    } // OnTriggerExit 方法结束
    private void OnCollisionEnter(Collision collision) // 当发生真实物理碰撞时调用
    { // OnCollisionEnter 方法开始
        ContactPoint contact = collision.contacts[0]; // 获取第一个碰撞接触点
        Debug.Log("Collision:碰到 " + collision.gameObject.name); // 输出碰撞到的对象名字
        Debug.Log("接触点:" + contact.point); // 输出碰撞接触点的位置
        Debug.Log("法线:" + contact.normal); // 输出碰撞表面的法线方向
    } // OnCollisionEnter 方法结束
} // 类结束

5. 2D 和 3D 不要混用

3D 物理用:

c
OnTriggerEnter(Collider other)
OnCollisionEnter(Collision collision)

2D 物理用:

c
OnTriggerEnter2D(Collider2D other)
OnCollisionEnter2D(Collision2D collision)

Collider 和 Collider2D 不是一套系统,Rigidbody 和 Rigidbody2D 也不是一套系统。

6. 面试高分回答

CAUTION

Collision 是真实物理碰撞,要求 Collider 不勾选 isTrigger,碰撞后会进入物理求解流程,产生阻挡、接触点、碰撞法线和相对速度,适合撞墙、落地、子弹命中墙体这类场景。Trigger 是触发器,至少一方 Collider 勾选 isTrigger,不会产生物理阻挡,只负责检测进入、停留、离开,适合拾取物、技能范围、传送门、怪物感知范围。两者通常都需要至少一方带 Rigidbody 才能稳定收到回调。

Raycast 的用途 ​

Raycast 的作用:从一个点沿某个方向发射一条射线,检测这条线上是否碰到了 Collider。

unity-raycast-uses

1. Raycast 能做什么?

Raycast 可以理解成“往世界里打一条探针线”。

它能回答这些问题:

鼠标点到了哪个物体? 枪口朝前有没有打中敌人? 角色脚下是不是地面? 玩家和怪物之间有没有墙挡住? 前方有没有障碍物? 技能释放方向上命中了谁?

2. Raycast 命中后能拿到什么?

常用的是 RaycastHit。

里面可以拿到:

hit.point:命中的世界坐标。 hit.normal:命中表面的法线方向。 hit.distance:起点到命中点的距离。 hit.collider:命中的碰撞体。 hit.transform:命中物体的 Transform。

3. 常见用途

点击地面移动:

从摄像机向鼠标位置发射射线,命中地面后,把命中点作为移动目标。

射击检测:

从枪口或摄像机中心发射射线,命中敌人就扣血,命中墙就播放弹孔特效。

地面检测:

从角色脚底向下发射短射线,判断角色是否站在地面上。

视野检测:

从怪物眼睛到玩家位置发射射线,如果中间先打到墙,说明玩家被遮挡。

交互检测:

从玩家眼前向前发射射线,检测门、宝箱、NPC、可拾取物。

4. 代码示例:鼠标点击地面

c
using UnityEngine; // 引入 Unity 引擎命名空间
public class ClickGroundRaycastDemo : MonoBehaviour // 定义点击地面射线检测脚本
{ // 类开始
    public Camera MainCamera; // 保存主摄像机引用
    public LayerMask GroundMask; // 保存地面 LayerMask,只检测地面层
    private void Update() // 每帧调用一次
    { // Update 方法开始
        if (Input.GetMouseButtonDown(0)) // 判断鼠标左键是否刚按下
        { // if 语句开始
            Ray ray = MainCamera.ScreenPointToRay(Input.mousePosition); // 从摄像机通过鼠标位置生成一条射线
            bool hasHit = Physics.Raycast(ray, out RaycastHit hit, 100f, GroundMask); // 发射射线并只检测地面层
            if (hasHit) // 判断射线是否命中了地面
            { // if 语句开始
                Debug.Log("点击地面位置:" + hit.point); // 输出命中的世界坐标
            } // if 语句结束
        } // if 语句结束
    } // Update 方法结束
} // 类结束

5. 代码示例:射击命中

c
using UnityEngine; // 引入 Unity 引擎命名空间
public class GunRaycastDemo : MonoBehaviour // 定义射击射线检测脚本
{ // 类开始
    public Transform FirePoint; // 保存枪口位置
    public float MaxDistance = 100f; // 定义射线最大检测距离
    public LayerMask HitMask; // 定义可以被射击命中的层
    private void Update() // 每帧调用一次
    { // Update 方法开始
        if (Input.GetMouseButtonDown(0)) // 判断是否按下鼠标左键
        { // if 语句开始
            Shoot(); // 调用射击方法
        } // if 语句结束
    } // Update 方法结束
    private void Shoot() // 定义射击方法
    { // Shoot 方法开始
        Vector3 origin = FirePoint.position; // 获取射线起点
        Vector3 direction = FirePoint.forward; // 获取射线方向
        bool hasHit = Physics.Raycast(origin, direction, out RaycastHit hit, MaxDistance, HitMask); // 发射射线检测命中
        Debug.DrawRay(origin, direction * MaxDistance, Color.red, 1f); // 在 Scene 视图画出射线方便调试
        if (hasHit) // 判断是否命中目标
        { // if 语句开始
            Debug.Log("射击命中:" + hit.collider.name); // 输出命中物体名称
            Debug.Log("命中点:" + hit.point); // 输出命中点坐标
        } // if 语句结束
    } // Shoot 方法结束
} // 类结束

6. 面试高分回答

可以这样说:

IMPORTANT

Raycast 是物理射线检测,从一个起点沿指定方向发出射线,检测路径上是否碰到 Collider。命中后可以通过 RaycastHit 拿到命中点、法线、距离、Collider、Transform 等信息。它常用于鼠标点击选中、射击命中、地面检测、视野遮挡、障碍物检测和交互检测。实际项目中要注意用 maxDistance 限制距离,用 LayerMask 过滤无关层,避免每帧大量无过滤射线造成性能浪费;如果是 2D 游戏,要用 Physics2D.Raycast,UI 点击则通常走 EventSystem 或 GraphicRaycaster。

Prefab 和 Prefab Variant ​

Prefab 是“可复用对象模板”,Prefab Variant 是“基于某个 Prefab 的变体模板”。

unity-prefab-prefab-variant

1. Prefab 是什么?

Prefab 是保存在 Project 里的对象模板。

它可以保存:

GameObject 层级。 Transform。 组件。 组件参数。 子物体。 引用关系。

比如你做了一个 Enemy.prefab,里面有模型、碰撞体、Animator、AI 脚本、血条挂点。之后场景里可以实例化很多个敌人,它们都来自同一个模板。

2. Prefab Instance 是什么?

场景里由 Prefab 创建出来的对象叫 Prefab Instance。

它默认继承 Prefab 的配置。 你在场景实例上改了某些属性,这些改动叫 Override。

比如:

Prefab 默认血量是 100。 某个场景里的敌人实例改成 150。 这个 150 就是这个实例相对 Prefab 的 Override。

3. Prefab Variant 是什么?

Prefab Variant 是基于另一个 Prefab 创建出来的新 Prefab。

它像“资源层面的继承”。

比如:

EnemyBase.prefab:基础敌人模板。 Goblin.prefab:基于 EnemyBase 的变体,只改速度和模型。 Orc.prefab:基于 EnemyBase 的变体,只改血量和体型。 Boss.prefab:基于 EnemyBase 的变体,额外加 Boss 技能组件。

父 Prefab 改公共结构时,Variant 会跟着继承。 Variant 自己改过的字段,会作为 Override 保留。

4. Prefab 和 Variant 的区别

Prefab:

独立模板。 可以被场景实例化。 适合保存一个完整对象。

Prefab Variant:

基于另一个 Prefab。 继承父 Prefab 的结构和默认配置。 只保存自己和父 Prefab 不同的地方。 适合同类对象有少量差异的情况。

5. 代码示例:实例化 Prefab 或 Variant

c
using UnityEngine; // 引入 Unity 引擎命名空间
public class EnemySpawner : MonoBehaviour // 定义敌人生成器脚本
{ // 类开始
    public GameObject EnemyPrefab; // 保存要生成的敌人 Prefab,可以是普通 Prefab,也可以是 Variant
    public Transform SpawnPoint; // 保存生成位置
    public void SpawnEnemy() // 定义生成敌人的方法
    { // SpawnEnemy 方法开始
        GameObject enemy = Instantiate(EnemyPrefab, SpawnPoint.position, SpawnPoint.rotation); // 根据 Prefab 模板创建敌人实例
        enemy.name = EnemyPrefab.name + "_Instance"; // 给生成出来的实例设置名字,方便调试
    } // SpawnEnemy 方法结束
} // 类结束

注意:代码里 Instantiate 不关心你拖进来的是普通 Prefab 还是 Prefab Variant。 它们在运行时都可以作为 Prefab 资源被实例化。

6. 使用场景

普通 Prefab 适合:

子弹。 特效。 UI Item。 普通敌人。 宝箱。 掉落物。

Prefab Variant 适合:

同一类敌人的不同版本。 同一个按钮的不同皮肤。 同一把武器的不同等级。 同一个 NPC 的不同外观。 Boss 基于普通怪扩展技能。

7. 常见坑

不要把所有差异都堆在场景实例 Override 上,否则很难维护。

不要随便改父 Prefab 的层级结构,因为 Variant 和实例可能都依赖它。

Variant 不是 C# 继承,它是 Unity 资源层面的继承。

Prefab 嵌套太深时,Apply 和 Revert 要小心,不然容易改错层级。

8. 面试高分回答

可以这样说:

CAUTION

Prefab 是 Unity 的可复用对象模板,保存 GameObject 层级、组件和参数;场景中由 Prefab 创建出来的是 Prefab Instance,实例上的差异叫 Override。Prefab Variant 是基于某个 Prefab 创建的变体资源,它继承父 Prefab 的公共结构和默认配置,只保存自己修改过的差异。这样可以把通用内容放在 Base Prefab,把不同敌人、不同皮肤、不同等级的差异放到 Variant 里,既减少重复配置,又方便统一维护。

ScriptableObject 的用途 ​

ScriptableObject 的核心用途:把数据做成 Unity 资源,让数据从场景对象和 Prefab 里独立出来。

unity-scriptableobject-uses

1. ScriptableObject 是什么?

ScriptableObject 是 Unity 提供的一种可序列化资源对象。

它不像 MonoBehaviour 那样必须挂在 GameObject 上,而是可以作为 .asset 文件存在于 Project 里。

所以它很适合保存“数据”,比如:

技能配置。 Buff 配置。 道具配置。 敌人配置。 掉落表。 音效配置。 关卡参数。

2. 它最大的价值是什么?

把“数据”和“行为”分离。

ScriptableObject 负责保存数据。 MonoBehaviour 负责读取数据并执行逻辑。

比如技能系统里:

SkillConfig 保存伤害、范围、冷却、特效。 SkillRunner 负责释放技能、扣血、播放特效。

这样策划可以直接在 Inspector 里改配置,代码不用写死大量参数。

3. 代码示例:技能配置

c
using UnityEngine; // 引入 Unity 引擎命名空间
[CreateAssetMenu(fileName = "SkillConfig", menuName = "Game/Skill Config")] // 允许在 Project 菜单中创建技能配置资源
public class SkillConfig : ScriptableObject // 定义技能配置类,并继承 ScriptableObject
{ // SkillConfig 类开始
    public string SkillName; // 保存技能名字
    public int Damage; // 保存技能伤害
    public float Cooldown; // 保存技能冷却时间
    public float Range; // 保存技能范围
    public GameObject EffectPrefab; // 保存技能特效 Prefab 引用
} // SkillConfig 类结束

4. 代码示例:读取配置释放技能

c
using UnityEngine; // 引入 Unity 引擎命名空间
public class SkillRunner : MonoBehaviour // 定义技能执行脚本
{ // SkillRunner 类开始
    public SkillConfig Config; // 引用一个 ScriptableObject 技能配置资源
    private float _cooldownTimer; // 保存当前剩余冷却时间
    private void Update() // 每帧更新技能冷却
    { // Update 方法开始
        if (_cooldownTimer > 0f) // 判断技能是否正在冷却
        { // if 语句开始
            _cooldownTimer -= Time.deltaTime; // 每帧减少冷却时间
        } // if 语句结束
        if (Input.GetKeyDown(KeyCode.Space) && _cooldownTimer <= 0f) // 判断是否按下空格并且技能不在冷却
        { // if 语句开始
            CastSkill(); // 释放技能
        } // if 语句结束
    } // Update 方法结束
    private void CastSkill() // 定义释放技能方法
    { // CastSkill 方法开始
        Debug.Log("释放技能:" + Config.SkillName); // 输出当前释放的技能名字
        Debug.Log("造成伤害:" + Config.Damage); // 输出技能伤害
        if (Config.EffectPrefab != null) // 判断技能特效是否存在
        { // if 语句开始
            Instantiate(Config.EffectPrefab, transform.position, transform.rotation); // 根据配置创建技能特效
        } // if 语句结束
        _cooldownTimer = Config.Cooldown; // 根据配置重置技能冷却时间
    } // CastSkill 方法结束
} // SkillRunner 类结束

5. 常见用途

做配置数据:

技能、Buff、装备、道具、敌人、关卡、掉落表。

做共享数据:

多个 Prefab 引用同一份配置,避免每个实例都复制一份数据。

做事件通道:

一个 ScriptableObject 代表一个事件入口,让 UI、战斗、音频系统解耦。

做运行时集合:

比如记录当前所有存活敌人,方便 AI、任务、战斗系统查询。

做编辑器工具数据:

保存工具参数、批处理规则、资源检查规则。

6. 常见坑

不要把玩家存档直接保存在 ScriptableObject 资产里。

因为它是资源配置,不是天然的存档文件。

不要随便在运行时修改共享配置。

如果多个角色都引用同一个 SkillConfig,你运行时改了 Config.Damage,所有引用它的对象都会看到变化。

如果需要每个角色独立状态,应该把运行时状态放到普通类或组件里,而不是直接改配置资源。

7. 面试高分回答

可以这样说:

IMPORTANT

ScriptableObject 是 Unity 的可序列化资源对象,不需要挂在 GameObject 上,通常以 .asset 文件存在。它最常用于数据驱动,把技能、道具、敌人、Buff、掉落等配置从代码和 Prefab 中抽离出来,让多个对象共享同一份数据,减少重复配置和内存浪费。实际项目里我会让 ScriptableObject 保存静态配置,MonoBehaviour 读取配置并执行逻辑;运行时状态不直接写回配置资产,而是放在实例数据或组件里。这样结构更清晰,也方便策划配置、工具检查和后续扩展。

UGUI 重建和优化 ​

UGUI 重建:UI 发生变化后,Unity 重新计算布局、顶点、材质批次和 Canvas 渲染数据。

unity-ugui-rebuild-optimization

1. 什么会触发重建?

常见触发点:

修改 Text.text。 修改 Image.sprite、颜色、透明度。 修改 RectTransform 大小、位置、缩放。 子物体增删、显隐、换父节点。 LayoutGroup、ContentSizeFitter 重新计算布局。 ScrollView 大量 Item 刷新。

UGUI 里经常说的“变脏”就是:UI 元素被标记为需要重新计算。

2. 重建分哪几类?

Layout Rebuild:

重新计算 UI 的位置和尺寸。 常见于 HorizontalLayoutGroup、VerticalLayoutGroup、GridLayoutGroup、ContentSizeFitter。

Graphic Rebuild:

重新生成 UI 的顶点和材质数据。 常见于 Text、Image、RawImage 内容变化。

Canvas BuildBatch:

Canvas 重新整理渲染批次。 如果一个大 Canvas 里有少量 UI 每帧变化,也可能导致这个 Canvas 相关的批处理重建。

3. 为什么会卡?

因为 UGUI 的重建有传播性。

一个子节点尺寸变化,可能影响父节点 Layout。 父节点 Layout 又可能影响兄弟节点。 最后 Canvas 可能要重新生成批次。

所以大 Canvas 里放了很多静态 UI,但其中一个小倒计时文本每帧变化,也可能带来不必要成本。

4. 怎么优化?

最重要几条:

把静态 UI 和动态 UI 拆到不同 Canvas。 不要每帧重复设置相同的 text、sprite、color。 运行时少用复杂 LayoutGroup 和 ContentSizeFitter。 不需要交互的 Image、Text 关闭 Raycast Target。 ScrollView 大量 Item 用虚拟列表。 使用图集减少材质和纹理切换。 频繁显隐可以考虑 CanvasGroup,不要总是整棵 UI SetActive。 用 Profiler 看 Canvas.BuildBatch、LayoutRebuilder、Graphic.Rebuild。

5. 代码示例:值没变就不刷新文本

c
using UnityEngine; // 引入 Unity 引擎命名空间
using UnityEngine.UI; // 引入 UGUI 命名空间,用于 Text 组件
public class OptimizedHpText : MonoBehaviour // 定义优化血量文本的脚本
{ // 类开始
    public Text HpText; // 保存血量 Text 引用
    private int _lastHp = -1; // 保存上一次显示的血量,避免重复刷新
    public void SetHp(int hp) // 提供设置血量显示的方法
    { // SetHp 方法开始
        if (_lastHp == hp) // 判断新血量是否和旧血量相同
        { // if 语句开始
            return; // 如果没有变化,就不重新设置 Text,避免触发重建
        } // if 语句结束
        _lastHp = hp; // 记录新的血量值
        HpText.text = hp.ToString(); // 只有变化时才更新文本内容
    } // SetHp 方法结束
} // 类结束

6. 面试高分回答

可以这样说:

TIP

UGUI 重建是 UI 被标记 dirty 后,在 Canvas 更新阶段重新计算布局、图形顶点和渲染批次的过程。常见来源有文本变化、图片变化、RectTransform 改动、LayoutGroup、ContentSizeFitter、ScrollView 大量元素刷新等。优化时我会先用 Profiler 定位 Canvas.BuildBatch、LayoutRebuilder、Graphic.Rebuild 是否偏高,然后把静态 UI 和动态 UI 拆分到不同 Canvas,减少每帧 UI 属性修改,关闭无交互元素的 Raycast Target,复杂列表做虚拟列表,运行时尽量少依赖自动 Layout。核心思路是:少让 UI 变脏,少让大 Canvas 被迫重建。

ScrollView 虚拟列表 ​

ScrollView 虚拟列表:数据可以很多,但 UI Item 只创建可见区域附近的一小批,然后滚动时复用。

unity-scrollview-virtual-list

1. 为什么需要虚拟列表?

普通 ScrollView 如果有 10000 条数据,直接创建 10000 个 Item,会导致:

GameObject 太多。 Layout 重建很重。 Canvas.BuildBatch 变高。 初始化慢。 滚动卡顿。 内存和 GC 压力变大。

虚拟列表的做法是:

数据有 10000 条。 Content 高度按 10000 条计算。 真实 Item 只创建屏幕能看到的数量,比如 10 到 20 个。 滚动时复用这些 Item,重新设置位置和显示内容。

2. 固定高度虚拟列表核心公式

假设每个 Item 高度是 80:

c
ContentHeight = TotalCount * ItemHeight
FirstIndex = Content.anchoredPosition.y / ItemHeight
ItemY = -DataIndex * ItemHeight

也就是说,滚动到哪里,就根据偏移算出当前第一个可见数据索引。

3. 简化版代码

c
using System.Collections.Generic; // 引入泛型集合命名空间,用来保存对象池 Item
using UnityEngine; // 引入 Unity 引擎命名空间
using UnityEngine.UI; // 引入 UGUI 命名空间,用来使用 ScrollRect 和 Text
public class VirtualListItem : MonoBehaviour // 定义虚拟列表中的单个 Item 视图
{ // VirtualListItem 类开始
    public RectTransform Rect; // 保存当前 Item 的 RectTransform
    public Text Label; // 保存当前 Item 上显示文字的 Text
    public void Bind(int index) // 绑定数据索引到当前 Item
    { // Bind 方法开始
        Label.text = "Item " + index; // 根据索引刷新显示内容
    } // Bind 方法结束
} // VirtualListItem 类结束
public class FixedHeightVirtualList : MonoBehaviour // 定义固定高度虚拟列表脚本
{ // FixedHeightVirtualList 类开始
    public ScrollRect ScrollRect; // 保存 ScrollRect 引用
    public RectTransform Content; // 保存 Content 节点引用
    public RectTransform Viewport; // 保存 Viewport 节点引用
    public VirtualListItem ItemPrefab; // 保存 Item 预制体引用
    public int TotalCount = 10000; // 保存总数据数量
    public float ItemHeight = 80f; // 保存每个 Item 的固定高度
    public int BufferCount = 2; // 保存上下额外缓冲 Item 数量
    private readonly List<VirtualListItem> _pool = new List<VirtualListItem>(); // 创建 Item 对象池
    private int _lastFirstIndex = -1; // 记录上一次首个可见索引
    private void Awake() // Unity 初始化时调用
    { // Awake 方法开始
        BuildPool(); // 创建可复用的 Item 对象池
        SetContentHeight(); // 设置 Content 的总高度
        ScrollRect.onValueChanged.AddListener(OnScroll); // 监听 ScrollView 滚动事件
        RefreshVisibleItems(); // 初始化刷新可见 Item
    } // Awake 方法结束
    private void OnDestroy() // 对象销毁时调用
    { // OnDestroy 方法开始
        ScrollRect.onValueChanged.RemoveListener(OnScroll); // 移除滚动事件监听,避免引用残留
    } // OnDestroy 方法结束
    private void BuildPool() // 创建对象池方法
    { // BuildPool 方法开始
        float viewportHeight = Viewport.rect.height; // 获取可见区域高度
        int visibleCount = Mathf.CeilToInt(viewportHeight / ItemHeight); // 计算屏幕内最多可见多少个 Item
        int poolCount = visibleCount + BufferCount * 2; // 加上上下缓冲数量得到对象池大小
        for (int i = 0; i < poolCount; i++) // 循环创建对象池 Item
        { // for 循环开始
            VirtualListItem item = Instantiate(ItemPrefab, Content); // 在 Content 下实例化一个 Item
            item.gameObject.SetActive(true); // 激活 Item 对象
            _pool.Add(item); // 把 Item 加入对象池
        } // for 循环结束
    } // BuildPool 方法结束
    private void SetContentHeight() // 设置 Content 高度方法
    { // SetContentHeight 方法开始
        float height = TotalCount * ItemHeight; // 根据总数据数量和行高计算 Content 高度
        Content.sizeDelta = new Vector2(Content.sizeDelta.x, height); // 设置 Content 的高度
    } // SetContentHeight 方法结束
    private void OnScroll(Vector2 value) // ScrollRect 滚动时调用
    { // OnScroll 方法开始
        RefreshVisibleItems(); // 滚动后刷新可见 Item
    } // OnScroll 方法结束
    private void RefreshVisibleItems() // 刷新可见 Item 方法
    { // RefreshVisibleItems 方法开始
        float offsetY = Content.anchoredPosition.y; // 获取 Content 当前向上滚动的偏移
        int firstIndex = Mathf.FloorToInt(offsetY / ItemHeight); // 根据偏移计算第一个可见数据索引
        firstIndex = Mathf.Max(0, firstIndex - BufferCount); // 往上多保留几个缓冲 Item
        if (firstIndex == _lastFirstIndex) // 判断首索引是否没有变化
        { // if 语句开始
            return; // 如果没有变化,就不重复刷新
        } // if 语句结束
        _lastFirstIndex = firstIndex; // 记录新的首个可见索引
        for (int i = 0; i < _pool.Count; i++) // 遍历对象池中的所有 Item
        { // for 循环开始
            int dataIndex = firstIndex + i; // 计算当前池对象对应的数据索引
            bool valid = dataIndex >= 0 && dataIndex < TotalCount; // 判断索引是否在有效数据范围内
            _pool[i].gameObject.SetActive(valid); // 没有对应数据时隐藏 Item
            if (!valid) // 判断当前 Item 是否无效
            { // if 语句开始
                continue; // 无效 Item 不再刷新
            } // if 语句结束
            _pool[i].Rect.anchoredPosition = new Vector2(0f, -dataIndex * ItemHeight); // 把 Item 摆到对应数据行的位置
            _pool[i].Bind(dataIndex); // 绑定数据索引并刷新显示内容
        } // for 循环结束
    } // RefreshVisibleItems 方法结束
} // FixedHeightVirtualList 类结束

4. 面试高分回答

可以这样说:

TIP

ScrollView 虚拟列表的核心是 UI 复用。总数据可能有几千几万条,但真实创建的 Item 只需要覆盖可见区域加少量缓冲。Content 的高度仍然按总数据量设置,这样滚动条表现正常;滚动时根据 content.anchoredPosition.y / itemHeight 算出第一个可见索引,然后把对象池里的 Item 重新摆到对应位置,并绑定新的数据。固定高度列表实现最简单,变高 Item 则需要记录每行高度,通常用前缀和加二分查找定位索引。优化重点是减少 GameObject 数量、减少 Layout 重建、避免频繁 Instantiate/Destroy,并且 Item 只负责 Bind(data),不要把业务逻辑塞进 UI。

对象池设计 ​

对象池设计的核心:对象用完不销毁,而是隐藏、重置、放回池里,下次继续复用。

unity-object-pool-design

1. 为什么需要对象池?

Unity 里频繁 Instantiate 和 Destroy 很贵。

比如:

子弹。 特效。 伤害数字。 怪物。 掉落物。 ScrollView Item。

这些对象生命周期短、数量多,如果每次都创建销毁,会带来 CPU 开销、内存分配和 GC 抖动。

对象池就是把这些对象复用起来。

2. 对象池核心结构

一般需要:

prefab:对象模板。 inactiveStack:空闲对象池。 activeSet:正在使用的对象集合,用来防止重复回收。 maxCount:最大容量,避免池无限膨胀。 OnSpawn:取出时重置状态。 OnDespawn:回收时清理状态。

3. 简化版通用对象池

c
using System.Collections.Generic; // 引入集合命名空间,用于 Stack 和 HashSet
using UnityEngine; // 引入 Unity 引擎命名空间
public interface IPoolable // 定义池对象生命周期接口
{ // IPoolable 接口开始
    void OnSpawn(); // 定义对象从池中取出时调用的方法
    void OnDespawn(); // 定义对象回收到池中时调用的方法
} // IPoolable 接口结束
public class GameObjectPool // 定义 GameObject 对象池类
{ // GameObjectPool 类开始
    private readonly GameObject _prefab; // 保存对象池要创建的 Prefab
    private readonly Transform _root; // 保存回收对象的父节点
    private readonly int _maxCount; // 保存对象池最大容量
    private readonly Stack<GameObject> _inactive = new Stack<GameObject>(); // 保存空闲对象
    private readonly HashSet<GameObject> _active = new HashSet<GameObject>(); // 保存正在使用的对象
    public GameObjectPool(GameObject prefab, Transform root, int prewarmCount, int maxCount) // 定义对象池构造函数
    { // 构造函数开始
        _prefab = prefab; // 记录 Prefab
        _root = root; // 记录对象池根节点
        _maxCount = maxCount; // 记录最大容量
        for (int i = 0; i < prewarmCount; i++) // 根据预热数量循环创建对象
        { // for 循环开始
            GameObject obj = CreateNewObject(); // 创建一个新对象
            Release(obj); // 把新对象回收到池里
        } // for 循环结束
    } // 构造函数结束
    public GameObject Get(Vector3 position, Quaternion rotation) // 从对象池中取出对象
    { // Get 方法开始
        GameObject obj = _inactive.Count > 0 ? _inactive.Pop() : CreateNewObject(); // 优先复用空闲对象,没有就创建新对象
        _active.Add(obj); // 把对象记录为正在使用
        obj.transform.SetPositionAndRotation(position, rotation); // 设置对象位置和旋转
        obj.SetActive(true); // 激活对象
        IPoolable poolable = obj.GetComponent<IPoolable>(); // 获取对象上的池生命周期组件
        poolable?.OnSpawn(); // 通知对象执行取出时的重置逻辑
        return obj; // 返回取出的对象
    } // Get 方法结束
    public void Release(GameObject obj) // 把对象回收到池里
    { // Release 方法开始
        if (obj == null) // 判断对象是否为空
        { // if 语句开始
            return; // 空对象不能回收,直接返回
        } // if 语句结束
        if (_active.Count > 0 && !_active.Remove(obj)) // 判断对象是否不在使用集合中
        { // if 语句开始
            Debug.LogWarning("对象重复回收:" + obj.name); // 输出重复回收警告
            return; // 避免同一个对象被重复放回池里
        } // if 语句结束
        IPoolable poolable = obj.GetComponent<IPoolable>(); // 获取对象上的池生命周期组件
        poolable?.OnDespawn(); // 通知对象执行回收前清理逻辑
        obj.SetActive(false); // 隐藏对象
        obj.transform.SetParent(_root); // 把对象挂回对象池根节点
        if (_inactive.Count >= _maxCount) // 判断空闲池是否已经达到最大容量
        { // if 语句开始
            Object.Destroy(obj); // 池满时销毁多余对象,避免无限增长
            return; // 销毁后直接返回
        } // if 语句结束
        _inactive.Push(obj); // 把对象压入空闲栈中等待复用
    } // Release 方法结束
    public void Clear() // 清空对象池
    { // Clear 方法开始
        foreach (GameObject obj in _inactive) // 遍历所有空闲对象
        { // foreach 循环开始
            Object.Destroy(obj); // 销毁空闲对象
        } // foreach 循环结束
        foreach (GameObject obj in _active) // 遍历所有正在使用的对象
        { // foreach 循环开始
            Object.Destroy(obj); // 销毁正在使用的对象
        } // foreach 循环结束
        _inactive.Clear(); // 清空空闲集合
        _active.Clear(); // 清空使用中集合
    } // Clear 方法结束
    private GameObject CreateNewObject() // 创建新对象
    { // CreateNewObject 方法开始
        GameObject obj = Object.Instantiate(_prefab, _root); // 根据 Prefab 实例化对象并挂到池根节点下
        obj.SetActive(false); // 创建后先隐藏,等待 Get 时激活
        return obj; // 返回创建出来的新对象
    } // CreateNewObject 方法结束
} // GameObjectPool 类结束

4. 池对象自己怎么重置?

c
using UnityEngine; // 引入 Unity 引擎命名空间
public class Bullet : MonoBehaviour, IPoolable // 定义子弹类,并实现池生命周期接口
{ // Bullet 类开始
    public Rigidbody Rb; // 保存子弹刚体引用
    public float LifeTime = 3f; // 保存子弹最大存活时间
    private float _timer; // 保存当前子弹已存活时间
    public void OnSpawn() // 子弹从池中取出时调用
    { // OnSpawn 方法开始
        _timer = 0f; // 重置存活计时器
        Rb.velocity = Vector3.zero; // 清空刚体速度
        Rb.angularVelocity = Vector3.zero; // 清空刚体角速度
    } // OnSpawn 方法结束
    public void OnDespawn() // 子弹回收到池中时调用
    { // OnDespawn 方法开始
        Rb.velocity = Vector3.zero; // 回收时清空速度,避免下次复用继承旧状态
        Rb.angularVelocity = Vector3.zero; // 回收时清空角速度
    } // OnDespawn 方法结束
    private void Update() // 每帧更新子弹生命周期
    { // Update 方法开始
        _timer += Time.deltaTime; // 累加子弹存活时间
        if (_timer >= LifeTime) // 判断子弹是否超过最大存活时间
        { // if 语句开始
            gameObject.SetActive(false); // 示例中只隐藏,正式项目应通知 Pool.Release
        } // if 语句结束
    } // Update 方法结束
} // Bullet 类结束

5. 设计时重点注意

取出时要重置状态:位置、旋转、速度、血量、计时器、动画、粒子状态。 回收时要清理状态:取消事件订阅、停止协程、停止粒子、清空目标引用。 要防止重复回收:用 HashSet 记录 active 对象。 要限制最大容量:避免某次峰值后池子永远占内存。 切场景时要清理:对象池持有对象,就等于持有资源引用。 对象池不是越大越好:池太大也会常驻内存。

6. 面试高分回答

可以这样说:

NOTE

对象池适合高频创建、生命周期短、可重置的对象,比如子弹、特效、伤害数字、UI Item。设计上我会用一个空闲栈保存可复用对象,用一个 active 集合记录正在使用的对象,防止重复回收。初始化时可以预热一批对象,运行时 Get 优先从池里取,没有再创建;Release 时调用对象的清理逻辑、隐藏对象并放回池里。如果池超过最大容量,就销毁多余对象,避免内存无限增长。关键不是简单 SetActive,而是要保证对象状态彻底重置,事件、协程、粒子、刚体速度、目标引用都要清理,否则复用时会出现旧状态污染。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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