Unity数组性能详解:连续内存、GC与场景选型实战 1. 数组在Unity里的快和省到底从哪来很多刚接触Unity的开发者第一个接触的数据结构就是数组Array。Tutorial里教你把一堆敌人放进数组里遍历把地图上的路点存成数组把拾取物做成数组。但大多数人对数组的理解停留在一种能存多个东西的容器这个层面至于它为什么快、为什么省内存、为什么有些场合必须用它、有些场合用它反而是坑很多人并没有真正吃透。我用Unity做了几年项目从最早的小游戏到后来的中度项目绕了一圈之后最大的感受是数组不是简单到没什么好讲而是简单到容易被低估。它的很多特性比如连续内存、固定长度、O(1)索引访问直接决定了你在Unity里怎么组织数据、怎么写Update循环、怎么做性能优化。这篇文章不打算从头科普数组的语法——那个随便找本C#入门书都有我重点聊的是数组在Unity这套引擎体系下的实际行为、典型应用场景和那些看不见的性能代价全是项目里总结出来的经验。1.1 连续内存意味着什么数组在内存里是一块连续的空间。这句话几乎所有教材都会讲但很少有人把它和Unity的游戏循环联系起来。连续内存带来的第一个直接好处是缓存友好。CPU在读取数据的时候不是按字节一个一个地读而是按缓存行Cache Line为单位批量加载一次通常拉64字节进缓存。如果你有一个int数组那么CPU第一次读取数组头部的元素时实际上会把后续十几个int一并塞进缓存。接下来你顺序遍历这个数组绝大部分访问都直接命中缓存不需要再回主内存取数据。游戏逻辑里最常见的操作就是逐帧遍历一批对象的状态并更新这种顺序访问模式让数组在性能上天然占优。第二点连续内存让分配和释放的代价可控。数组对象本身是一个引用类型存到托管堆上但它的元素数据是内联在这块堆内存里的也就是说你new一个长度为100的int数组分配的是1个对象头加100个int的空间而不是101个独立小对象。这对GC垃圾回收压力来说是好事——遍历和访问数组元素不会产生额外的堆分配也就不会产生额外的GC.Alloc。第三点数组的元素地址可以精确计算。arr[i]的地址 数组起始地址 i * 元素大小。这就是数组索引访问能做到O(1)的底层原因——它不需要像链表那样一个一个找过去而是直接用偏移量计算内存地址。这也是为什么数组支持随机访问而链表不行的根本区别。我用一个直观的例子说明连续内存的威力。假设场景里有5000个粒子每个粒子需要每帧更新位置。如果它们存在数组中迭代这5000个元素可能只需要几十微秒如果拆成5000个独立的类对象用List 存储光是指针追踪和分散内存访问带来的开销就能把这个时间翻几倍。在移动平台上这种差距会直接反映在帧率上。1.2 固定长度是限制也是保障初学者最容易抱怨数组的一点是长度固定不能像List那样随便Add。但如果反过来想固定长度在很多游戏场景里恰恰是优点。比如一个关卡预设了5个出生点一个敌人身上挂了4个骨骼挂点一个技能最多命中10个目标——这类数量在设计阶段就定死的数据用数组存储最简单直接。长度固定意味着你不必担心动态扩容带来的内存复制和GC压力也意味着你可以在初始化阶段就把所有数据准备好运行期间零分配。固定长度还有一个隐藏优势——它的长度信息是类型的一部分。C#里int[]和int[10]在运行时是同一类型但数组实例的Length属性是确定的编译器可以进行一些边界优化。Unity的Il2CPP后端在AOT编译时对数组边界检查的处理也比较成熟遍历固定长度数组生成的机器码通常很干净。当然固定长度的另一面就是越界访问的风险。C#的数组是带边界检查的访问超出Length的索引会抛出IndexOutOfRangeException。这个异常在编辑器下很好抓但在真机上如果被吞了排查起来就痛苦。我的习惯是凡是从配置表、Inspector面板或网络数据里拿到索引去访问数组一律先做长度校验。别嫌啰嗦线上最恶性的Bug往往是数组越界但没崩溃只是读到了脏数据。提示数组的Length是O(1)获取的它直接存在数组对象的头部字段里不需要像某些语言一样遍历计数。放心在频繁调用的逻辑里使用arr.Length不会产生额外开销。2. Unity开发中数组真正高频的用武之地先声明一下我这里讲的数组主要指C#的一维数组因为这是Unity脚本里最常见的形态。二维数组、交错数组、NativeArray这类特殊形态后面单独说。下面这几个场景是我在项目里反复遇到、最适合用数组的地方每个都会结合Unity的实际API和业务逻辑来展开。2.1 批量获取场景对象的标准姿势Unity里有一类经典API返回结果就是数组——比如Object.FindObjectsOfTypeT()、Object.FindObjectOfTypeT()新版已经改成返回单个对象、GetComponentsInChildrenT()、GetComponentsT()。这些API之所以设计成返回数组而不是List就是因为它一次性查完所有匹配对象结果集大小在一个时间点上是确定的用数组承载最合适。举个实际例子一个俯视角射击游戏里玩家每次释放全屏AOE技能需要找到场上所有的敌人并结算伤害。代码大概是这样的// 一次性获取所有敌人注意性能避免在Update里每帧调用 private Enemy[] allEnemies; public void OnAOESkillTriggered(Vector3 center, float radius) { allEnemies FindObjectsOfTypeEnemy(); for (int i 0; i allEnemies.Length; i) { float dist Vector3.Distance(center, allEnemies[i].transform.position); if (dist radius) { allEnemies[i].TakeDamage(100); } } }这里用数组的另外一个原因是便于做批量修改和状态同步。比如战斗结束清零所有敌人的仇恨值、切换所有陷阱的激活状态一次数组遍历全部搞定。而如果在遍历过程中还要往集合里增删元素就另当别论了——后面会提到为什么那种情况要用List或者临时拷贝。2.2 固定数量数据的天然容器Unity项目里大量数据在结构上就是定长的路径点序列巡逻路线、赛道检查点、镜头动画路径点的数量在做地图时就已经确定。动画关键帧一个Clip里的关键帧数组或者一组序列帧的Sprite引用。皮肤/材质变体角色的多套材质球通常不会在运行时动态增减。波次配置怪物的生成波次表每一波出什么怪、出几只策划配置时数量就锁定了。技能连击段一段连招最多有N段数组下标正好对应连击段位。这种场景下用数组代码表达最清晰按下标访问第几个元素下标本身就是业务语义的一部分。比如连击段数数组索引0是第一段、1是第二段比List更直观。你要是用Dictionaryint, AttackData反而绕远了。还有一类特别容易忽略的对象池的闲置列表。很多人一提到对象池就上List但如果你能预估池子的上限比如同屏最多100颗子弹直接预分配一个固定大小的数组配合一个当前使用数量的计数器性能会比List好很多而且完全没有扩容GC。private Bullet[] bulletPool new Bullet[100]; private int activeCount 0; public Bullet GetBullet() { if (activeCount bulletPool.Length) { // 池已满可扩容、拒绝或覆盖最旧的看业务取舍 return null; } Bullet b bulletPool[activeCount]; activeCount; return b; }这种用数组手写对象池的方式比ListBullet的Remove和Add操作少了很多元素搬移的开销。我在弹幕游戏里实测过敌人子弹上限在500颗左右时数组池方案能明显压低GC峰值。2.3 序列化与Inspector面板数组在编辑器里的天然优势Unity的[SerializeField]和[System.Serializable]可以直接暴露数组到Inspector面板而且支持在编辑器里拖拽对象引用、调整长度、展开查看每一项。这个能力对策划和美术极其友好——他们不用碰代码就能在面板上配好一波波敌人的阵型、一组对话文本、一列掉落表。// 在Inspector里显示为一个可拖拽、可排序的数组 [SerializeField] private Transform[] patrolPoints; [SerializeField] private GameObject[] enemyPrefabs; [SerializeField] private string[] dialogLines;这里有个很实用的点数组本身可以被SerializeField序列化但多态类型不行。也就是说你要是存一个Component[]或ScriptableObject[]Unity能序列化数组里的引用但要存一组不同子类的对象实例数组就无能为力了那需要ShowIf或者自定义编辑器方案绕路。这是数组做配置数据时的一个边界提前知道能省很多调Bug的时间。数组在序列化上的一个优势是顺序稳定。Inspector里第3个元素序列化数据里第3个位置运行时读出来还是第3个。Dictionary在Inspector里的序列化表现就远不如数组直观因为哈希表的顺序本来就不是确定的这也是为什么很多配置型数据结构选择数组而不是字典。3. 数组在性能上的几个真实陷阱数组性能好但用错姿势的数组也会成为卡顿源头。这一节专门讲数组的坑很多是我在Profiler里一格一格看出来的排查过程都很有代表性。3.1 foreach数组的隐藏开销从栈到堆的逃逸如果你写的代码如下int[] numbers new int[100]; foreach (int num in numbers) { // 处理 }C#中foreach遍历数组时编译器会做特殊优化生成的代码和for循环非常接近不会分配Enumerator对象。这是数组相对List的一个优势——ListT的foreach不是免费的它的Enumerator是一个结构体虽然本身不堆分配但如果有方法调用导致逃逸分析失败可能触发装箱。但有一个很多人会踩的坑是foreach捕获和闭包Action[] actions new Action[10]; for (int i 0; i actions.Length; i) { int index i; actions[i] () Debug.Log(index); }这段代码里的闭包捕获了循环变量会为每个Action分配一个闭包对象产生10次堆分配。这个不算数组的错但数组和闭包结合起来的时候特别容易写出这种代码。实测在Update里这么写GC Alloc蹭蹭涨Profiler里的Managed Allocations一眼就能看到。还有一点用foreach遍历数组时如果数组是值类型元素的数组foreach的迭代变量是元素的副本。也就是说foreach里改item不会改到原数组。很多人写foreach (var item in structArray) { item.x 10; }然后发现数组没变一脸懵。如果你要批量修改值类型数组里的元素必须用for循环加下标访问。3.2 频繁Resize和复制数组最容易被忽略的成本数组长度固定所以扩容只能靠new一个更大数组然后把旧数据拷过去。这个操作的时间复杂度是O(n)而且会产生一次旧数组的内存垃圾。Unity开发里最常见的问题场景是这样的策划配置了一个可变数量的怪物波次你用数组存波次数据但每次开新关卡时波次数量都不一样于是你的代码反复Array.Resize。看起来没什么可万一这个Resize发生在初始化流程里、又叠加在高频循环里GC压力和内存碎片就会被放大。Array.Resize在内部其实也是创建新数组然后Array.Copy只是帮你省了手写复制代码。它的存在很容易给人一种数组也可以随便加长的错觉。我的经验是如果你需要经常动态增删就直接用List如果数量有明确上限一次性初始化一个容量足够的数组如果数量小且变化不频繁偶尔Resize问题不大但要留意频率。再补充一个Unity场景AddComponent的返回值、Transform.GetChild(i)、MaterialPropertyBlock的向量数组这些API底层可能都要复制数组。在Update里频繁调用这些API即使每次只复制一个十几元素的数组累加起来也很可观。这种情况下最好用缓存数组把结果存在成员变量里而不是每次调用都现取。3.3 值类型数组与引用类型数组的内存差异C#数组元素可以是值类型int、float、struct也可以是引用类型class、string。这两种数组的存储方式完全不同直接影响内存布局和性能。值类型数组比如Vector3[]元素是连续内联的访问快、缓存友好。引用类型数组比如Transform[]数组里存的其实是引用可以理解为指针实际对象分散在托管堆各处。所以就算数组本身连续你通过引用再去找对象时仍然可能发生缓存未命中。Unity里一个常见优化是把Transform[]改成TransformAccessArrayUnityEngine.Jobs或者用NativeArrayTransformAccess背后逻辑就是把分散的对象访问改成更适合批量处理的模式。不过这个属于Job System的范畴了一般项目用不到。这里只提醒一点别以为用数组存了一堆GameObject就自动获得了连续内存优化。你只是省了集合本身的开销对象缓存友好性没法靠数组解决。3.4 多维数组和交错数组两个容易混淆的东西C#里int[,]是真正的二维数组多维数组内存上是连续的一块int[][]是交错数组数组的数组每行是独立数组对象。这两个在Unity里的表现差异很大。多维数组int[,]的特点是访问语法统一但GetLength(0)这类调用和边界检查的开销比一维数组要高而且CLR对多维数组的JIT优化不如一维数组。我在Unity里实测过一个二维数组的遍历在十万级数据量以下其实差距不明显但如果做的是图像处理、网格数据处理这种百万级循环多维数组的一维遍历性能可能只剩一维数组的60%~70%。交错数组int[][]每一行独立可以做每行长度不同的数据但代价是有额外的引用跳转缓存友好性比不上一维数组。而且它每一个子数组都是独立对象GC压力也更大。我的建议是除非业务需求迫不得已尽量用一维数组模拟多维。比如一个8x8的棋盘数据用int[64]访问grid[y * 8 x]性能最好序列化也最简单。然后是NativeArrayT。这是Unity ECS和Job System里的原生容器它分配在非托管内存上不受GC管理可以在Job间共享。NativeArrayT的连续性和数组一样但它是值类型使用前要Alloc、用完后要Dispose还要注意NativeLeakDetector。它的存在是为了解决托管数组不能安全传给Job线程的痛点。用不到Job System的项目可以先不深究但要记住如果把NativeArrayT当普通数组用又忘了Dispose编辑器下就会报内存泄漏警告这条帮我抓过团队里不少次代码Review的问题。4. 数组、List还是NativeArrayUnity里的选型方法论这一节是纯实战经验。很多人问我什么时候用数组什么时候用List我直接说判断标准数据量大小、是否需要增删、是否需要跨线程、是否长期持有。四个条件排列组合结论就很清晰了。4.1 List的动态扩容机制和它的代价ListT内部其实就是用数组实现的。它在内部维护一个T[] _items当元素数量超过容量时会new一个容量翻倍的新数组把旧数据全部拷贝过去。所以List在某些方面的性能和数组非常接近——索引访问同样是O(1)迭代时也走数组的迭代器。但List有几点和数组不同扩容是不可预知的分配。如果你事先不知道最终有多少元素List可能多次扩容每次都是一次数组复制加一次堆分配。减少扩容的办法是在构造时预估容量new ListT(capacity)。RemoveAt会搬移元素。删除中间元素时后面的元素都要往前挪一位时间复杂度O(n)。如果在循环里频繁RemoveAt性能很容易恶化。foreach分配。虽然List的Enumerator是struct不会每次foreach都堆分配但如果它被传到非泛型接口或者被yield返回就可能装箱。此外在启用了.NET 2.0 API兼容级别的Unity版本里List 的某些操作相比数组多了间接调用开销多一层虚调用/接口调用。List的价值在于它的可变长度。需要动态增删的场景比如角色背包、玩家击杀记录、动态生成的敌人列表List是正解。在这些场景硬用数组反而会写出一堆手动的扩容代码且容易出错。4.2 什么时候别用数组也别用List还有一种情况是数组和List都不合适——需要频繁按Key查找。比如通过玩家ID查找玩家对象、通过物品ID查物品配置。这种场景应该用DictionaryTKey, TValue哈希表查找是O(1)而数组和List的线性查找是O(n)。我看过很多新手项目用一个数组存所有玩家然后遍历找ID玩家一多就卡用List也一样卡。这就是用错了数据结构的经典案例。判断依据很简单如果你的操作是查一下某个存不存在或者取一个具体对象优先考虑Dictionary如果你的操作是遍历全部并排序/筛选/批量处理数组和List更合适。4.3 我的选型模板我在Unity项目里总结了一个非常粗粒度但有效率的选型模板分享出来供你参考场景推荐结构原因数量定死的配置数据数组序列化友好、无额外开销同屏大量对象的批量遍历数组或缓存数组连续内存、缓存友好需要动态增删的运行时集合List灵活、后顾无忧需要按ID/Key查找DictionaryO(1)查找跨Job处理的批量数据NativeArray非托管内存、线程安全共享频率极高的小批量数据数组缓存避免GC Alloc需要排序频繁变化的数据List Sort排序逻辑成熟这个模板不是死规矩但它覆盖了Unity日常开发90%以上的选择场景。你可以把它贴在手边写代码前对照一下。4.4 从效率直觉到Profiler证据最后想说一点数据结构选型这件事最好的判断依据永远是你的Profiler数据而不是网上的经验帖。我见过有些人看到数组性能好就什么都用数组结果代码里到处是Array.Resize和数组越界处理维护成本极高也见过有人什么都是List结果在移动端上因为GC压力被打回原型。正确姿势是先默认用清晰、可维护的写法通常就是List跑起来后打开Profiler看Managed Allocations和CPU耗时发现瓶颈以后再针对性替换成数组、缓存复用或者NativeArray。先正确再高效。我在项目里的习惯是只在明确检测到GC问题或者热点循环里才把数据结构从List换成数组并写上注释说明为什么这么改。这样团队其他人不会回头帮你改回去。5. 用数组时绕不开的边界与调试实操数组是很基础的东西但基础不代表不会出问题。我在项目里遇到过好几个和数组相关的疑难杂症排查过程挺有代表性挑几个典型的讲讲。5.1 数组越界不崩溃的假安全C#的数组访问是带边界检查的越界会抛异常。但有一种情况是——数组本身合法下标计算逻辑错了访问的位置恰好落在另一个合法数组的范围内或者访问了当前数组的某个逻辑上越界但物理上存在的位置。比如这张地图的数据是一个二维数组拉平的private int[] tileMap new int[width * height]; public int GetTile(int x, int y) { if (x 0 || x width || y 0 || y height) { return -1; // 越界保护 } return tileMap[y * width x]; }如果不加x和y的边界检查GetTile(-1, 0)会访问tileMap[-width]这在C#里直接抛异常还算安全但如果y * width x因为整型溢出算出负值就可能访问到数组头部之外的内存——这个在托管环境下一般还是会被CLR拦截但如果是某些更底层的批量操作结果就不好说了。我的建议是所有来自外部输入的下标一律先做范围校验。写一个Helper函数或者扩展方法把越界保护收拢到一处public static bool TryGetValueT(this T[] array, int index, out T value) { if (index 0 index array.Length) { value array[index]; return true; } value default; return false; }这样调用方代码一目了然也不会漏判。5.2 Inspector里数组引用为空Serialization的坑另一个高频问题是你在Inspector里拖了一堆引用进数组运行时某些元素却是null。原因通常有三种场景没保存拖拽引用后在编辑器里运行引用只在场景文件保存后才会持久化。对象被删了但数组引用没更新比如删除了一个被引用的Prefab或场景对象数组里留下了一个丢失的空引用Missing。序列化类型不匹配数组声明为GameObject[]但把Scene里的Prefab拖进去运行时引用的是场景实例还好如果声明为某个自定义类型又没加[System.Serializable]数组可能直接是空的。排查这类问题我通常先看Inspector面板检查数组长度和每一项的状态然后在OnValidate里打印一下数组信息private void OnValidate() { for (int i 0; i patrolPoints.Length; i) { if (patrolPoints[i] null) { Debug.LogWarning($巡逻点数组第{i}项为空请检查引用, this); } } }OnValidate是编辑器下每次Inspector变动都会调用的回调能帮你在进入Play模式前就发现空引用。这条经验处理团队配置问题时很管用。5.3 数组作为方法参数时要注意的引用传递C#数组是引用类型所以一个方法里改了数组元素调用方看到的是同一个数组。这个特性用得好是利器用不好是陷阱。我在项目里遇到过一次很隐蔽的BugA系统把Transform[]传给B系统做临时排序B系统排序后把数组返回A系统拿着这个数组发现顺序变了。原因就是B系统直接对传进来的数组做了Array.Sort改了原数组。这不算Bug但绝对是没约定清楚引发的血案。解决方式有两种要么方法文档里注明此方法会修改传入数组要么在方法内部(T[])array.Clone()拷贝一份再操作。从性能角度拷贝数组有成本一般只在数据量小或者确实需要保护原数据时才做但为了代码的安全性和可预期性我建议默认做拷贝除非你能确认调用方不介意原数组被改。public T[] SortAndReturnCopyT(T[] source, ComparisonT comparison) { T[] copy new T[source.Length]; Array.Copy(source, copy, source.Length); Array.Sort(copy, comparison); return copy; }5.4 遍历数组时删除元素一个经典的逻辑坑这个坑几乎所有写代码的人都踩过。你有一个数组想遍历它把满足条件的元素删掉但数组长度是固定的删除意味着要么改数组内容、要么创建新数组、要么用特殊值标记。比较常见的错误做法是for (int i 0; i enemies.Length; i) { if (enemies[i].hp 0) { enemies[i] null; // 只把引用清了数组还在 } }然后后续遍历时到处都要判空代码越来越乱。正确的做法要么是维护一个存活标记数组要么遍历时把存活元素compact到数组前部并记录新长度要么干脆这种场景用List并倒序遍历RemoveAt。// 倒序遍历List安全删除 for (int i enemyList.Count - 1; i 0; i--) { if (enemyList[i].hp 0) { enemyList.RemoveAt(i); } }数组的删除本质上是覆盖不要让数组和动态删除硬拧着来。我的原则是定长数据用数组变长数据用List不要用数组硬写变长逻辑。6. 一个完整的数组优化案例从Profiler到改造落地最后用我一个实际项目的优化过程来收尾。这个项目是一个2D弹幕小游戏敌机每帧要更新朝向、位置、发射子弹同屏单位大概在300左右。最初的实现是ListEnemy每帧遍历更新。打开Profiler后看到两个问题一是Update里ListEnemy的迭代虽然不算慢但有一处逻辑频繁调用enemies.RemoveAt导致元素搬移这部分占了大概15%的CPU二是每帧遍历时因为会生成新的弹幕对象GC.Alloc一直在涨平均每帧有2~3KB的托管分配。6.1 优化方案我把ListEnemy换成了固定容量的数组配合当前存活数字段管理private Enemy[] enemies new Enemy[512]; private int enemyCount 0; public void SpawnEnemy(Enemy enemy) { if (enemyCount enemies.Length) { return; // 达到上限不再生成 } enemies[enemyCount] enemy; enemyCount; } public void UpdateEnemies(float deltaTime) { for (int i 0; i enemyCount; i) { enemies[i].Tick(deltaTime); if (enemies[i].IsDead) { // 用最后一个元素覆盖当前元素避免搬移 enemies[i] enemies[enemyCount - 1]; enemyCount--; i--; // 继续检查交换过来的新元素 } } }这里用了一个典型的swap-remove技巧删除中间元素时把最后一个元素挪过来覆盖被删位置然后把计数减一这样删除操作从O(n)变成了O(1)。这个模式在数组做动态池时非常常用前提是元素顺序不重要。如果顺序有要求比如表现层按数组顺序画轨道就要换思路或者容忍O(n)的搬移成本。6.2 优化结果改造后Update里的CPU占用下降了大概12个百分点GC.Alloc几乎降到0因为数组本身在这个逻辑里不再有增删分配。这个改造也有代价——敌人数量上限被写死在512不能再动态加了。我在代码里注释得很清楚并加了编辑器下的警告如果策划把某关刷怪量配到超过512直接报警提示。这个例子想说明的是数组优化的本质是用固定上限换零GC和高性能。这个换不换、上限设多少取决于你的业务有没有明确的峰值。如果一款游戏同时在线单位数可能从0到10000波动那就别硬用固定数组了要么做成可扩容的池子要么老老实实用List但做好容量预估。6.3 这个方案什么时候失效有人可能会觉得这个方案很完美但我要泼一盆冷水它不是万能解。如果你的敌人类型很复杂每个Enemy对象本身分散在堆里数组只帮你优化了容器层对象访问还是可能缓存不友好。而且一旦数据量超过数组容量的上限你只能拒绝生成或者等待旧对象回收这个硬拒绝行为在玩家体验上可能是个灾难。所以我现在的做法是先用最简单的数据结构做出可玩版本Profiler确认瓶颈后再用数组或NativeArray做针对性优化。优化时保留一层封装让上层逻辑不感知底层结构的变化。这样既享受了数组的性能优势又不至于被固定上限绑死。7. 留点实操经验做结尾数组这个知识点看起来基础但越往深挖越发现它和Unity的运行时模型绑得特别紧。我最后再分享几条自己实操中的经验不一定全面但都是踩过坑换来的。第一条用数组前先问自己三个问题这个集合的最大长度是多少会不会动态增删是否长期持有如果最大长度确定、不变动或极偶尔变动、且长期持有数组基本是正解。第二条数组不等于无GC。虽然数组本身不产生遍历GC但如果你在循环里频繁new临时数组比如每帧new一个Vector3[]来算偏移GC压力照样会爆。数组要配缓存复用才够极致。第三条Unity的API返回值是数组的别乱改。比如GetComponentsInChildrenT()返回的数组Unity文档明确说这个数组是缓存复用的修改它的内容可能影响下次调用也可能引发诡异问题。如果你需要对这个数组做排序先拷贝一份再动。第四条Editor脚本里尽量用数组。Inspector面板对数组的支持最好自定义Editor代码里遍历数组也最直接。虽然ListT也能在Inspector显示但数组在序列化时更可控、更容易写自定义属性绘制器。最后聊一聊数组的学习价值。老实说数组是数据结构里最基础的一环但恰恰是这种基础决定了你对内存、对性能、对Unity运行时模型的理解深度。我见过不少开发者写了好几年游戏数据结构的基础概念都熟但真到了优化关键帧的时候还是会因为没有吃透数组的内存模型而走弯路。希望这篇文章能帮你把这块地基夯实。以后有时间再写一篇文章把链表、栈、队列、哈希表这些数据结构在Unity里的实际应用也逐个拆一遍。