首页 / 资讯中心 / 文章详情

C#数组从入门到避坑:Unity开发中的核心技巧与实战指南

C#数组从入门到避坑:Unity开发中的核心技巧与实战指南 ★ FEATURED ARTICLE
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的默认值是0float是0.0fbool是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需要逆序遍历或者跳跃遍历只能用forforeach在底层其实也是通过索引器访问的对于数组来说编译器会优化成和for差不多的效率。但在Unity中有一个老生常谈的问题foreach在某些情况下会产生垃圾回收GC开销。不过对于数组来说C#编译器会特殊处理不会产生额外的GC。所以遍历数组时foreach是可以放心用的。实操心得如果你在Update里遍历的是ListT而不是数组那foreach确实可能产生GC。但数组不用担心这个问题这是数组相比List的一个小优势。3.2 数组的常用属性和方法数组本身提供了一些实用的属性和方法我整理了一个速查表成员类型说明示例Length属性获取所有维度元素总数arr.LengthRank属性获取维度数arr.RankGetLength(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); // 输出 123 * 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#提供了ListT内部封装了动态扩容的逻辑用起来方便得多。5.2 数组 vs List vs 其他集合的选型对比特性数组 T[]ListDictionaryK,V长度固定动态动态访问方式索引索引键访问速度最快快较快哈希插入/删除不支持支持中间插入慢支持内存占用最小略大较大适用场景数据量固定、性能敏感需要动态增减需要键值对查找我的选型原则很简单数据量在运行过程中不变或者变化很少用数组需要频繁增删元素用ListT需要通过一个键快速找到对应的值用DictionaryK,V在Unity中Update里每帧遍历的数据如果数量固定优先用数组因为数组的遍历效率最高而且不会产生GC。如果数量会变化用ListT但要注意在Update里遍历ListT时用for而不是foreach避免GC开销。5.3 从数组到List的平滑过渡如果你已经用数组写了一部分代码后来发现需要动态扩容迁移到ListT其实很简单// 数组转List int[] array { 1, 2, 3, 4, 5 }; Listint list new Listint(array); // List转数组 int[] newArray list.ToArray();ListT内部其实也是用数组实现的只是帮你封装了扩容逻辑。当你new Listint()的时候它默认创建一个容量为4的数组。当你添加第5个元素时它会创建一个容量翻倍的新数组容量变成8把旧数据拷过去然后再添加新元素。这个扩容过程有一定的性能开销所以如果你大概知道最终会有多少个元素可以在创建ListT时指定初始容量// 预估有100个元素直接指定容量避免多次扩容 ListItem inventory new ListItem(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 LengthNullReferenceException引用类型数组元素未赋值使用前判空或先初始化修改副本影响原数组浅拷贝导致引用共享需要深拷贝时手动创建新对象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。需要变长数组就用ListT或者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项目里数组最适合用在那些数据量固定、访问频繁、性能敏感的场景比如每帧要遍历的路径点、固定数量的技能槽位、格子地图数据等。而一旦涉及到动态增删就应该果断换成ListT不要为了省一点内存而把代码写得又臭又长。另外关于数组的性能优化有一个点值得注意数组的缓存友好性。因为数组在内存中是连续的CPU缓存命中率高所以遍历数组通常比遍历链表快得多。这也是为什么Unity的ECS架构、Job System都大量使用数组或者NativeArray的原因。如果你在做性能优化把数据从ListT转成数组来遍历有时候能带来意想不到的提升。最后说一个我经常用的技巧当你需要返回多个值的时候与其定义一个out参数或者创建一个临时类不如直接返回一个数组。比如计算某个物体的包围盒范围返回float[] { minX, maxX, minY, maxY }简单直接。当然如果这些值有明确的语义还是定义一个结构体更清晰。数组是工具用对了地方才能发挥价值。
阅读完成 · 觉得有帮助?
咨询建站