Appearance
C# / Unity 必背
值类型和引用类型区别
值类型和引用类型的核心区别:值类型保存“数据本身”,引用类型保存“对象地址引用”。
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 是值类型。
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 适合小型、不可变倾向、表示一组数据的对象,比如坐标、颜色、范围等。
装箱拆箱
装箱和拆箱是值类型与引用类型之间的转换。
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。
1. GC 大概怎么工作?
C# 里的对象通常分配在托管堆上,GC 会自动判断哪些对象已经“不可达”,然后回收它们。普通 .NET GC 会从静态字段、线程栈上的局部变量、GC Handle 等根对象开始扫描引用关系,能扫描到的是活对象,扫描不到的就是垃圾对象。Microsoft 文档也把这个过程描述为:GC 根据应用程序的 roots 建立可达对象图,不在图里的对象会被认为是垃圾并释放内存。
可以理解成:
GC Roots -> 引用链 -> 活对象保留 -> 不可达对象回收2. Unity 的 GC 有什么特点?
Unity 使用的是 Boehm-Demers-Weiser GC,Mono 和 IL2CPP 后端都使用它;Unity 默认使用增量式 GC,也就是把一次 GC 工作拆到多个帧里做,减少单帧大卡顿。
但注意:增量 GC 不是让 GC 总成本消失,只是把一次长暂停拆成多次短暂停。Unity 文档也明确说,增量 GC 不会让垃圾回收整体更快,只是减少 GC spike。
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 上测。
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> 底层本质是动态数组。
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 数据数组。
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 orange4. 复杂度
哈希分布好时:
查找:平均 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 的字段。
委托和事件区别
委托是“方法引用类型”,事件是“基于委托的安全发布订阅机制”。
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 主线程上的“分帧执行状态机”,线程是操作系统调度的并行执行流。
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# 异步任务流程。
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。
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 的区别:一个跟渲染帧走,一个跟物理步走,一个在本帧逻辑后收尾。
1. Update
Update 每个渲染帧调用一次。
帧率高,它调用得多;帧率低,它调用得少。
适合做:
读取输入。 普通逻辑更新。 UI 状态刷新。 非物理移动。
移动时通常乘:
c
Time.deltaTime2. FixedUpdate
FixedUpdate 按固定物理时间步调用,不是每帧固定调用一次。
如果一帧很快,可能这一帧没有 FixedUpdate。 如果一帧很卡,Unity 可能补跑多次 FixedUpdate。
适合做:
Rigidbody.AddForce。 Rigidbody.MovePosition。 物理检测和物理移动。 依赖物理模拟的逻辑。
物理里通常使用:
c
Time.fixedDeltaTime3. 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 的作用:表示上一帧到当前帧经过的时间,单位是秒。它最常用于让移动、计时、冷却等逻辑不受帧率影响。
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 是角色控制器。一个偏真实物理,一个偏稳定手感。
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 是触发检测。
1. Collision 是什么?
Collision 表示真实物理碰撞。
两个物体碰到后,会产生物理阻挡、接触点、法线、相对速度等信息。
比如:
角色撞墙。 箱子被推。 子弹打到墙。 物体落到地面。
常用回调:
c
OnCollisionEnter
OnCollisionStay
OnCollisionExit2. Trigger 是什么?
Trigger 表示触发器检测。
它不会把物体挡住,只负责检测某个物体是否进入、停留、离开一个区域。
比如:
拾取金币。 进入传送门。 进入技能范围。 进入怪物仇恨范围。 触发剧情区域。
常用回调:
c
OnTriggerEnter
OnTriggerStay
OnTriggerExit3. 触发条件区别
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。
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 的变体模板”。
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 里独立出来。
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 渲染数据。
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 只创建可见区域附近的一小批,然后滚动时复用。
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。
对象池设计
对象池设计的核心:对象用完不销毁,而是隐藏、重置、放回池里,下次继续复用。
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,而是要保证对象状态彻底重置,事件、协程、粒子、刚体速度、目标引用都要清理,否则复用时会出现旧状态污染。