
写过一段时间Unity的人应该都有过这种经历遍历背包里的道具要写foreach处理敌人列表要写for循环想给某个集合换个遍历方式还得改业务代码。这些看似不起眼的细节其实都牵扯到迭代器模式。作为GoF 23种设计模式里最容易被人忽视、却又在C#和Unity里无处不在的一个迭代器模式真正的价值不仅是让你少写几行循环而是把遍历这件事从业务代码里彻底剥离开让你能随意更换遍历策略而不影响调用方。这篇文章我从实际项目出发把迭代器模式的原理、常见误区和Unity里的落地方式一次讲透。1. 从需求说起Unity开发里为什么需要迭代器模式1.1 迭代器模式在23种设计模式中的定位23种设计模式里迭代器模式Iterator Pattern属于行为型模式它要解决的核心问题是如何在不暴露集合内部表示的前提下让外部代码按顺序访问集合中的元素。这句话听起来有点绕我换个说法——假设你有一个背包系统背包内部是用数组存道具还是用List存道具是用字典按ID索引还是用链表按照时间排序这些细节调用方根本不需要关心调用方只想要一件事把所有道具一个一个拿出来处理。迭代器模式就是在调用方和集合内部结构之间加了一层遍历规约。很多初学者以为迭代器模式就是foreach其实foreach只是一个语法糖真正干活的是背后的迭代器对象。C#的foreach编译后实际上是调用GetEnumerator()拿到一个IEnumerator然后反复调用MoveNext()和Current。所以你在Unity里写的每一段foreach本质上都在使用迭代器模式只是C#帮你把细节隐藏了。1.2 游戏开发中典型的遍历痛点游戏开发里的遍历场景比普通后端开发多得多而且更强调性能和安全。举几个我实际遇到的例子敌人AI需要每帧检查活着的敌人列表但敌人可能在战斗中被销毁UI系统需要遍历所有激活的面板做统一刷新技能系统需要遍历Buff列表并逐个结算。这些场景有几个共同痛点第一集合内部结构经常变化。今天用数组明天项目优化改成池化对象如果业务代码直接操作数组下标或者List索引改一次内部结构就要改一片调用点。第二遍历过程中集合会被修改。foreach里Remove一个元素直接抛InvalidOperationException这个问题在敌人死亡、道具拾取这类高频场景里尤其致命。第三不同的遍历需求。同一个敌人列表有时候要按距离排序遍历有时候要按血量从低到高遍历有时候只想要活着的敌人。如果把排序和过滤逻辑都写在业务方代码会迅速腐化。迭代器模式恰好能解决这三个痛点。它把集合的存储结构和遍历行为解耦让你可以给同一个集合挂上多个迭代器每个迭代器有自己的遍历规则调用方只依赖迭代器接口。这种面向接口遍历的思路在Unity这种组件化极强的环境下特别适用。2. 核心细节解析Unity/C#里迭代器的实现机制2.1 迭代器模式的四个核心角色迭代器模式有四个核心角色Iterator迭代器接口、ConcreteIterator具体迭代器、Aggregate聚合对象接口和ConcreteAggregate具体聚合对象。在C#里对应关系非常清晰模式角色C#接口/类职责迭代器接口IEnumerator定义MoveNext()、Reset()和Current具体迭代器各类Enumerator实现实现具体的遍历算法聚合对象接口IEnumerable定义GetEnumerator()方法具体聚合对象List、Array、自定义集合类返回对应的迭代器这里有个容易混淆的点IEnumerator和IEnumerable是两个不同的接口。IEnumerable表示这个集合可以被遍历对应聚合对象的抽象IEnumerator才是真正的迭代器负责记录当前遍历位置并移动到下一个元素。很多Unity新手搞不清这两个接口面试时被问到foreach到底是怎么工作的就卡住了。一个标准的foreach执行流程是这样的调用集合的GetEnumerator()拿到IEnumerator然后循环调用MoveNext()判断是否还有下一个元素如果有就通过Current属性取当前元素。循环结束后如果迭代器实现了IDisposableIEnumerator确实继承了IDisposableforeach还会自动调用Dispose()释放资源。这个流程把所有遍历逻辑都封装在迭代器内部集合本身完全不需要暴露任何内部结构。2.2 Unity开发中最重要的实现方式yield returnC#里实现迭代器有两种方式手动实现IEnumerator接口或者用yield return关键字让编译器帮你生成迭代器。手动实现的方式大家能理解就是写一个类实现MoveNext、Current、Reset和Dispose但代码量很大而且容易出错。实际开发中我几乎都用yield return它写法简洁编译器会在背后生成一个状态机把迭代器的所有状态全部托管。这里要特别说明yield return的原理因为它跟Unity协程的关系太紧密了。C#的yield return并不是简单的返回一个值然后继续执行编译器会把包含yield的方法转换成一个状态机类方法里的局部变量和当前执行位置都被保存到状态机里。每次调用MoveNext()状态机从上次中断的地方继续执行直到遇到下一个yield return。用通俗的话说yield return让一个方法变成了可暂停和恢复的遍历器。在Unity里yield break是另一个常用关键字它直接结束迭代。如果你写得不好yield break和return的混用会导致迭代提前结束或者继续执行这个后面我会放到常见问题里细说。2.3 用生活化类比理解迭代器状态机状态机这个概念对非科班开发者来说有点抽象。我习惯用停车场闸机来类比一辆车MoveNext的调用方开到闸机前闸机状态机判断是否放行放行后车进入停车场Current等这辆车离开后闸机又回到待命状态准备放行下一辆。每次MoveNext就像一辆车到达闸机Current就像放行后停在车位上的那辆车状态机通过保存当前车停在哪来记住遍历到了哪里。还有一种类比是翻书。集合就是一本厚书迭代器就像一个书签。你每翻一页MoveNext书签就移到新一页的位置状态更新你看到的这一页内容就是Current。书签本身不关心书里有多少页也不关心书的装订方式它只知道怎么一页一页翻。这个类比能解释为什么迭代器能把遍历和集合内部结构解耦——因为翻书的方式跟书怎么印刷完全无关。3. 实操过程在Unity里落地迭代器模式3.1 场景一背包系统的安全遍历先从一个最常见的场景入手背包系统。假设你需要遍历背包所有道具把品质达到紫色的装备数量统计出来。最直接的做法是拿List 然后foreach看似没问题但一旦遇到遍历过程中移除道具的需求就麻烦了。我在项目里遇到过这样一个需求背包满了以后玩家拾取新道具时系统要自动把品质最低的白色道具卖掉腾出格子。一开始用foreach遍历背包并Remove低品质道具跑了不到十次就崩了——foreach循环体内修改集合直接抛InvalidOperationException。后来我做了两版优化第一版是把要删除的道具先收集到另一个List再统一删虽然能解决崩溃但代码很丑。第二版就是自己写一个迭代器在迭代器内部维护一个安全移除索引表让Remove操作和枚举操作互相协调。// 自定义背包集合支持遍历时安全移除 public class Backpack : IEnumerableItem { private ListItem _items new ListItem(); public void Add(Item item) _items.Add(item); // 通过迭代器暴露遍历能力不暴露内部List public IEnumeratorItem GetEnumerator() new BackpackIterator(_items); IEnumerator IEnumerable.GetEnumerator() GetEnumerator(); // 迭代器内部维护索引快照遍历过程中删除元素不崩溃 private class BackpackIterator : IEnumeratorItem { private ListItem _source; private int _index -1; private Item _current; public BackpackIterator(ListItem source) _source source; public bool MoveNext() { while (_index 1 _source.Count) { _index; if (_source[_index] null) continue; // 跳过空槽 _current _source[_index]; return true; } return false; } public Item Current _current; object IEnumerator.Current Current; public void Reset() _index -1; public void Dispose() { } } }这段代码的关键在于外部调用方拿到的永远只是IEnumerator接口背包内部用List还是数组存储完全不影响遍历代码。如果你后续想优化背包把List换成数组调用方一行都不用改。这就是迭代器模式在Unity里最实在的价值——集合结构演化不影响遍历逻辑。当然实际项目中我不会每次都手写迭代器上面例子更多是教学演示。真正项目里如果只是遍历时安全删除我会直接用反向for循环或者收集待删除元素列表。迭代器模式真正发光发热的场景是当你需要把遍历算法本身抽象出来、供多个业务模块复用时。比如一个通用的只遍历活着的敌人迭代器可以同时被伤害计算、UI统计和技能系统使用这就比每个系统各写一份过滤逻辑好维护得多。3.2 场景二用迭代器隐藏复杂数据结构Unity开发里复杂数据结构非常多常见的有二叉树UI层级、图结构技能树、嵌套列表关卡配置等。直接把这些结构暴露给外部代码不仅会让调用方代码写得像屎山还容易在反复的层级遍历中出错。迭代器模式可以让你把结构隐藏起来对外只提供顺序访问的接口。举个例子技能树系统通常要分前后端客户端展示技能树服务端算技能解锁。服务端技能树可能用嵌套字典存储客户端用一种平铺的List配合深度字段表示。如果不做任何抽象两个系统之间的数据转换代码会非常痛苦。我当时的做法是给技能树适配器实现IEnumerable 内部封装层级遍历逻辑外部foreach就能拿到所有技能节点并且顺序可控前序、后序、层序都可以通过不同迭代器提供。// 技能树平铺适配器对外隐藏内部树形结构提供顺序遍历 public class SkillTreeAdapter : IEnumerableTreeNode { private Dictionarystring, TreeNode _nodeMap; private string _rootId; public SkillTreeAdapter(Dictionarystring, TreeNode nodeMap, string rootId) { _nodeMap nodeMap; _rootId rootId; } public IEnumeratorTreeNode GetEnumerator() BfsOrder(_rootId); private IEnumeratorTreeNode BfsOrder(string startId) { Queuestring queue new Queuestring(); queue.Enqueue(startId); while (queue.Count 0) { string currentId queue.Dequeue(); if (!_nodeMap.TryGetValue(currentId, out TreeNode node)) yield break; yield return node; // 每访问一个节点就向外吐一个 foreach (string childId in node.ChildIds) { queue.Enqueue(childId); } } } IEnumerator IEnumerable.GetEnumerator() GetEnumerator(); }注意这里我用yield return配合Queue实现了广度优先遍历编译器自动生成了迭代器状态机我不需要手动维护当前遍历到哪了的状态。这就是yield return的威力——把复杂的遍历算法用顺序书写的方式表达编译器帮你保存中断现场。这个适配器写完之后客户端UI要遍历技能树直接foreach这个适配器就行树的结构完全被隔离了。3.3 场景三协程与迭代器的正确关系凡是Unity开发者几乎都用过协程。协程的本质就是IEnumerator这经常让人产生一个误解协程就是迭代器模式迭代器模式就是协程。这是不对的。协程在内存模型上依赖迭代器状态机但它的用途不是遍历集合而是挂起和恢复一个执行流程。你可以用协程做计时、做动画序列、做加载流程这些都不涉及集合遍历。迭代器模式和协程的正确关系是迭代器模式提供了一种可以暂停/恢复的遍历机制协程是这种机制在Unity中的典型应用之一。换句话说协程就是把一个方法变成一个可持续执行的迭代器其中每次yield return对应一次挂起点。但协程解决的问题是异步时序控制迭代器模式解决的问题是集合遍历解耦两者目的不同。这里有个非常实用的技巧你可以让一个协程内部调用一个自定义迭代器实现遍历延时的组合效果。比如遍历敌人列表时每遍历一个敌人就等待0.5秒再处理下一个听起来像是动画演出用协程加迭代器就能很优雅地实现。// 依次对每个敌人执行延迟攻击 private IEnumerator AttackEnemiesSequentially(ListEnemy enemies) { foreach (Enemy enemy in enemies) // 这里用到了迭代器 { enemy.TakeDamage(10); yield return new WaitForSeconds(0.5f); // 协程的挂起 } }我见过不少团队在写这种代码时直接在for循环里塞yield return循环和协程耦合得非常紧。其实更好的做法是隔离迭代器只负责怎么遍历敌人协程只负责遍历过程中做什么。如果你后面要改成每两个敌人攻击一次或者跳过死亡的敌人只需要改迭代器的遍历算法协程内容完全不用动。4. 常见问题与排查技巧实录4.1 遍历集合时修改集合导致InvalidOperationException这是迭代器模式里我问到的问题最多、也是实际项目中最容易踩的坑。无论是foreach List还是自定义迭代器只要在MoveNext执行期间增删了集合元素几乎必然抛出InvalidOperationException。原因是集合内部维护了一个版本号version每次增删元素都会让版本号自增迭代器持有进入遍历时的版本号MoveNext时发现版本号不匹配就立即抛异常。在Unity里尤其危险的是在Update或者协程里同时遍历和销毁敌人private void Update() { foreach (Enemy enemy in _activeEnemies) { if (enemy.Health 0) { _activeEnemies.Remove(enemy); // 抛异常 } } }解决方案有三种值得推荐第一反向遍历。用for循环从尾部往前遍历在安全删除后立刻break或者continue这种方式对List这种随机访问集合很实用但无法复用到所有集合类型。第二先收集再删除。遍历过程中把需要删除的元素加入一个_pendingRemoveList遍历结束后统一删除。这个方案在任何集合上都适用也是我推荐的最通用方案。第三自己实现支持延迟删除的迭代器。就像前面书包系统的例子那样在迭代器内部维护一个删除标记列表MoveNext时跳过标记为删除的元素。这种方式对调用方最友好但实现成本高适合对性能要求不高的低频操作。我实际选择的依据是如果是UI列表这种低频操作先收集再删除足以如果是战斗系统中高频更新逻辑我会单独设计一个延迟标记删除的容器让它本身就支持遍历时安全修改。4.2 yield return状态机的常见坑用yield return写迭代器有两个常见的坑一个是yield break没写导致方法提前返回另一个是局部变量捕获问题。先看yield break。很多人以为迭代器方法返回类型是IEnumerator里面用return结束方法就不会返回迭代器了。实际上在迭代器方法中return和yield return不能混用——编译器报告并非所有代码路径都会返回值或者不能在迭代器类型中使用return这类错误。如果遍历条件不满足你要提前结束迭代必须用yield break否则编译器会报错。public IEnumeratorItem GetTopQualityItems(int threshold) { foreach (Item item in _items) { if (item.Quality threshold) yield break; // 提前终止遍历 yield return item; } }另一个坑是局部变量捕获。yield return生成的迭代器状态机会把方法内所有局部变量提升为字段如果这些变量在循环中被多个yield return共享并且你在循环里改变它们可能会产生奇怪的执行结果。有个典型场景是for循环里用迭代器输出遍历索引由于状态机复用同一个局部变量槽可能所有输出都是同一个值。这类问题的排查思路通常是升级到最新的C#版本并确保每个迭代器都是新创建的。还有一个跟Unity引擎绑定的坑MonoBehaviour析构时挂在上面的协程会自动停止。协程内部用foreach迭代一个已经销毁的GameObject集合会在下次MoveNext时访问已销毁的对象。排查方法很简单遍历时对每个元素做null检查尤其是物体的gameObject null这种Unity特有的判空要特别谨慎。4.3 性能问题装箱、GC和结构体迭代器迭代器模式在Unity里有个绕不开的话题GC。C#的IEnumerator是接口foreach引用类型集合是没有额外装箱的但如果集合元素是值类型比如用List 或者自定义struct的Listforeach的过程中会把迭代器作为object来处理导致装箱和后续GC压力。我之前有段时间写粒子系统需要遍历一个粒子数组数组元素是含位置、速度的结构体用foreach直接遍历Profiler显示GC Alloc一直在涨。排查后发现问题是List .Enumerator虽然在C#里避免了装箱但只要你在foreach里对Current赋值或者把Current传给一个接口类型参数就会产生装箱。优化的思路有两个方向第一如果集合很小几百个元素以下用for循环直接遍历下标。性能最优代价是丢失迭代器模式的封装性。第二如果必须用迭代器模式并且集合是结构体列表就不要让Current暴露成值类型并且避免把它作为object传递。或者干脆把集合改成类存储牺牲一点内存换取接口调用的稳定性。// 避免结构体装箱的迭代方式直接用for 下标 for (int i 0; i _particles.Length; i) { Particle p _particles[i]; p.Update(Time.deltaTime); _particles[i] p; // 修改结构体需要重新写回 }这条优化路径需要特别注意Unity的数组其实是引用类型但数组元素是结构体时数组的foreach也会引入迭代器字段分配性能低一些。所以做大数组遍历且在意GC时for循环最稳。4.4 迭代器模式在Unity与C#中的工程实践误区最后总结一下我在Unity项目里见过的、属于迭代器模式工程实践层面的误区。误区一所有集合遍历都要套迭代器模式。其实不需要。迭代器模式的价值在于解耦和复用如果集合结构非常稳定调用方就一处foreach直接写循环反而更清晰。设计模式是为了解决重复问题不要为了用设计模式而用设计模式。误区二自定义迭代器一定要实现完整的IEnumerator接口。在C#里可以实现非泛型的IEnumerator也可以用yield return让编译器生成。出于可维护性我一般用yield return生成如果对性能有极强要求再手动实现。手动实现要特别注意Dispose和Reset的正确性Reset在C#里几乎不调用但接口要求实现别抛异常。误区三把迭代器和协程混为一谈。协程是Unity提供的一种时序控制机制迭代器是通用的遍历抽象。二者在C#语法层面是相通的都基于IEnumerator状态机但职责完全不同。设计代码时先想清楚你要解决的是遍历方式还是执行时序再用对应的模式。误区四忽略了单迭代器与多迭代器的差异。一个集合可以被多个迭代器同时遍历C#的List就是这样做的——多个foreach互不干扰。但如果你自己实现的容器类内部只有一个状态字段多个迭代器共享状态就会互相覆盖。实现自定义容器的时候每次GetEnumerator都要返回一个新迭代器实例而不是返回同一个缓存对象。误区五迭代器模式的排查思路没建立起来。迭代器模式一旦出问题往往不是逻辑错而是状态错。对这类问题最快的定位方式是打印MoveNext的调用次数和每次Current的值确认遍历顺序是否符合预期。还有一个小技巧迭代器会隐式实现IDisposable如果在foreach中途break编译器会调用迭代器的Dispose如果你在Dispose里做了多余地清理集合操作就可能影响后续遍历。遇到奇怪的问题先检查Dispose逻辑。5. 一些真正能帮到你的项目落地建议迭代器模式在Unity里的应用远不止安全遍历集合这么简单。我常用的组合拳是用接口类型存储集合而不是具体类型这样GetEnumerator的返回值就可以是自定义迭代器了。比如一个系统的配置数据我会声明为IReadOnlyList 内部可能是动态数组也可能是池化数组外部通过foreach访问时完全感知不到内部差异。另一个落地建议是需要在多个模块间共享遍历规则时写一个静态迭代器工具类把遍历算法集中管理。比如遍历场景中所有激活的音频源遍历当前活跃的UI面板这些遍历规则写成静态方法返回IEnumerable 业务代码一行foreach直接调用。这样规则只维护一份不会出现三处遍历代码三种过滤条件的情况。我个人的习惯是任何集合如果被三个以上系统的foreach使用就把它升级为自定义迭代器模式如果只有一两个调用点直接用foreach和filter就够了。这个判断标准帮我避免了很多过度设计也让代码保持简洁。设计模式这个东西背概念是最没用的能落地到具体场景才是真本事。迭代器模式看起来不起眼但它是Unity开发高频使用的模式理解透它哪怕只是为了更顺畅地阅读那些用了异步遍历的框架源码都值回票价。