1. 为什么数组值得单独拎出来讲如果你刚开始用C#写Unity脚本大概率已经接触过int、float、string这些基础类型。但真正开始做游戏逻辑的时候你会发现单个变量根本不够用——管理一排敌人、一组道具、一串关卡分数这些场景都需要一次性处理多个同类型的数据。数组就是解决这类问题最基础、最直接的工具。我在刚学Unity那会儿总觉得数组这东西太简单了不就是int[] nums new int[5]吗结果在实际项目里踩了不少坑索引越界导致游戏崩溃、数组长度写死后期改不动、不知道什么时候该用数组什么时候该用List。后来带过几个新人发现他们遇到的问题几乎一模一样。所以这篇笔记我打算把数组这个东西从头到尾讲透不只是语法层面更重要的是在Unity实际开发中怎么用、什么时候用、哪里容易翻车。这篇文章适合两类人一是刚接触C#和Unity、对数组还停留在“知道有这么个东西”阶段的初学者二是已经用过数组但经常被索引越界、长度固定等问题困扰的开发者。我会从数组的本质讲起把内存布局、声明方式、遍历方法、与List的取舍、常见异常处理这些内容全部串起来配上可以直接跑的代码示例。读完你至少能做到知道数组在内存里长什么样能熟练用各种方式遍历和操作数组遇到索引越界能快速定位问题并且清楚什么场景该用数组、什么场景该换List。2. 数组的本质与内存布局2.1 数组到底是什么从内存角度理解很多人学数组的时候老师会告诉你“数组就是一组相同类型的数据集合”。这个说法没错但太表面了。要真正理解数组得从内存的角度看。C#里的数组是一种引用类型它在内存中是一块连续分配的区域。假设你声明了一个int[] scores new int[5]在32位系统上每个int占4个字节那么这5个int会紧挨着排在内存里总共占20个字节。变量scores本身存在栈上它保存的是这块连续内存的首地址。这种连续内存布局带来两个直接后果。第一访问速度极快。因为元素地址可以通过“首地址 索引 × 元素大小”直接算出来不需要像链表那样从头遍历。第二长度一旦确定就不能改。因为这块内存是连续分配的后面可能已经被其他数据占用了你没法直接在原地址上扩展。注意C#的数组和C/C的数组在内存模型上不完全一样。C#数组在堆上分配带有类型信息和长度信息访问时会做边界检查。这也是为什么C#里索引越界会抛异常而不是像C那样直接读到脏数据。2.2 值类型数组和引用类型数组的区别这一点特别容易被忽略但在Unity开发中非常关键。如果你声明的是int[]、float[]、bool[]、Vector3[]Vector3是结构体那数组里存的是实际的值。每个元素直接占据那块连续内存的一部分。但如果你声明的是GameObject[]、Transform[]、string[]数组里存的是引用也就是指向实际对象的地址。实际的对象存在堆的其他地方数组那块连续内存里放的是一个个指针。这个区别为什么重要因为当你复制数组或者传递数组的时候行为是不一样的。值类型数组复制时会把所有值都拷贝一份引用类型数组复制时只拷贝引用两个数组指向的还是同一批对象。我在项目里就遇到过这个问题把一个Transform[]传给另一个方法方法里修改了数组元素的属性结果原数组也变了——因为大家指向的是同一个Transform对象。// 值类型数组复制后互不影响 int[] a { 1, 2, 3 }; int[] b (int[])a.Clone(); b[0] 100; // a[0] 仍然是 1 // 引用类型数组复制后仍指向同一批对象 GameObject[] objs1 { go1, go2 }; GameObject[] objs2 (GameObject[])objs1.Clone(); objs2[0].name Changed; // objs1[0].name 也变成了 Changed2.3 数组的默认值新建一个数组后如果没有显式赋值每个元素会被自动赋予该类型的默认值。这个规则在Unity里经常被忽略导致一些莫名其妙的bug。类型默认值int / float / double0boolfalsestring / 引用类型nullVector3 / 结构体各字段默认值通常是零向量enum第一个枚举值对应0我踩过的一个坑用bool[]记录关卡解锁状态新建数组后以为默认是false没问题结果逻辑写反了把false当成已解锁导致所有关卡一开始就是打开状态。所以新建数组后如果默认值不符合你的业务逻辑一定要手动初始化一遍。3. 数组的声明、初始化与常用操作3.1 四种声明初始化方式及其适用场景C#里声明数组有好几种写法初学者容易搞混。我把它们列出来并说明各自适合什么场景。// 方式一先声明再分配 int[] arr1; arr1 new int[5]; // 方式二声明时直接分配 int[] arr2 new int[5]; // 方式三声明时初始化具体值 int[] arr3 new int[] { 1, 2, 3, 4, 5 }; // 方式四简化写法最常用 int[] arr4 { 1, 2, 3, 4, 5 };方式一和方式二适合你暂时不知道具体值、但知道长度的场景比如根据关卡数量动态创建分数数组。方式三和方式四适合你已经知道具体内容的场景比如配置一组固定的敌人生成位置。在Unity里我还经常用另一种模式先声明一个空数组然后在Start或Awake里根据实际情况填充。private GameObject[] enemies; void Start() { int count GameObject.FindGameObjectsWithTag(Enemy).Length; enemies new GameObject[count]; // 后续填充... }3.2 索引与长度两个必须刻在脑子里的规则数组的索引从0开始长度用Length属性获取。这两个规则看起来简单但实际写代码时出错率极高。int[] scores { 90, 85, 78, 92, 88 }; // 索引范围0 到 4 // 长度5 Debug.Log(scores[0]); // 90第一个元素 Debug.Log(scores[4]); // 88最后一个元素 Debug.Log(scores.Length); // 5 Debug.Log(scores[scores.Length - 1]); // 88安全的访问最后一个元素记住一个公式最后一个元素的索引 Length - 1。任何时候你要访问数组末尾都用arr[arr.Length - 1]不要硬编码数字。我见过太多人写arr[4]结果数组长度改成6之后忘了改索引直接越界。3.3 遍历数组的三种方式及性能对比遍历数组是最常见的操作C#提供了好几种方式各有适用场景。for循环是最灵活的因为你可以在遍历过程中修改元素也可以控制遍历顺序。for (int i 0; i arr.Length; i) { arr[i] arr[i] * 2; // 可以修改元素 }foreach循环写起来最简洁适合只读遍历。foreach (int item in arr) { Debug.Log(item); // 只读不能修改item来影响原数组 }Array.ForEach静态方法适合对每个元素执行同一个操作。Array.ForEach(arr, item Debug.Log(item));性能方面在Unity的Mono编译器下for循环通常比foreach略快因为foreach在某些情况下会产生额外的迭代器开销。但对于普通数组现代C#编译器的foreach优化已经很好差异可以忽略。我的建议是需要修改元素或需要索引时用for只读遍历用foreach代码可读性优先。实操心得在Update等每帧执行的方法里遍历大数组时尽量避免在循环体内做Find、GetComponent这类耗时操作。我一般会提前把需要的数据缓存到数组里循环里只做纯计算。4. 数组在Unity中的典型应用场景4.1 管理多个游戏对象这是数组在Unity里最常见的用途之一。比如你有一组敌人需要在关卡开始时全部激活或者全部重置位置。public class EnemyManager : MonoBehaviour { private GameObject[] enemies; void Start() { // 通过标签查找所有敌人 enemies GameObject.FindGameObjectsWithTag(Enemy); Debug.Log(找到敌人数量 enemies.Length); } public void ResetAllEnemies() { for (int i 0; i enemies.Length; i) { enemies[i].SetActive(true); enemies[i].transform.position Vector3.zero; } } }这里有个细节FindGameObjectsWithTag返回的就是一个数组不需要你手动分配长度。但要注意这个方法在场景中对象很多时性能开销较大不建议在Update里每帧调用。我通常只在Start或关卡初始化时调用一次把结果缓存起来。4.2 存储配置数据做游戏经常需要配置一组数据比如每波的敌人数、每个关卡的时限、每种武器的伤害值。这些用数组存非常合适。public class LevelConfig : MonoBehaviour { // 每波敌人数量 private int[] enemiesPerWave { 3, 5, 8, 12, 20 }; // 每波间隔时间秒 private float[] waveIntervals { 5f, 4f, 3.5f, 3f, 2.5f }; public int GetEnemyCount(int waveIndex) { if (waveIndex 0 || waveIndex enemiesPerWave.Length) { Debug.LogWarning(波次索引越界 waveIndex); return 0; } return enemiesPerWave[waveIndex]; } }这种写法的好处是数据集中、一目了然改数值不用翻遍代码。但缺点是长度固定如果策划想动态增加波次就不方便了。这种情况我会改用List后面会详细对比。4.3 与Inspector配合在编辑器中配置数组Unity的一个强大之处是可以在Inspector面板里直接编辑数组。只要把数组声明为public或者加上[SerializeField]就能在编辑器里可视化地增删元素。public class SpawnPoints : MonoBehaviour { [SerializeField] private Transform[] spawnPoints; void Start() { foreach (var point in spawnPoints) { Debug.Log(生成点位置 point.position); } } }在Inspector里你会看到一个可折叠的列表可以设置Size来增减元素数量每个元素都能拖拽赋值。这个功能在关卡设计时特别实用策划不用改代码就能调整生成点。注意在Inspector里修改数组长度后已有元素的值会保留新增的元素会是默认值。但如果你在代码里重新赋值整个数组Inspector里的配置会被覆盖。所以一般不要在运行时给Inspector配置的数组整体赋值。4.4 多维数组与交错数组C#支持多维数组语法上用逗号分隔维度。// 二维数组3行4列 int[,] grid new int[3, 4]; grid[0, 0] 1; grid[2, 3] 5; // 遍历二维数组 for (int row 0; row grid.GetLength(0); row) { for (int col 0; col grid.GetLength(1); col) { Debug.Log(grid[row, col]); } }还有一种是交错数组也就是数组的数组。int[][] jagged new int[3][]; jagged[0] new int[] { 1, 2 }; jagged[1] new int[] { 3, 4, 5 }; jagged[2] new int[] { 6 };多维数组适合规则网格比如棋盘、地图格子。交错数组适合每行长度不一致的情况比如不同关卡的道具列表。在Unity里做网格地图时二维数组用得比较多但要注意GetLength(0)和GetLength(1)分别对应第一维和第二维的长度别搞反了。5. 数组的常见操作与避坑指南5.1 查找、排序、反转Array类的静态方法C#的System.Array类提供了一批静态方法可以直接操作数组不用自己写循环。int[] nums { 5, 2, 8, 1, 9 }; Array.Sort(nums); // 升序排列1, 2, 5, 8, 9 Array.Reverse(nums); // 反转9, 8, 5, 2, 1 int index Array.IndexOf(nums, 8); // 查找元素返回索引 bool exists Array.Exists(nums, x x 7); // 条件查找 int found Array.Find(nums, x x 7); // 返回第一个匹配元素这些方法用起来很方便但有几个坑要注意。Array.Sort会直接修改原数组不是返回新数组。Array.IndexOf如果找不到元素会返回-1不是抛异常所以调用后要判断返回值。Array.Find如果找不到会返回该类型的默认值对于int就是0这可能导致你分不清“找到了0”和“没找到”。5.2 数组的复制浅拷贝与深拷贝复制数组有好几种方式行为各不相同。int[] source { 1, 2, 3, 4, 5 }; // 方式一Clone返回object需要强转 int[] copy1 (int[])source.Clone(); // 方式二CopyTo int[] copy2 new int[source.Length]; source.CopyTo(copy2, 0); // 方式三Array.Copy int[] copy3 new int[source.Length]; Array.Copy(source, copy3, source.Length);对于值类型数组这三种方式都是深拷贝复制后互不影响。但对于引用类型数组它们都是浅拷贝复制的是引用两个数组指向同一批对象。如果需要真正的深拷贝得自己遍历并逐个创建新对象。我在项目里遇到过一个典型问题用一个Vector3[]记录路径点想备份一份原始数据用了Clone()结果修改备份数据时原数据也变了。后来才反应过来Vector3虽然是结构体但如果数组里存的是包含引用类型字段的自定义类Clone仍然是浅拷贝。所以复制数组前一定要想清楚我需要的是引用复制还是值复制。5.3 索引越界的排查与预防索引越界是数组相关bug里出现频率最高的。典型报错是IndexOutOfRangeException: Index was outside the bounds of the array。排查思路很简单找到报错的那行代码看数组的Length是多少看索引值是多少对比一下就知道问题在哪。但实际项目中索引往往是计算出来的不是硬编码的所以需要往前追溯。// 危险写法 int index someValue - 1; arr[index] 10; // 如果someValue是0index就是-1直接越界 // 安全写法 int index someValue - 1; if (index 0 index arr.Length) { arr[index] 10; } else { Debug.LogWarning(索引越界someValue someValue); }预防措施我总结了几条第一任何计算出来的索引在使用前都做边界检查第二循环条件用i arr.Length而不是硬编码数字第三访问最后一个元素用arr[arr.Length - 1]第四在可能出问题的地方加日志方便定位。5.4 数组长度固定带来的设计限制数组一旦创建长度就不能改。这在某些场景下会很麻烦。比如做一个动态列表玩家可以不断添加道具用数组就得预先分配一个很大的长度浪费内存或者每次添加都新建一个更大的数组并复制性能很差。// 数组的扩容手动新建并复制 int[] oldArr { 1, 2, 3 }; int[] newArr new int[oldArr.Length 1]; Array.Copy(oldArr, newArr, oldArr.Length); newArr[newArr.Length - 1] 4;这种操作写起来繁琐性能也不好。所以当你需要频繁增删元素时应该考虑用ListT。但数组也不是没有优势内存连续、访问速度快、没有额外开销。在元素数量固定、频繁访问的场景下数组仍然是更好的选择。6. 数组与List的取舍什么时候该换6.1 核心差异对比对比项数组 Array列表 List长度固定创建后不可变动态可增删内存连续分配无额外开销内部也是数组有容量管理开销访问速度极快直接索引快略慢于数组增删元素不支持需手动扩容支持Add/Remove/Insert适用场景数量固定、频繁访问数量动态变化Unity序列化支持Inspector编辑也支持Inspector编辑6.2 我的实际选择标准在Unity项目里我一般按这个标准选如果元素数量在游戏运行期间基本不变比如一组固定的生成点、一套配置数据用数组。如果元素数量会动态变化比如玩家背包、敌人列表、消息队列用List。还有一个考虑因素是性能敏感度。在每帧执行的热路径上如果元素数量固定数组的访问速度优势会体现出来。但在大多数游戏逻辑里这个差异微乎其微可读性和可维护性更重要。// 数量固定用数组 private Transform[] waypoints; // 数量动态用List private ListGameObject activeEnemies new ListGameObject(); void OnEnemyDead(GameObject enemy) { activeEnemies.Remove(enemy); }6.3 数组和List之间的转换有时候需要在两者之间转换。List可以用ToArray()转成数组数组可以用new ListT(array)转成List。int[] arr { 1, 2, 3 }; Listint list new Listint(arr); int[] backToArray list.ToArray();转换本身有性能开销因为涉及内存分配和复制。所以不要在Update里频繁转换最好在初始化时转好运行时直接用。实操心得如果某个数据既需要Inspector配置又需要运行时动态增删我会用数组做配置运行时转成List来操作。这样策划在编辑器里配得舒服代码逻辑也灵活。7. 常见问题与排查技巧实录7.1 高频问题速查表问题现象可能原因解决方法IndexOutOfRangeException索引超出0到Length-1范围检查索引计算加边界判断NullReferenceException数组本身为null或元素为null检查是否new过元素是否赋值数组元素全是默认值只new了没赋值手动初始化每个元素Inspector里改了数组但代码没生效代码运行时覆盖了数组检查是否有重新赋值foreach里修改元素无效foreach迭代变量是只读副本改用for循环数组复制后原数组也变了引用类型数组浅拷贝需要深拷贝时手动逐个复制7.2 几个我踩过的真实坑第一个坑是关于FindGameObjectsWithTag的返回顺序。这个方法返回的数组顺序是不确定的不保证和Hierarchy面板里的顺序一致。我曾经依赖这个顺序来做敌人编号结果每次运行顺序都不一样。后来改成按名字排序或者手动指定顺序才解决。第二个坑是数组在Inspector里的序列化。如果你在代码里声明了一个public数组然后在Inspector里配置了元素之后又在代码里给这个数组整体赋了新值Inspector里的配置就丢了。正确做法是运行时操作数组的单个元素而不是整体替换。第三个坑是关于多维数组的序列化。Unity的Inspector不支持直接序列化多维数组你只能看到一维数组。如果需要在编辑器里配置二维数据要么用一维数组加索引换算要么用自定义的序列化类。7.3 调试数组的实用技巧在Unity里调试数组我常用这几招。第一用Debug.Log打印数组时直接打印数组变量只会输出类型名要用string.Join拼接。int[] nums { 1, 2, 3 }; Debug.Log(string.Join(, , nums)); // 输出1, 2, 3第二在Inspector里查看运行时数组可以把数组声明为public运行时在Inspector里实时观察元素变化。第三对于复杂数组写一个专门的调试方法格式化输出所有元素及其索引。void DebugArrayT(T[] arr, string name) { string result name [; for (int i 0; i arr.Length; i) { result i : arr[i] (i arr.Length - 1 ? , : ); } result ]; Debug.Log(result); }这个泛型方法可以打印任意类型的数组带索引一目了然。我在排查数组相关bug时基本都会先调这个方法看看数组的实际内容。7.4 性能相关的注意事项数组本身性能很好但使用不当也会拖慢游戏。几个要点避免在Update里分配新数组因为每次分配都会产生垃圾回收压力遍历大数组时把Length缓存到局部变量虽然现代编译器会优化但显式写出来更保险避免在循环里做字符串拼接尤其是调试日志发布版本要关掉。// 不推荐每帧分配新数组 void Update() { int[] temp new int[100]; // ... } // 推荐在Start里分配Update里复用 private int[] temp; void Start() { temp new int[100]; } void Update() { // 直接使用temp不重新分配 }这个习惯在移动端尤其重要因为移动设备对垃圾回收更敏感。我做过一个测试在Update里每帧分配一个100长度的int数组几分钟后就能明显感觉到帧率波动。改成复用之后帧率稳定了很多。8. 从数组出发的进阶方向数组是C#数据结构的基础理解了数组之后再学其他集合类型会轻松很多。List内部就是用数组实现的Dictionary的桶结构也和数组有关甚至Unity的Job System里用的NativeArray也是数组的变体。如果你想继续深入我建议按这个顺序扩展先搞懂List的内部实现看看它是怎么处理扩容的然后学Dictionary和HashSet理解哈希表再往后可以看Span和Memory这是C#里处理连续内存的高性能方式。每一步都建立在数组的基础上基础打牢了后面学什么都快。我在实际项目中用得最多的还是普通数组和List偶尔会用Dictionary做快速查找。数组虽然简单但它是所有集合类型的根基把这个根基理解透了遇到任何数据结构问题都能从内存布局和访问模式两个角度去分析。这比死记API有用得多。