Skip to content

C# / Unity 必背

值类型和引用类型区别

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

csharp-value-type-reference-type

1. 值类型是什么?

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

常见值类型:

intfloatdoubleboolcharstructenumDateTime

比如:

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

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

2. 引用类型是什么?

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

常见引用类型:

classobjectstring、数组、接口、委托

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

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 压力。

classstruct 区别

classstruct 的核心区别: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,而是字段归零:

int0boolfalse,引用字段是 null

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

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

5. 性能区别

struct 小对象通常更轻,比如 Vector3Color、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 优化重点

最重要的是减少分配:

UpdateLateUpdateFixedUpdate 里不要频繁 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);

此时 Count2,但 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 还保存 hashCodenext。如果多个 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 是发布者。 UIAudioBattleSystem 是订阅者。 玩家死亡时,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 主线程上,可以直接操作 TransformGameObject、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 nullWaitForSeconds 等等待对象时,协程暂停;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 协程基于 IEnumeratoryield return,由 Unity PlayerLoop 在主线程上推进,适合游戏流程、分帧执行、等待下一帧或等待游戏时间。async/await 基于 Task 或 awaiter,适合异步 IO、网络、文件、需要返回值和异常传播的流程,可以用 Task<T> 返回结果,用 try/catch 处理异常,用 CancellationToken 取消。两者都不等于自动开线程,真正并行通常要用 Task.Run 或显式线程,而且后台线程不能直接操作大多数 Unity API。

AwakeStartUpdate 执行顺序

执行顺序: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 一定早于 StartStart 一定早于第一次 Update

但多个脚本之间:

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

5. 面试高分回答

IMPORTANT

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

UpdateFixedUpdateLateUpdate 区别

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

unity-update-fixed-lateupdate-difference

1. Update

Update 每个渲染帧调用一次。

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

适合做:

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

移动时通常乘:

c
Time.deltaTime

2. FixedUpdate

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

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

适合做:

Rigidbody.AddForceRigidbody.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

RigidbodyCharacterController 区别

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

unity-rigidbody-charactercontroller-difference

1. Rigidbody 是什么?

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

它会受这些东西影响:

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

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

2. CharacterController 是什么?

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

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

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

controller.Move(move);

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

3. 最大区别

Rigidbody

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

CharacterController

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

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 是角色控制器,它本身不按刚体那套受力逻辑运动,而是通过脚本调用 MoveSimpleMove 来移动,并提供碰撞检测和贴地信息,适合需要稳定手感的角色移动。简单说,Rigidbody 是物理驱动,CharacterController 是脚本驱动;真实物理交互用 Rigidbody,精确可控的玩家角色常用 CharacterController

TriggerCollision 区别

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)

ColliderCollider2D 不是一套系统,RigidbodyRigidbody2D 也不是一套系统。

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 点击则通常走 EventSystemGraphicRaycaster

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 大小、位置、缩放。 子物体增删、显隐、换父节点。 LayoutGroupContentSizeFitter 重新计算布局。 ScrollView 大量 Item 刷新。

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

2. 重建分哪几类?

Layout Rebuild

重新计算 UI 的位置和尺寸。 常见于 HorizontalLayoutGroupVerticalLayoutGroupGridLayoutGroupContentSizeFitter

Graphic Rebuild

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

Canvas BuildBatch

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

3. 为什么会卡?

因为 UGUI 的重建有传播性。

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

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

4. 怎么优化?

最重要几条:

把静态 UI 和动态 UI 拆到不同 Canvas。 不要每帧重复设置相同的 textspritecolor。 运行时少用复杂 LayoutGroupContentSizeFitter。 不需要交互的 ImageText 关闭 Raycast Target。 ScrollView 大量 Item 用虚拟列表。 使用图集减少材质和纹理切换。 频繁显隐可以考虑 CanvasGroup,不要总是整棵 UI SetActive。 用 Profiler 看 Canvas.BuildBatchLayoutRebuilderGraphic.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.BuildBatchLayoutRebuilderGraphic.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 里频繁 InstantiateDestroy 很贵。

比如:

子弹。 特效。 伤害数字。 怪物。 掉落物。 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