1. 为什么数组值得单独拎出来聊
如果你刚开始用C#写Unity脚本,大概率已经接触过int、float、string这些基础类型了。但真正开始做点像样的东西——比如管理一堆敌人、存储一组路径点、记录玩家背包里的物品——你就会发现,单个变量根本不够用。这时候数组(Array)就登场了。
数组这个东西,说起来简单,不就是“一堆相同类型的数据排在一起”嘛。但我在带新人的过程中发现,恰恰是数组这块,埋了不少坑。有人写了个int[] scores = new int[5];然后往里面塞了6个值,运行时报IndexOutOfRangeException;有人把数组当参数传给函数,改了里面的值,结果发现原来的数组也跟着变了,一脸懵;还有人不知道Length和GetLength()的区别,在二维数组里翻车。
这篇笔记就是把我这些年用数组的经验整理出来,从最基础的概念到实际项目中的使用技巧,再到那些文档里不会写的避坑指南。不管你是刚学C#的新手,还是已经写过一些Unity脚本但想系统梳理一下的开发者,应该都能从中找到有用的东西。文章会涉及数组的声明、初始化、遍历、常见操作,以及在Unity中的典型应用场景,最后还会聊聊数组的局限性和什么时候该换用其他集合类型。
2. 数组的本质:连续内存里的同类型数据集合
2.1 从内存角度理解数组
要真正理解数组,得先知道它在内存里长什么样。数组是一块连续的内存空间,里面存放着相同类型的元素。这句话有两个关键词:连续、相同类型。
连续意味着什么?假设你声明了一个int[] numbers = new int[5];,在32位系统上,每个int占4个字节,那么这5个元素就会在内存中占据20个字节的连续空间。因为地址是连续的,所以访问numbers[3]的时候,计算机只需要知道数组的起始地址,然后加上3 * 4个字节的偏移量,就能直接定位到目标元素。这就是数组随机访问效率极高的原因——时间复杂度是O(1)。
相同类型则意味着数组里不能混着放int和string。你可能会说,那object[]不就能放任何东西了吗?确实可以,但那是通过装箱(boxing)实现的,会有性能损耗,而且取出来的时候还得拆箱(unboxing),在Unity这种对性能敏感的环境里,能不用就尽量不用。
注意:Unity中每帧都在执行的代码(比如
Update)里,尽量避免频繁的装箱拆箱操作,数组类型选对了能省不少性能。
2.2 数组的声明与初始化方式
C#里声明和初始化数组有好几种写法,我按常见程度排个序:
// 方式一:声明时直接初始化,最常用 int[] scores = { 90, 85, 78, 92, 88 }; // 方式二:指定长度,后续赋值 int[] scores = new int[5]; scores[0] = 90; scores[1] = 85; // ... // 方式三:new + 初始化列表 int[] scores = new int[] { 90, 85, 78, 92, 88 }; // 方式四:new + 长度 + 初始化列表(长度必须匹配) int[] scores = new int[5] { 90, 85, 78, 92, 88 };方式一最简洁,适合已知所有元素的情况。方式二适合先占坑后填值。方式三和方式四其实差不多,方式四多此一举地写了长度,实际项目中用得少。
这里有个细节值得注意:new int[5]创建出来的数组,每个元素都会被赋予该类型的默认值。int的默认值是0,float是0.0f,bool是false,引用类型(比如string、自定义类)是null。这个特性有时候很有用,比如你需要一个全零的分数数组,直接new int[10]就行了,不用手动一个个赋0。
2.3 值类型数组和引用类型数组的区别
这是新手最容易踩的坑之一。int[]是值类型数组,GameObject[]是引用类型数组,它们在内存中的布局和行为是不一样的。
值类型数组里,元素本身就存在数组的连续内存块里。而引用类型数组里,数组存的是引用(地址),实际的对象在堆内存的其他地方。这意味着:
- 值类型数组的访问更快,因为不需要通过引用跳转
- 引用类型数组初始化后,每个元素都是
null,你必须逐个new出来才能用
// 值类型数组:元素默认是0 int[] nums = new int[3]; // [0, 0, 0] // 引用类型数组:元素默认是null GameObject[] enemies = new GameObject[3]; // [null, null, null] // 直接访问 enemies[0].transform 会报NullReferenceException // 必须先 enemies[0] = new GameObject(); 或者赋值一个已有对象我见过不少新手写了GameObject[] enemies = new GameObject[5];然后直接enemies[0].SetActive(false);,结果报空引用异常,找了半天没发现问题在哪。记住:引用类型数组的初始化只是创建了放引用的“架子”,里面的对象还得你自己创建或赋值。
3. 数组的核心操作与实战技巧
3.1 遍历数组的几种方式及选择依据
遍历数组是最基本的操作,C#提供了好几种方式:
int[] scores = { 90, 85, 78, 92, 88 }; // 方式一:for循环,最灵活 for (int i = 0; i < scores.Length; i++) { Debug.Log(scores[i]); } // 方式二:foreach,最简洁 foreach (int score in scores) { Debug.Log(score); } // 方式三:Array.ForEach,函数式风格 Array.ForEach(scores, score => Debug.Log(score));那到底该用哪个?我的经验是:
- 需要索引值(比如要根据索引做额外操作),用
for - 只是简单遍历所有元素,用
foreach - 需要逆序遍历或者跳跃遍历,只能用
for
foreach在底层其实也是通过索引器访问的,对于数组来说,编译器会优化成和for差不多的效率。但在Unity中有一个老生常谈的问题:foreach在某些情况下会产生垃圾回收(GC)开销。不过对于数组来说,C#编译器会特殊处理,不会产生额外的GC。所以遍历数组时foreach是可以放心用的。
实操心得:如果你在
Update里遍历的是List<T>而不是数组,那foreach确实可能产生GC。但数组不用担心这个问题,这是数组相比List的一个小优势。
3.2 数组的常用属性和方法
数组本身提供了一些实用的属性和方法,我整理了一个速查表:
| 成员 | 类型 | 说明 | 示例 |
|---|---|---|---|
Length | 属性 | 获取所有维度元素总数 | arr.Length |
Rank | 属性 | 获取维度数 | arr.Rank |
GetLength(int) | 方法 | 获取指定维度的长度 | arr.GetLength(0) |
GetValue(int) | 方法 | 获取指定索引的值 | arr.GetValue(2) |
SetValue(obj, int) | 方法 | 设置指定索引的值 | arr.SetValue(10, 2) |
Clone() | 方法 | 浅拷贝数组 | arr.Clone() |
CopyTo() | 方法 | 复制到另一个数组 | arr.CopyTo(dest, 0) |
这里重点说一下Length和GetLength()的区别。对于一维数组,Length和GetLength(0)的结果是一样的。但对于二维数组,Length返回的是所有元素的总数,而GetLength(0)返回的是第一维的长度(行数),GetLength(1)返回的是第二维的长度(列数)。
int[,] grid = new int[3, 4]; Debug.Log(grid.Length); // 输出 12(3 * 4) Debug.Log(grid.GetLength(0)); // 输出 3(行数) Debug.Log(grid.GetLength(1)); // 输出 4(列数)我见过有人在二维数组里用Length来遍历行,结果越界了。记住这个区别,能省不少调试时间。
3.3 数组作为参数传递的陷阱
数组是引用类型,所以把它传给函数时,传的是引用。这意味着函数内部修改数组元素,外部的数组也会跟着变。这个特性有时候是好事(避免拷贝大数组),有时候是坑(不小心改了原始数据)。
void ModifyArray(int[] arr) { arr[0] = 999; } int[] myScores = { 90, 85, 78 }; ModifyArray(myScores); Debug.Log(myScores[0]); // 输出 999,原始数组被修改了如果你不想让函数修改原始数组,有两个办法:
- 在函数内部先
Clone()一份再操作 - 使用
Array.Copy()创建一个副本传进去
void SafeModify(int[] arr) { int[] copy = (int[])arr.Clone(); copy[0] = 999; // 原始数组不受影响 }注意:
Clone()是浅拷贝。对于值类型数组,浅拷贝就是完全独立的副本。但对于引用类型数组,克隆出来的数组里的引用还是指向同一批对象。这一点要特别小心。
3.4 数组的排序与查找
C#的Array类提供了静态的排序和查找方法,用起来很方便:
int[] scores = { 90, 85, 78, 92, 88 }; // 升序排序 Array.Sort(scores); // { 78, 85, 88, 90, 92 } // 反转 Array.Reverse(scores); // { 92, 90, 88, 85, 78 } // 查找元素索引 int index = Array.IndexOf(scores, 88); // 返回2 // 二分查找(必须先排序) int index2 = Array.BinarySearch(scores, 88);Array.Sort()底层用的是快速排序(Quicksort),平均时间复杂度O(n log n)。对于小数组(元素少于16个),它会切换成插入排序,因为小数据量下插入排序更快。这些细节你不需要手动实现,但知道底层原理有助于理解性能特征。
Array.IndexOf()是线性查找,从头到尾一个个比对,时间复杂度O(n)。如果数组很大且需要频繁查找,考虑先用Array.Sort()排序,然后用Array.BinarySearch(),后者的时间复杂度是O(log n)。
在Unity中,如果你有一个敌人数组需要按距离排序,可以这样写:
GameObject[] enemies = GameObject.FindGameObjectsWithTag("Enemy"); Vector3 playerPos = transform.position; // 按距离升序排序 Array.Sort(enemies, (a, b) => { float distA = Vector3.Distance(a.transform.position, playerPos); float distB = Vector3.Distance(b.transform.position, playerPos); return distA.CompareTo(distB); });这个用法在实际项目中非常常见,比如做自动瞄准、范围检测之类的功能。
4. 多维数组与交错数组的选用
4.1 二维数组的声明与遍历
C#支持多维数组,最常见的是二维数组,用来表示网格、地图、矩阵等结构:
// 声明一个3行4列的二维数组 int[,] grid = new int[3, 4]; // 初始化赋值 int[,] map = { { 1, 1, 1, 1 }, { 1, 0, 0, 1 }, { 1, 1, 1, 1 } }; // 遍历二维数组 for (int row = 0; row < map.GetLength(0); row++) { for (int col = 0; col < map.GetLength(1); col++) { Debug.Log($"({row},{col}) = {map[row, col]}"); } }二维数组在Unity里做格子地图特别方便。比如一个简单的迷宫,0表示通路,1表示墙壁,用二维数组存起来,遍历的时候直接双重循环就行。
4.2 交错数组:数组的数组
交错数组(Jagged Array)是“数组的数组”,每一行的长度可以不同:
// 声明一个包含3个数组的数组,每个子数组长度不同 int[][] jagged = new int[3][]; jagged[0] = new int[] { 1, 2 }; jagged[1] = new int[] { 3, 4, 5, 6 }; jagged[2] = new int[] { 7 }; // 遍历 for (int i = 0; i < jagged.Length; i++) { for (int j = 0; j < jagged[i].Length; j++) { Debug.Log(jagged[i][j]); } }交错数组和二维数组的区别在于:二维数组是规则的矩形,每行长度必须相同;交错数组的每行长度可以不同。在内存布局上,二维数组是一整块连续内存,交错数组是多个独立的一维数组。
那什么时候用哪个?我的经验是:
- 数据是规则的矩形结构(比如格子地图、矩阵运算),用二维数组
- 数据是“每行长度不定”的结构(比如每个玩家的背包物品列表),用交错数组
- 性能上二维数组略优,因为内存连续,缓存命中率更高
4.3 在Unity中的典型应用场景
二维数组在Unity里最常见的用途就是格子地图。比如做一个战棋游戏,地图就是一个二维数组:
public enum TileType { Empty, Wall, Grass, Water } TileType[,] mapData = new TileType[10, 10]; void GenerateMap() { for (int x = 0; x < mapData.GetLength(0); x++) { for (int y = 0; y < mapData.GetLength(1); y++) { // 根据坐标生成不同的地形 if (x == 0 || y == 0 || x == 9 || y == 9) mapData[x, y] = TileType.Wall; else mapData[x, y] = TileType.Grass; } } }交错数组则适合存储每个角色的技能列表、每个关卡的敌人配置这类数据:
string[][] characterSkills = new string[3][]; characterSkills[0] = new string[] { "火球术", "冰霜箭" }; characterSkills[1] = new string[] { "治疗术", "护盾", "复活" }; characterSkills[2] = new string[] { "斩击" };5. 数组的局限性:什么时候该换用其他集合
5.1 固定长度的困扰
数组最大的局限就是长度固定。一旦创建,就不能动态增减元素。这在很多场景下很不方便。比如你要做一个动态的背包系统,玩家可能捡到新物品,也可能丢弃物品,用数组就得预先分配一个足够大的空间,然后自己维护一个“当前元素个数”的变量。
// 用数组实现动态列表的笨办法 Item[] inventory = new Item[100]; int itemCount = 0; void AddItem(Item item) { if (itemCount < inventory.Length) { inventory[itemCount] = item; itemCount++; } } void RemoveItem(int index) { for (int i = index; i < itemCount - 1; i++) { inventory[i] = inventory[i + 1]; } inventory[itemCount - 1] = null; itemCount--; }这样写当然能跑,但很繁琐,而且容易出错。C#提供了List<T>,内部封装了动态扩容的逻辑,用起来方便得多。
5.2 数组 vs List vs 其他集合的选型对比
| 特性 | 数组 T[] | List | Dictionary<K,V> |
|---|---|---|---|
| 长度 | 固定 | 动态 | 动态 |
| 访问方式 | 索引 | 索引 | 键 |
| 访问速度 | 最快 | 快 | 较快(哈希) |
| 插入/删除 | 不支持 | 支持(中间插入慢) | 支持 |
| 内存占用 | 最小 | 略大 | 较大 |
| 适用场景 | 数据量固定、性能敏感 | 需要动态增减 | 需要键值对查找 |
我的选型原则很简单:
- 数据量在运行过程中不变,或者变化很少,用数组
- 需要频繁增删元素,用
List<T> - 需要通过一个键快速找到对应的值,用
Dictionary<K,V>
在Unity中,Update里每帧遍历的数据,如果数量固定,优先用数组,因为数组的遍历效率最高,而且不会产生GC。如果数量会变化,用List<T>,但要注意在Update里遍历List<T>时用for而不是foreach,避免GC开销。
5.3 从数组到List的平滑过渡
如果你已经用数组写了一部分代码,后来发现需要动态扩容,迁移到List<T>其实很简单:
// 数组转List int[] array = { 1, 2, 3, 4, 5 }; List<int> list = new List<int>(array); // List转数组 int[] newArray = list.ToArray();List<T>内部其实也是用数组实现的,只是帮你封装了扩容逻辑。当你new List<int>()的时候,它默认创建一个容量为4的数组。当你添加第5个元素时,它会创建一个容量翻倍的新数组(容量变成8),把旧数据拷过去,然后再添加新元素。这个扩容过程有一定的性能开销,所以如果你大概知道最终会有多少个元素,可以在创建List<T>时指定初始容量:
// 预估有100个元素,直接指定容量,避免多次扩容 List<Item> inventory = new List<Item>(100);这个技巧在性能敏感的场景下很有用,能减少不必要的内存分配和拷贝。
6. 常见问题与排查技巧实录
6.1 索引越界异常:最常见也最容易避免
IndexOutOfRangeException是数组操作中最常见的异常。原因无非两种:索引小于0,或者索引大于等于数组长度。
int[] arr = new int[5]; // arr[5] = 10; // 报错:索引越界 // arr[-1] = 10; // 报错:索引越界排查这个问题的关键是:在访问数组元素之前,始终确认索引在有效范围内。
int index = GetSomeIndex(); if (index >= 0 && index < arr.Length) { arr[index] = value; } else { Debug.LogWarning($"索引 {index} 超出数组范围 [0, {arr.Length - 1}]"); }实操心得:在Unity中,我习惯在可能越界的地方加一个
Debug.LogWarning,这样即使出了问题也能快速定位。比直接报异常然后去翻堆栈要高效得多。
6.2 空引用异常:引用类型数组的专属坑
前面提到过,GameObject[]、Transform[]这类引用类型数组,初始化后每个元素都是null。如果你直接访问arr[0].transform,就会报NullReferenceException。
GameObject[] enemies = new GameObject[3]; // enemies[0].SetActive(false); // 报错:空引用 // 正确做法:先检查再访问 if (enemies[0] != null) { enemies[0].SetActive(false); }在Unity中,还有一个特殊情况:对象被Destroy之后,数组里的引用会变成“伪null”。你用== null判断会返回true,但实际上引用还在。这是Unity重写了==运算符的结果。所以判断Unity对象是否有效,用== null或者ReferenceEquals要小心,最好用if (obj)这种隐式bool转换。
6.3 数组拷贝的浅拷贝陷阱
前面提过Clone()是浅拷贝,这里展开说一下。对于值类型数组,浅拷贝没问题。但对于引用类型数组,克隆出来的数组和原数组指向同一批对象:
GameObject[] original = new GameObject[2]; original[0] = new GameObject("A"); original[1] = new GameObject("B"); GameObject[] copy = (GameObject[])original.Clone(); copy[0].name = "C"; Debug.Log(original[0].name); // 输出 "C",原数组的对象也被改了如果你需要完全独立的副本(深拷贝),得手动逐个创建新对象:
GameObject[] deepCopy = new GameObject[original.Length]; for (int i = 0; i < original.Length; i++) { deepCopy[i] = Instantiate(original[i]); }6.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| IndexOutOfRangeException | 索引超出数组长度 | 访问前检查0 <= index < Length |
| NullReferenceException | 引用类型数组元素未赋值 | 使用前判空或先初始化 |
| 修改副本影响原数组 | 浅拷贝导致引用共享 | 需要深拷贝时手动创建新对象 |
| foreach遍历时修改元素报错 | 不能在foreach中修改集合 | 改用for循环 |
| 二维数组遍历越界 | 混淆Length和GetLength | 用GetLength(维度)获取对应维度长度 |
| 数组排序后索引错乱 | 排序改变了元素顺序 | 排序前记录原始索引或使用键值对 |
6.5 几个容易被忽略的细节
数组的默认值:new int[5]是[0,0,0,0,0],new bool[3]是[false,false,false],new string[2]是[null,null]。这个特性可以用来快速初始化,但也要注意引用类型的null问题。
数组的长度不可变:arr.Length是只读的,你不能写arr.Length = 10。需要变长数组就用List<T>或者Array.Resize(注意Array.Resize其实是创建了新数组)。
Array.Resize的真相:Array.Resize(ref arr, newSize)并不是在原数组上扩容,而是创建了一个新数组,把旧数据拷过去,然后让arr指向新数组。所以如果有其他地方引用了旧数组,那些引用不会更新。
int[] arr = { 1, 2, 3 }; int[] refToArr = arr; Array.Resize(ref arr, 5); // 此时 arr 指向新数组,refToArr 还指向旧数组(长度为3)这个坑我在实际项目中踩过,当时有个数组被多个地方引用,用Array.Resize扩容后,发现有些地方的数据没更新,排查了半天才发现是引用没同步。
7. 一些实战中的经验总结
数组这个东西,入门容易,用好却需要一些经验积累。我个人的体会是,在Unity项目里,数组最适合用在那些数据量固定、访问频繁、性能敏感的场景,比如每帧要遍历的路径点、固定数量的技能槽位、格子地图数据等。而一旦涉及到动态增删,就应该果断换成List<T>,不要为了省一点内存而把代码写得又臭又长。
另外,关于数组的性能优化,有一个点值得注意:数组的缓存友好性。因为数组在内存中是连续的,CPU缓存命中率高,所以遍历数组通常比遍历链表快得多。这也是为什么Unity的ECS架构、Job System都大量使用数组(或者NativeArray)的原因。如果你在做性能优化,把数据从List<T>转成数组来遍历,有时候能带来意想不到的提升。
最后说一个我经常用的技巧:当你需要返回多个值的时候,与其定义一个out参数或者创建一个临时类,不如直接返回一个数组。比如计算某个物体的包围盒范围,返回float[] { minX, maxX, minY, maxY },简单直接。当然,如果这些值有明确的语义,还是定义一个结构体更清晰。数组是工具,用对了地方才能发挥价值。