Unity开发中最常用的数据结构
- 数组:
Array - 集合(动态数组):
List<T> - 字典:
Dictionary<TKey,TValue> - 哈希集:
HashSet<T> - 链表:
LinkedList<T> - 栈:
Stack<T> - 队列:
Queue<T>
集合(List)
在C#中,List 是一个非常常用的集合类型,它实现了 IList 接口,并且提供了动态数组的功能。List 允许你添加、删除、查找和遍历一个对象的集合。
内存模型
List<T> 本质上是对托管数组(T[ ])的封装,因此其内存布局与数组高度相似,但增加了动态扩容能力。
内部字段
-
T[] _items:实际存储元素的数组,在托管堆上连续分配。 -
int _size:当前列表中有效元素的数量。 -
int _version:用于枚举器版本控制,防止迭代过程中集合被修改。
值类型与引用类型
-
当
T 为值类型(如int、Vector3)时,元素直接存储在_items的连续内存中,无额外间接寻址。 -
当
T 为引用类型(如GameObject)时,_items存储的是引用,实际对象分散在堆上。
容量与占用
-
容量
Capacity为_items.Length,占用内存大小 = sizeof(T) * Capacity + 对象头开销。 -
扩容时,旧数组变为垃圾,新数组大小通常为原来的2倍(.NET 实现策略),产生新的内存分配和旧数据复制。
GC 压力:
- 由于
List<T>的底层数组在堆上分配,频繁扩容会导致多次GC 分配;当列表作为临时变量在每帧使用时,尤其需要注意。
实现原理
底层实现概览
List< T >是 System.Collections.Generic 命名空间下的泛型类,源码可参考 .NET 参考源。其核心操作实现如下:
构造:无参时初始为空数组,有参时分配指定容量。
csharp
public List() => _items = Array.Empty<T>(); // 初始空数组
public List(int capacity) => _items = new T[capacity];
索引器:有边界检查,O(1)访问。
csharp
public T this[int index] {
get {
if ((uint)index >= (uint)_size) throw new IndexOutOfRangeException();
return _items[index];
}
set {
if ((uint)index >= (uint)_size) throw new IndexOutOfRangeException();
_items[index] = value;
_version++;
}
}
Add:若容量不足则扩容(EnsureCapacity),复制原数组,再添加新元素。
csharp
public void Add(T item) {
if (_size == _items.Length) {
// 扩容:新容量 = _size == 0 ? 4 : _size * 2
EnsureCapacity(_size + 1);
}
_items[_size++] = item;
_version++;
}
-
Insert:在指定索引插入元素,若空间不足则扩容;然后使用Array.Copy将插入点后的元素后移一位。 -
RemoveAt:将待删除元素之后的元素向前复制覆盖,并将最后一个元素位置设为default(避免引用残留),同时_size - -。 -
Clear:若元素为引用类型,会将_items中所有位置设为null;数组本身不释放,只是_size = 0。 -
枚举器:foreach语法糖使用GetEnumerator()返回的结构体(Enumerator)避免堆分配;但若将List<T>转为IEnumerable<T>接口调用,则会产生装箱。
复杂度分析
| 操作 | 平均时间复杂度 | 最坏情况 | 说明 |
|---|---|---|---|
索引访问 |
O(1) | O(1) | 直接访问数组 |
Add |
O(1) 摊销 | O(n)(扩容) | 扩容时需复制所有元素,但均摊成本为 O(1) |
Insert |
O(n) | O(n) | 需要移动插入点之后的元素 |
RemoveAt |
O(n) | O(n) | 移动删除点之后的元素 |
Remove(按值) |
O(n) | O(n) | 先查找,再执行删除 |
Contains |
O(n) | O(n) | 线性查找 |
Sort |
O(n log n) | O(n²) | 内部 Array.Sort |
Clear |
O(1) 或 O(n) | - | 引用类型需置 null |
关键点:
-
扩容成本:当容量不足时,扩容会复制整个数组。假设初始容量为 0,经过 n 次 Add,总复制次数约为 O(n),均摊到每次添加为 O(1)。
-
内存浪费:扩容后可能有多余的未使用容量,可通过
TrimExcess()释放。
Unity 实际应用场景
- 动态管理对象 :存储敌人、子弹等,注意遍历时修改需用倒序
for或临时副本。
csharp
public List<Enemy> enemies = new List<Enemy>();
void Update() {
foreach (var enemy in enemies) {
enemy.UpdateAI(); // 注意:若在遍历时添加/删除会报错
}
}
注意:foreach 在 Unity 旧版 Mono 中可能产生装箱,但现代版本(.NET 4.x / .NET Standard 2.1)已优化为无分配。若需在遍历时修改集合,应使用 for 倒序遍历或临时副本。
- 与 Unity API 配合 :如
Physics.OverlapSphereNonAlloc复用List避免分配。
csharp
// ❌ 每次调用都分配新 List
List<Collider> results = new List<Collider>();
Physics.OverlapSphere(pos, radius, results); // 内部填充,但外部 list 需提前分配
// ✅ 重用 List,减少 GC
List<Collider> results = new List<Collider>();
void Update() {
results.Clear(); // 不清除容量
Physics.OverlapSphere(pos, radius, results);
// 使用 results...
}
类似的 API 有 Physics.RaycastAll(但返回数组,更推荐使用非分配版本 RaycastNonAlloc)。
- 对象池 :对象池常用
List<T>或数组来存储空闲对象
csharp
public class ObjectPool<T> where T : new() {
private List<T> pool = new List<T>();
public T Get() {
if (pool.Count > 0) {
T obj = pool[pool.Count - 1];
pool.RemoveAt(pool.Count - 1);
return obj;
}
return new T();
}
public void Return(T obj) {
pool.Add(obj);
}
}
注意 RemoveAt 是 O(1)(删除最后一个),避免移动元素。若需要频繁从中间移除,应考虑LinkedList或自定义索引标记。
- 预分配容量 :已知元素数量时,构造时指定
Capacity减少扩容。- 在 Update 中频繁创建 List 是性能大忌,应尽量复用
csharp
// ❌ 每帧分配新 List
void Update() {
List<Transform> nearby = new List<Transform>();
// ...
}
// ✅ 复用并清空
private List<Transform> nearby = new List<Transform>();
void Update() {
nearby.Clear();
// ...
}
- 替代选择
-
当集合大小固定且无需修改时,使用数组更轻量。
-
当需要频繁在头部插入或删除时,考虑
LinkedList<T>或双端队列(Deque)自定义实现。 -
当需要线程安全或高性能并发时,使用
System.Collections.Concurrent 集合或 Unity 的NativeList。
-
总结
-
内存模型:封装托管数组,连续内存,扩容时复制到新数组,容量动态增长。
-
实现原理:通过
_items数组和_size管理,索引访问有边界检查,扩容策略为容量翻倍。 -
复杂度:索引 O(1),增删尾部 O(1) 摊销,中间插入/删除 O(n),查找 O(n)。
-
Unity 实践:常与 API 配合使用,需注意复用实例、预分配容量、避免
foreach修改集合、利用Clear清空而非重建。在性能敏感场景优先考虑数组或原生容器。