
1. 项目概述为什么Unity开发者必须掌握数组与List如果你刚开始接触Unity开发或者从其他语言转过来可能会觉得C#里的数组和ListT看起来差不多不都是用来装一堆东西的吗干嘛要学两个我刚开始也这么想直到在项目里踩了几个大坑。比如做一个小怪生成器一开始用数组定了10个位置结果策划临时说要加个Boss战需要15个位置我手忙脚乱地重构代码又比如需要动态管理玩家背包里捡到的道具用数组实现起来繁琐得让人头皮发麻。这些经历让我彻底明白在Unity里数组和List不是二选一的关系而是“基石”与“瑞士军刀”的搭配。数组是你规划好的、固定大小的储物柜性能极致而List是你随用随取的魔法口袋灵活无比。理解它们不仅是语法问题更直接关系到你代码的性能、可维护性和应对需求变更的能力。这篇内容我就结合在Unity里实际开发的场景把这两者的创建、使用、性能差异和那些教科书里不会写的“坑”一次性讲透让你在实战中能真正用起来。2. 核心概念辨析数组与List的本质区别在深入代码之前我们必须从内存和设计哲学层面理解这两者。这决定了你何时该用谁而不是凭感觉。2.1 数组定长、连续内存的性能基石数组在C#中是一个最基础的数据结构。它的核心特点是长度固定。当你写下int[] enemyHp new int[5];这一行时程序会向内存申请一块连续的空间足够存放5个整数。这个“连续”非常关键也是数组访问速度快的根本原因。因为内存地址是连续的CPU可以根据首地址和索引通过简单的加法计算首地址 索引 * 元素大小直接定位到任何一个元素这个过程是O(1)的时间复杂度极快。但是固定长度既是优点也是枷锁。在Unity中哪些情况适合用数组呢配置数据比如关卡中固定的出生点坐标Vector3[] spawnPoints、角色固定的技能ID列表。这些数据在运行时通常不会改变数量。网格或纹理数据处理Mesh的顶点、三角形索引或者图像像素数据时数据大小是确定的数组的连续内存特性能与底层API如Graphics API高效交互。性能关键循环在Update或固定物理循环中需要遍历处理的大量、长度不变的对象。例如管理一个粒子系统里所有粒子的状态。注意这里说的“固定”是指在单次运行周期内。你可以通过new关键字重新创建一个不同长度的新数组来“改变”大小但这意味着旧数组被丢弃并伴随一次新的内存分配和可能的垃圾回收GC在性能敏感处需谨慎。2.2 List 动态数组灵活的容器ListT本质上是对数组的一层智能封装。你可以把它想象成一个“动态数组”。它内部维护了一个私有的数组常称为“容量”Capacity以及一个表示当前元素数量的“计数”Count。当你创建一个Listint enemyList new Listint();时它可能内部只分配了一个很小的数组比如容量为4。当你不断Add元素且数量达到容量上限时List会自动执行一个昂贵的操作创建一个容量更大的新内部数组通常是翻倍如从4到8然后将旧数组的所有元素复制过去。这个过程称为“扩容”。所以List的优势在于“动态”你无需关心初始大小可以随时增删。代价是在扩容时会有性能开销并且因为有一层封装其直接索引访问的速度比纯数组有极其微小的损耗通常可忽略不计除非在超级密集的循环中。在Unity中List的用武之地更广动态集合管理玩家背包物品、场景中动态生成的敌人、聊天记录、任务列表等数量随时变化。临时数据存储需要收集一些数据再进行批处理比如一帧内所有需要播放音效的请求。作为返回结果从数据库或网络请求获取的数据列表数量不确定。2.3 如何选择一个简单的决策流面对一个需求你可以快速通过下面这个流程做决定元素数量在编译时或初始化时就完全确定且永不改变吗如果是优先考虑数组。元素数量是否频繁变化增删如果是必须使用List。是否处于性能极端关键的代码路径如每帧执行数千次的循环如果是且长度固定用数组如果长度变化考虑使用List但通过设置初始容量new Listint(1000)来避免运行时扩容。只是需要一个简单的可迭代集合且代码可读性/易用性更重要大多数情况下直接用List更省心。3. 创建与初始化从声明到赋值的各种姿势知道该用谁之后我们来看看具体怎么把它们“造”出来。C#提供了多种灵活的初始化方式。3.1 数组的创建与初始化a. 声明与分配空间分离这是最基础的方式清晰地将声明、分配空间和赋值步骤分开。// 1. 声明一个整数数组引用 int[] scores; // 2. 分配一个可以容纳5个整数的内存空间并默认初始化为0 scores new int[5]; // 3. 为每个位置赋值 scores[0] 90; scores[1] 85; // ... scores[2] 到 scores[4] 目前仍是0这种方式适合在游戏不同阶段初始化数据比如在Awake中声明在Start中根据配置分配大小和赋值。b. 声明时直接分配并初始化指定大小// 创建长度为3的字符串数组并依次赋值 string[] playerNames new string[3] { Alice, Bob, Charlie };注意花括号{}内的元素数量必须与new后面中括号[3]指定的长度严格一致否则会编译错误。c. 简洁语法编译器推断长度这是最常用、最简洁的写法。// 编译器会自动计算花括号内元素的数量这里是4并创建对应长度的数组 Vector3[] waypoints new Vector3[] { new Vector3(0, 0, 0), new Vector3(5, 0, 0), new Vector3(5, 5, 0), new Vector3(0, 5, 0) }; // 甚至可以省略 new Vector3[]更简洁 Vector3[] waypoints2 { new Vector3(0, 0, 0), new Vector3(5, 0, 0) };在Unity中定义预制体引用、材质数组时这种语法非常方便。d. 多维数组与交错数组多维数组像一个整齐的矩形表格。// 一个2行3列的整数矩阵 int[,] matrix new int[2, 3] { {1, 2, 3}, {4, 5, 6} }; int value matrix[1, 2]; // 获取第2行第3列的值即6在Unity中可用于表示网格地图Tilemap、简单的像素块数据。交错数组数组的数组每一行可以有不同的长度。// 声明一个交错数组它包含3个数组行 int[][] jaggedArray new int[3][]; // 为每一行分配不同长度的数组 jaggedArray[0] new int[] {1, 2, 3}; jaggedArray[1] new int[] {4, 5}; jaggedArray[2] new int[] {6, 7, 8, 9}; int value jaggedArray[1][1]; // 获取第2行第2列的值即5这种结构更灵活内存利用率可能更高但访问语法稍复杂。3.2 List 的创建与初始化a. 创建空列表// 创建一个空的、用于存储GameObject引用的列表 ListGameObject enemyList new ListGameObject();此时Capacity容量是一个默认值.NET Framework中通常是4Count为0。b. 创建时指定初始容量如果你能预估列表大致会包含多少元素强烈建议在构造函数中指定初始容量。这可以避免初期频繁添加元素时多次扩容带来的性能损耗。// 预估这个关卡最多有50个敌人 ListGameObject enemyList new ListGameObject(50);这是一个非常重要的性能优化习惯。c. 从现有集合初始化List的构造函数非常强大可以直接从其他集合如数组、另一个列表初始化。// 从一个数组初始化 int[] baseHpArray { 100, 150, 200 }; Listint hpList new Listint(baseHpArray); // hpList 现在包含3个元素 // 从另一个列表初始化相当于复制 Liststring originalNames new Liststring { Tom, Jerry }; Liststring copiedNames new Liststring(originalNames);d. 使用集合初始化器和数组类似List也可以使用花括号语法在声明时直接添加元素。Liststring weaponTypes new Liststring { Sword, Bow, Staff }; // 等价于 // Liststring weaponTypes new Liststring(); // weaponTypes.Add(Sword); // weaponTypes.Add(Bow); // weaponTypes.Add(Staff);实操心得在Unity的MonoBehaviour脚本中如果你有一个public ListTransform并在Inspector中手动分配Unity会为你执行初始化。但如果你在代码中动态创建务必养成指定合理初始容量的习惯特别是对于会在运行时大量增删的列表如对象池、子弹管理器这对维持游戏帧率稳定有奇效。4. 核心操作详解增删改查与遍历创建好了容器接下来就是往里面放东西、取东西、找东西。这些操作是日常使用中最频繁的部分。4.1 添加元素数组无法直接添加。因为长度固定你只能对已有索引位置进行赋值。如果“添加”意味着要创建新数组。int[] oldArray {1, 2, 3}; // 想要“添加”一个元素4必须创建新数组 int[] newArray new int[oldArray.Length 1]; oldArray.CopyTo(newArray, 0); // 将旧数组复制到新数组开头 newArray[newArray.Length - 1] 4; // 在新位置赋值 // 现在 oldArray 仍是 {1,2,3}, newArray 是 {1,2,3,4}List提供了多种添加方式。Listint numbers new Listint {10, 20}; numbers.Add(30); // 在末尾添加单个元素变成 {10, 20, 30} numbers.AddRange(new int[] {40, 50}); // 在末尾添加另一个集合变成 {10,20,30,40,50} numbers.Insert(1, 15); // 在索引1的位置插入15后面元素依次后移变成 {10,15,20,30,40,50}Add是O(1)操作除非触发扩容而Insert因为可能需要移动后续所有元素是O(n)操作在列表中间频繁插入性能较差。4.2 访问与修改元素两者语法类似都通过索引器[index]。// 数组 int[] arr {100, 200, 300}; int first arr[0]; // 访问first 100 arr[1] 250; // 修改arr 变成 {100, 250, 300} // List Listint list new Listint {100, 200, 300}; int firstList list[0]; // 访问 list[1] 250; // 修改关键区别数组访问索引越界会抛出IndexOutOfRangeException。List也会但List的索引上限是Count-1而不是Capacity-1。Capacity是内部数组大小对使用者是透明的。4.3 删除元素数组同样无法直接删除。需要手动创建新数组并复制需要保留的元素或者将待删除位置用一个特殊值如null、default(T)标记为“空”但这会留下空洞管理起来很麻烦。List提供了便捷的删除方法。Liststring items new Liststring {A, B, C, B, D}; items.Remove(B); // 删除第一个匹配到的“B”列表变成 {A, C, B, D} items.RemoveAt(2); // 删除索引2处的元素现在是第二个B列表变成 {A, C, D} items.RemoveAll(s s.StartsWith(C)); // 使用Lambda表达式删除所有以C开头的元素变成 {A, D} items.Clear(); // 清空所有元素Count变为0Capacity不变Remove和RemoveAt都会导致被删除元素后面的所有元素向前移动也是O(n)操作。如果要从大型列表频繁删除元素需要考虑其他数据结构如LinkedList或者使用“交换-删除”技巧将末尾元素与待删除元素交换然后删除末尾这在对象池中很常见。4.4 查找元素按值查找Listint nums new Listint {5, 10, 15, 10}; int index nums.IndexOf(10); // 返回第一个10的索引即1 int lastIndex nums.LastIndexOf(10); // 返回最后一个10的索引即3 bool contains nums.Contains(15); // 返回trueIndexOf/Contains内部需要遍历列表是O(n)操作。对于需要频繁查找的集合应考虑使用HashSetT或DictionaryTKey, TValue。按条件查找// 使用Find方法返回第一个匹配项 string result items.Find(s s.Length 1); // 使用FindAll方法返回所有匹配项是一个新的List Liststring longItems items.FindAll(s s.Length 1); // 使用Exists方法判断是否存在匹配项 bool exists items.Exists(s s Target);Lambda表达式是C#非常强大的特性让条件查找变得非常简洁。4.5 遍历元素遍历是游戏循环中最常见的操作之一有多种方式性能略有差异。for循环最传统性能最好可以直接使用索引。for (int i 0; i enemyList.Count; i) // 对List用Count { // 可以通过 enemyList[i] 访问也可以修改 if (enemyList[i] ! null enemyList[i].health 0) { // 处理敌人死亡 } }注意遍历数组时条件用i array.Length遍历List时务必用i list.Count而不是Capacity。foreach循环语法最简洁安全性高防止误修改集合但对于值类型数组会有微小的装箱开销对于引用类型如GameObject则无。foreach (GameObject enemy in enemyList) { // 在这里不能对enemyList进行增删操作否则会抛出InvalidOperationException if (enemy ! null) { enemy.GetComponentRenderer().material.color Color.red; } }使用List.ForEach方法函数式风格代码更紧凑但调试不如for循环直观且性能略低于for。enemyList.ForEach(enemy { if (enemy ! null) enemy.SetActive(false); });性能小贴士在Unity的Update中遍历成百上千个对象时for循环通常是性能最佳的选择。对于List在循环前将Count存入局部变量int count enemyList.Count;可以避免每次循环都查询属性能带来一点点性能提升在极端优化场景下可以考虑。5. 高级技巧与实战应用掌握了基本操作我们来看看在Unity项目中那些能提升效率和代码质量的高级用法。5.1 与Unity引擎的深度集成a. Inspector序列化这是Unity最方便的功能之一。你可以将public的数组或List字段暴露在Inspector面板中直接拖拽赋值。public class SpawnManager : MonoBehaviour { // 在Inspector中可以直接设置出生点 public Transform[] spawnPointsArray; // 也可以使用List效果类似但List在Inspector中更易动态调整 public ListGameObject enemyPrefabsList; }使用List时Inspector会提供一个可动态增减元素的列表界面比数组的固定长度界面更友好。但要注意在代码中动态清空Clear或修改这个List后在编辑器运行时模式下的修改不会被保存除非使用[SerializeField]配合私有字段的特定模式。b. 使用LINQ进行复杂查询LINQLanguage Integrated Query是一套强大的查询语法可以让你以声明式的方式处理集合。using System.Linq; // 必须引入命名空间 ListEnemy enemies GetAllEnemies(); // 找出所有存活的、距离玩家小于10米的敌人并按血量降序排序 var dangerousEnemies enemies .Where(e e.IsAlive Vector3.Distance(e.Position, player.Position) 10f) .OrderByDescending(e e.Health) .ToList(); // 将查询结果转换为一个新的ListLINQ语法优雅能极大简化代码。但要注意大部分LINQ操作如Where,OrderBy会生成新的迭代器或集合产生GC垃圾回收压力。在性能关键的每帧循环如Update中应避免使用但在初始化、加载或非实时逻辑中使用是极好的。c. 数组与List的相互转换两者之间转换非常频繁。// List - 数组 Liststring nameList new Liststring{A, B, C}; string[] nameArray nameList.ToArray(); // 创建一个全新的数组 // 数组 - List int[] scoreArray {90, 80, 70}; Listint scoreList scoreArray.ToList(); // 创建一个包含数组元素的新ListToArray()和ToList()都会创建新的集合副本。如果原集合很大这个操作会有开销。如果只是想将数组作为List的只读视图进行遍历可以考虑使用Array.AsReadOnly()或直接传递数组给接受IEnumerableT参数的方法。5.2 性能优化关键点预分配容量针对List这是最重要的优化。在创建List时如果知道大概的元素数量一定要使用构造函数指定初始容量。// 不好可能经历多次扩容 ListBullet bullets new ListBullet(); for (int i 0; i 1000; i) bullets.Add(new Bullet()); // 好一次分配到位 ListBullet bullets new ListBullet(1000); for (int i 0; i 1000; i) bullets.Add(new Bullet());避免在循环中修改集合在使用foreach遍历List或数组时绝对不要对其进行添加或删除操作这会立即导致InvalidOperationException。如果需要可以收集需要处理的索引或元素在遍历结束后再统一操作。// 错误示例会在运行时抛出异常 foreach (var enemy in enemyList) { if (enemy.IsDead) { enemyList.Remove(enemy); // 错误 } } // 正确做法使用for循环倒序遍历 for (int i enemyList.Count - 1; i 0; i--) { if (enemyList[i].IsDead) { enemyList.RemoveAt(i); } } // 或者使用一个临时列表记录待删除项 ListEnemy toRemove new ListEnemy(); foreach (var enemy in enemyList) { if (enemy.IsDead) toRemove.Add(enemy); } foreach (var enemy in toRemove) { enemyList.Remove(enemy); }考虑使用结构体数组如果存储的是小型值类型如Vector3,int,Color使用结构体数组Vector3[]比使用ListVector3或ListTransform存储引用在内存局部性和缓存友好性上更好遍历速度更快。这在处理大量粒子、顶点数据时差异明显。5.3 实战模式对象池的实现对象池是重用游戏对象以避免频繁实例化Instantiate和销毁Destroy造成性能卡顿的经典模式。List在这里扮演了核心角色。using System.Collections.Generic; using UnityEngine; public class GameObjectPool : MonoBehaviour { public GameObject prefab; public int initialSize 10; private ListGameObject pooledObjects new ListGameObject(); void Start() { // 预初始化对象池 for (int i 0; i initialSize; i) { CreateNewPooledObject(); } } private GameObject CreateNewPooledObject() { GameObject obj Instantiate(prefab); obj.SetActive(false); // 创建后先禁用 pooledObjects.Add(obj); return obj; } public GameObject GetPooledObject() { // 遍历寻找第一个可用的禁用的对象 for (int i 0; i pooledObjects.Count; i) { if (!pooledObjects[i].activeInHierarchy) { pooledObjects[i].SetActive(true); return pooledObjects[i]; } } // 如果没有可用的就创建一个新的并加入池子 GameObject newObj CreateNewPooledObject(); newObj.SetActive(true); return newObj; } public void ReturnToPool(GameObject obj) { obj.SetActive(false); } }在这个简单实现中ListGameObject完美地管理了所有池中对象的引用。你可以用GetPooledObject获取一个对象用ReturnToPool归还。通过重用对象显著降低了GC频率。6. 常见问题、陷阱与排查技巧即使理解了原理在实际编码中还是会遇到各种奇怪的问题。下面是我总结的一些典型“坑”和解决方法。6.1 空引用与索引越界这是最最常见的两类运行时错误。问题现象NullReferenceException或IndexOutOfRangeException。排查思路访问前检查对于可能为null的引用类型元素如GameObject,Transform在访问其成员前一定要做判空。// 安全做法 if (enemyArray[i] ! null) { enemyArray[i].TakeDamage(10); }检查索引边界始终确保你的索引i满足0 i array.Length或0 i list.Count。在循环中使用Length或Count作为条件。注意List的Count与Capacity新手常犯的错误是用for (int i0; imyList.Capacity; i)来遍历这会导致访问到未初始化的部分可能为null对于引用类型或默认值对于值类型逻辑出错。6.2 值类型与引用类型的复制行为这个区别在集合操作中至关重要。// 值类型如int, float, Vector3, 结构体存储在集合中是其“副本”。 ListVector3 positions new ListVector3(); Vector3 pos new Vector3(1,2,3); positions.Add(pos); // 这里添加的是pos的一个“副本” pos.x 100; // 修改原变量 Debug.Log(positions[0].x); // 输出 1列表中的副本未被修改 // 引用类型如GameObject, Transform, 自定义类存储在集合中是其“引用”。 ListTransform transforms new ListTransform(); Transform objTransform someGameObject.transform; transforms.Add(objTransform); // 这里添加的是对objTransform的“引用” objTransform.position new Vector3(10,0,0); // 通过原引用修改对象 Debug.Log(transforms[0].position); // 输出 (10,0,0)列表中的引用指向同一个对象所以能看到修改理解这一点就能明白为什么修改了列表中的某个对象外部的原始对象也变了因为是引用或者为什么修改了外部变量列表里的值却没变因为是副本。6.3 遍历时修改集合的替代方案前面提到不能在foreach中增删。这里提供一个更健壮的、处理动态集合的通用模式// 假设我们需要在处理过程中根据条件删除一些敌人并可能添加新敌人 void ProcessEnemies() { // 方案一使用倒序for循环处理删除仅删除时有效 for (int i activeEnemies.Count - 1; i 0; i--) { if (activeEnemies[i].ShouldBeRemoved) { // 可以在这里执行销毁或回池逻辑 ReturnToPool(activeEnemies[i]); activeEnemies.RemoveAt(i); } } // 方案二使用临时列表处理复杂的增删逻辑 ListEnemy enemiesToAdd new ListEnemy(); ListEnemy enemiesToRemove new ListEnemy(); foreach (var enemy in activeEnemies) { enemy.Update(); if (enemy.ShouldSpawnMinion) { enemiesToAdd.Add(CreateMinion()); } if (enemy.IsDead) { enemiesToRemove.Add(enemy); } } // 遍历结束后统一修改原集合 foreach (var enemy in enemiesToRemove) { activeEnemies.Remove(enemy); } activeEnemies.AddRange(enemiesToAdd); }6.4 性能问题排查清单当游戏出现卡顿怀疑是集合操作导致时可以按以下步骤排查问题现象可能原因排查与优化建议频繁GC垃圾回收导致帧率周期性卡顿1. 在循环中频繁new List/new array。2. 频繁使用LINQ的ToList(),ToArray(),Where()等产生新集合的方法。3.List频繁扩容。1. 将临时集合提升为类成员变量或静态变量重用而非重建。2. 在性能关键路径避免使用LINQ改用for循环。3. 为List设置合理的初始容量。某一帧突然特别卡1. 单帧内进行了超大List的AddRange或Insert操作。2. 单帧内进行了超大数组的复制。1. 将大数据操作分摊到多帧完成协程。2. 考虑使用LinkedList如果需要在头部频繁插入。遍历速度慢1. 遍历ListStruct时struct过大导致拷贝开销。2. 遍历ListGameObject时每个元素都进行GetComponent等耗时操作。1. 对于大型结构体考虑使用数组或ref关键字C# 7.2来避免拷贝。2. 缓存GetComponent的结果避免在循环中重复调用。6.5 Unity特定调试技巧在Inspector中查看内容将public ListT或数组暴露出来在Play模式下可以实时观察其元素的变化是调试动态集合的利器。使用Debug.Log输出集合直接Debug.Log(myList)会输出类型名。更好的方式是使用string.Join。Listint scores new Listint{95, 80, 73}; Debug.Log(Scores: string.Join(, , scores)); // 输出: Scores: 95, 80, 73性能分析器使用Unity的Profiler窗口重点关注GC Alloc垃圾回收分配列。如果你在Update中看到持续的、大量的GC Alloc很可能就是不当的集合操作如每帧new List导致的。