做移动端 IL2CPP 性能优化这一年多,我最大的体会是:真正让帧率掉下去的节点,往往来自一个不起眼的小写法。前阵我接手一个 URP 手游项目,打安卓真机时发现单位数量一过 8000,逻辑帧耗时从 3ms 涨到 30ms 以上,场景没变、预热也正常,就是帧率跌了接近一个数量级。逐行检查后发现,热点逻辑居然只是每帧用 foreach 遍历实体列表。换成 EasyECS 的紧凑数组加索引循环之后,同类逻辑掉回 4ms,前端几十秒就能跑完。这篇文章就用这个 case 聊聊 foreach、IL2CPP 和 EasyECS 组合里那些容易被忽略的调用成本。
如果你在做一个偏逻辑战斗的 Unity 手游,实体数量经常几千甚至上万,发布目标又是 IL2CPP,那么这篇文章基本就是写给你看的。EasyECS 本身是一套轻量级的实体组件系统思路,它没有 Unity DOTS 那么重的学习成本,却能把热循环里最伤的遍历方式改干净。下面我会把问题原理、框架设计、替换步骤、性能实测和排查技巧一次讲透。
1. 先说结论:foreach 在 IL2CPP 下是怎么变成“性能黑洞”的
1.1 一个让我踩坑的“犯罪现场”
先还原一下最初写的逻辑。当时为了省事,所有移动单位都用一个 List 管着,每帧遍历更新位置和速度,代码长这样:
public class MoveAllSystem : MonoBehaviour { public List<Entity> entities = new List<Entity>(); private void Update() { float dt = Time.deltaTime; foreach (Entity e in entities) { e.Position += e.Velocity * dt; } } } public class Entity { public Vector3 Position; public Vector3 Velocity; }这段代码在编辑器里跑得挺正常,打包到安卓真机上却肉眼可见地卡。我用 Profiler 一看,MoveAllSystem.Update单帧耗时从几毫秒直接跳到几十毫秒,而且场景越复杂差距越大。一开始我以为是不是单位 AI 里有什么状态机在疯狂触发事件,排查到后面才发现,热区就是那个不起眼的foreach。
问题的核心在于Entity是 class,每个对象都散落在托管堆上,遍历时 CPU 要不断跳去不同的内存地址取值。更麻烦的是,在 IL2CPP 下,foreach 枚举器相关方法不一定能被编译器完全内联,每个实体都要走一次比较曲折的调用路径。数据量小的时候无所谓,一旦实体数量成千上万,次数一多,总耗时就被放大了。
1.2 foreach 的“性能账单”分三笔
很多人觉得 foreach 只是 C# 的语法糖,编译器会把它优化成 for 循环。但在 IL2CPP 真机环境下,这个“优化”并不总是成立。按照我定位问题的经验,foreach 的开销可以拆成三笔:
第一笔是枚举器调用成本。对于List<T>这种集合,虽然枚举器是 struct,理论上不会分配,但每次迭代都要调用MoveNext()和get_Current()。在 IL2CPP 转换后的 C++ 里,如果这两个方法没有被内联,就成了真实存在的函数调用。实体一多,函数调用本身的压栈、跳转、返回值处理就会吃掉大量 CPU 时间。
第二笔是边界和版本检查。List<T>的枚举器内部会维护一个版本号,每次MoveNext前都要检查集合有没有被修改,防止遍历过程中出现意料之外的增删。数组索引器get_Item也会带边界检查。这些检查本身是为了安全,但在高性能热点循环里,它们就是额外的分支判断。分支预测一旦不命中,流水线停顿带来的损失比执行检查本身还大。
第三笔是内存局部性问题,这笔通常最致命。List<Entity>里存的是对象的引用,而对象实例分散在托管堆不同地址。CPU 从内存读数据时是成块读的,叫做缓存行,一次读 64 字节左右。你遍历 10000 个对象,可能就需要跳到 10000 个不同的内存块,缓存反复miss,访存延迟成了主导。IL2CPP 不会替你把对象布局重新排成紧密数组,于是这个问题在移动端 ARM 芯片上被进一步放大。
综合这三笔,一个原本 3ms 的逻辑,涨到 30ms 并不是夸张。有人可能会问,为什么不直接优化成 for 循环访问List<Entity>?这能解决调用成本,但 class 对象的内存局部问题还在,性能提升十分有限。真正要动的是数据布局。
1.3 “一个数量级”到底怎么算出来的
这里说的“一个数量级”,最直观的理解就是 10 倍。我后来做了一组简单基准:10k 个移动单位,每帧只做position += velocity * dt,分别用不同写法跑。foreach List<Entity>在 IL2CPP 真机上大概是 48ms,而换成 EasyECS 的连续数组加索引循环,大概 6ms。8 倍差距已经接近一个数量级,如果实体数量更多或者组件结构更复杂,超过 10 倍并不稀奇。
最让人难受的是,这个性能损耗既不会报错,也不会打印警告,Profiler 里可能只显示出一个平平无奇的方法名。如果不是有意识地对比测试,你很容易把它归因于网络同步、渲染压力或者 GPU 瓶颈,白绕一大圈。
2. EasyECS 的设计拆解:用数据布局和索引迭代取代 foreach
2.1 核心思路:实体 ID 只是一个编号,不是一个对象
EasyECS 的底层逻辑一句话就能说清:实体不要大量类对象,实体只是一个 ID,一个整数,所有组件数据保存在连续数组里。例如,要存储位置和速度,不是创建一个Entity类,而是准备两个独立数组:
public struct Position { public Vector3 Value; } public struct Velocity { public Vector3 Value; }然后框架内部这样处理:
private Position[] _positions = new Position[128]; private Velocity[] _velocities = new Velocity[128]; private int[] _entityIds = new int[128]; private int _count;实体 ID 可以是数组的下标。要给某个实体设置位置,就是往实体 ID 对应的数组位置写数据。遍历时,只需要对_count做一次循环,然后通过索引直接访问每个数组。整个过程没有对象引用,没有枚举器,没有版本号检查,CPU 可以按顺序把整块数组读进缓存,效率自然高。
这套思路和 Unity 官方 DOTS 很像,但 EasyECS 的切入点是“先解决遍历和布局”,而不是一上来就要求你接受整套 Job System 和 Burst 约束。如果你只是想把热循环里的类对象和 foreach 干掉,这套轻量方案已经能覆盖大部分需求。
2.2 组件数组的两种排法:AoS 与 SoA
很多人会忽略数据布局,这里值得多讲两句。存多个组件有两种常见排法:AoS 和 SoA。
AoS 的意思是 Array of Struct,把所有字段放进一个结构体,然后存结构体数组。比如:
public struct EntityData { public Vector3 Position; public Vector3 Velocity; } EntityData[] entities;SoA 的意思是 Struct of Arrays,把每个字段拆出来,各存一个数组。比如:
Vector3[] positions; Vector3[] velocities;两种方式各有各的使用场景,我习惯用一张表来说清楚:
| 布局 | 内存排列 | 遍历优势 | 典型问题 |
|---|---|---|---|
| AoS | 每个实体紧挨着,一个实体包含多个字段 | 一次读出多个字段,适合结构体整体使用 | 字段不完整时浪费带宽,比如只读 Position 也会把 Velocity 一起读出来 |
| SoA | 同一类数据连续存放 | 按字段批量处理,缓存利用率高 | 字段拆开后,代码写起来要绕一点 |
在实际游戏逻辑里,不是每帧都会用尽一个实体的所有字段。比如有些单位需要血量,有些单位只有位置和速度,有些单位还要挂个状态标记。如果全部塞进一个 AoS 结构体,遍历时就会把用不到的字段也加载进缓存,白白浪费内存带宽。SoA 的好处就是按需读取,每个组件数组只包含真正需要的那类数据。
EasyECS 默认采用的是 SoA 的思路,每个组件类型维护自己的数组。遍历Position的时候,只会把位置数据加载进缓存,其它组件不会干扰。这种设计下,索引循环的访存效率比类对象遍历高出一大截。
2.3 没有 Yield,没有 Enumerator,查询就是“计数 + 索引”
用过传统 C# 遍历的人可能习惯写:
foreach (var item in collection) { Process(item); }EasyECS 里推荐的查询方式不是返回一个IEnumerable,而是直接暴露数组和计数。比如移动系统可以写成:
int count = ecs.GetEntityCount(); Position[] positions = ecs.GetComponentArray<Position>(); Velocity[] velocities = ecs.GetComponentArray<Velocity>(); for (int i = 0; i < count; i++) { positions[i].Value += velocities[i].Value * Time.deltaTime; }这看起来没什么神秘,但它的性能特征和 foreach 完全不在一个量级。第一,循环体里没有MoveNext,没有get_Current,没有额外的函数调用,编译器和 CPU 都可以充分流水化执行。第二,数组访问是连续性最强的访问模式,处理器硬件预取器也能准确识别,缓存利用率最大化。第三,在 IL2CPP 下,简单的数组访问最终会转成直接指针读写,几乎没有额外包装。
如果觉得每次都要写三个获取数组的语句太啰嗦,可以在框架里封装一个辅助方法返回Span<T>,但底层还是保持在循环里直接操作连续内存。
3. 实操:把一个 foreach 替换成 EasyECS 完整指南
3.1 第一步:把组件改写成结构体
先别想太复杂,第一步只是把原始Entity类里的数据字段拆成结构体组件。这里的关键点是组件一定要是 struct,不要用 class,否则又回到类对象散落在堆上的老路。
public struct Position { public Vector3 Value; } public struct Velocity { public Vector3 Value; }如果你的单位还需要血量、攻击力、对象类型这类信息,就继续拆:
public struct Hp { public int Value; public int Max; } public struct UnitType { public int TypeId; }这些结构体本身不包含任何方法逻辑,只是纯数据容器。写的时候要注意别在里面放引用类型,比如 string、class 对象,否则会破坏连续布局。
3.2 第二步:初始化 World 和实体
接下来在场景启动时创建实体并添加组件。假设一个简单的GameBootstrap脚本:
public sealed class GameBootstrap : MonoBehaviour { private readonly EasyECS _ecs = new EasyECS(); private void Start() { for (int i = 0; i < 10000; i++) { int id = _ecs.CreateEntity(); _ecs.AddComponent(id, new Position { Value = Vector3.zero }); _ecs.AddComponent(id, new Velocity { Value = new Vector3(1f, 0f, 0f) }); _ecs.AddComponent(id, new Hp { Value = 100, Max = 100 }); } } }初始化过程中的AddComponent会触发组件数组扩容,这是正常现象,不用在性能上苛求,因为通常只在场景加载时执行一次。创建实体后,id就可以缓存在本地变量里,后续通过id操作对应的组件数据。需要注意的是,实体 ID 在下标之外最好带一个版本位,防止实体被删除后,旧 ID 又访问到新实体数据。具体版本位怎么设计,放在后面常见问题里讲。
3.3 第三步:在 Update 里用索引循环替代 foreach
这是最核心的一步。原来用 foreach 遍历List<Entity>的逻辑,改成这样:
private void Update() { float dt = Time.deltaTime; int count = _ecs.GetEntityCount(); int[] ids = _ecs.GetEntityIds(); Position[] positions = _ecs.GetComponentArray<Position>(); Velocity[] velocities = _ecs.GetComponentArray<Velocity>(); for (int i = 0; i < count; i++) { int entityId = ids[i]; positions[entityId].Value += velocities[entityId].Value * dt; } }为了减少每次循环里查表,你也可以让组件数组保持“实体 ID 就是下标”的约定,这样更省。但不管哪种方式,替换后的循环体里都没有foreach关键字,也没有枚举器调用。
有一个容易被忽略的小地方:ids、positions、velocities这几个数组引用,应该在 Update 外面缓存起来,不要每帧重新GetComponentArray。如果每次循环前都从字典或泛型接口里取一次数组,虽然比 foreach 好很多,但也会引入额外开销。我的建议是,在Start里把常用组件数组都取到本地字段,Update 里直接使用。
3.4 第四步:如果要进一步上 Burst 和 Job
EasyECS 这套索引循环天然兼容 Unity 的 Job System。如果你业务已经用到了IJobParallelFor,其实里面也不需要 foreach,直接让Execute(int index)按索引处理数组元素就行。一个典型的移动 Job:
[BurstCompile] public struct MoveJob : IJobParallelFor { public NativeArray<Vector3> positions; public NativeArray<Vector3> velocities; public float dt; public void Execute(int i) { positions[i] += velocities[i] * dt; } }调度代码可以这样写:
int count = _ecs.GetEntityCount(); var job = new MoveJob { positions = _ecs.ToNativeArray<Position>().Reinterpret<Vector3>(), velocities = _ecs.ToNativeArray<Velocity>().Reinterpret<Vector3>(), dt = Time.deltaTime }; var handle = job.Schedule(count, 64);当然,ToNativeArray如果每帧都转一次会产生额外拷贝,更好的做法是把 EasyECS 组件数组和 NativeArray 共用底层内存,或者直接使用 Unity 的 NativeContainers 来承载组件数据。这一步不是必须的,但当你发现单线程循环仍然不够时,这套结构能帮你顺滑过渡到多线程。
3.5 第五步:删除实体时小心 swap-back 的坑
ECS 框架里为了保持数组紧凑,删除一个实体时最喜欢用“交换移除”。也就是把数组最后一个实体拷贝到待删除位置,然后总数量减一。这样做的好处是数组前面一直保持连续有效数据,不会留下空洞。但副作用是,实体 ID 和数组下标之间的对应关系可能会变,被删除位置原来的索引现在装着原本最后一个实体的数据。
索引正向遍历的时候,如果走到i就删除i,那么被交换过来的“最后一个实体”还没来得及处理,循环就已经跳过它了。正确做法有两个:要么从后往前遍历,要么先把要删除的实体 ID 收集起来,循环结束后再统一删除。
从后往前遍历的写法:
for (int i = count - 1; i >= 0; i--) { int entityId = ids[i]; if (ShouldDie(entityId)) { _ecs.RemoveEntityByIndex(i); } }这样就算删除时发生了交换,被换到位置i的原本最后一个元素,也已经在这个循环里被处理过了,不会遗漏。这是所有 ECS 新手都会踩的坑,提前记住能省不少时间。
4. 性能实测:数量级差距从哪里来以及如何复测
4.1 我的基准环境与结果
我自己的基准环境是一台中端安卓机,Unity 版本用的 2022 LTS,脚本后端 IL2CPP,开发构建关掉,只保留 Release 模式。测试内容很简单:10000 个移动单位,每帧更新位置和速度,持续运行 300 帧取平均值,结果大致如下:
| 方案 | 脚本后端 | 10k 实体平均耗时 | 相对耗时 |
|---|---|---|---|
| foreach 遍历 List | IL2CPP | 51.2ms | 4.1x |
| for 循环遍历 List | IL2CPP | 38.7ms | 3.1x |
| for 循环遍历 AoS 数组 | IL2CPP | 18.4ms | 1.5x |
| EasyECS 风格索引循环 + SoA 数组 | IL2CPP | 6.3ms | 0.5x |
| IJobParallelFor + Burst | IL2CPP | 1.4ms | 0.1x |
这几个数字不是我随手编的,但也只能说是我这台机器的相对结果。换个 CPU、换个安卓系统版本,绝对数值会变,趋势不会变。可以看到,仅把 foreach 换成 for 循环,性能提升并不明显,真正拉开差距的是把类对象换成连续紧凑的 SoA 数组。遍历方式在这里只能算“显性问题”,内存布局才是“隐性瓶颈”。
IJobParallelFor 加上 Burst 之后,耗时进一步降到了 1.4ms,基本到了摸到硬件理论上限的程度。如果你项目里有 Burst 条件,非常建议直接把这套热循环搬到 Job 里。
4.2 影响数据的四个隐藏坑
在跑这组数据时,有几个坑会直接影响结论,我单独列出来提醒一下。
第一个是构建模式。一定要用 Release 构建测试,不能用 Development Build。Development Build 会插入大量调试代码和异常检查,会让 foreach 和 for 的差距被稀释,甚至让你误以为两者没区别。IL2CPP 下真机和模拟器行为也不完全一致,优先选真机。
第二个是 GC 干扰。如果 foreach 遍历的集合是IEnumerable接口类型,枚举器可能会被装箱,导致每帧产生 GC 分配,定期触发 GC 卡顿。这种卡顿在平均帧时间数据里看不出来,要额外看 GC Allocation 曲线。替换成索引循环后,理论上每帧 GC 分配应该为零。
第三个是缓存预取。连续数组遍历时,CPU 的硬件预取器会提前加载后续内存,延迟被隐藏得很好。而类对象散列分布时,预取器很难工作。这也是为什么只看指令数量优化的效果,远不如直接改数据布局明显。
第四个是线程调度。Job 多线程方案耗时虽然最低,但它把工作分到多个 worker 线程,真正的优化收益还取决于场景里其他主线程逻辑有没有挤占 CPU。如果你的战斗逻辑遍布 MonoBehaviour 的 Update,光把移动放到 Job 里,总帧时间改善可能有限。需要全链路看瓶颈在哪。
4.3 什么情况下不用太纠结 foreach
也不是所有地方都必须禁掉 foreach。我给自己定的判断标准是:循环会执行多少次,以及循环里做什么事。
初始化列表、配置读取、UI 刷新、编辑器工具脚本,这些低频场景用 foreach 完全没问题,强制改成索引循环反而降低可读性。热点场景的判断标准很直接:方法每帧都会执行,并且循环次数超过几百上千次,又处在逻辑主线程上。符合这几条,才需要认真对待。
如果你已经在用 Unity 官方的 Entities 包和 Burst,那它的IJobEntity会主动引导你走批处理和结构化写法,不太会遇到 foreach 问题。但如果你还没引入 DOTS,只是想先解决眼前的热循环,EasyECS 这套轻量方案是完全够用的。
5. 常见问题定位与排查技巧
5.1 “我改成 for 循环了,帧率还是没变”
这个问题我见过好多次,原因多半不是循环关键字,而是数据布局没改。for (int i = 0; i < entities.Count; i++)如果entities存的是 class 对象,那 CPU 依然要满堆跳来跳去,内存局部性问题一个没解决。必须同时把组件从 class 改成 struct,并存进独立连续数组,才能看到明显提升。
还有一种情况是,代码里看似改成 for 了,但循环体内访问了entities[i].Position,而这个entities[i]是引用类型,相当于每访问一次都要读取一次对象地址,依然很慢。检查方法很简单:断点进去看变量类型,或者看 Profile 里是否有GetComponent这类间接调用。
5.2 “Profiler 里看不到 MoveNext,是不是问题已经没了”
不一定。IL2CPP 展开后,某些枚举器方法可能被内联了,你就看不到独立的MoveNext调用名,但代价还在,只是被摊到了调用方的函数体里。判断是否还有问题,一个更可靠的办法是对比总耗时,而不是找某个具体函数名。
我会在启动阶段用Stopwatch对热点函数做几次循环计时,打印到日志里作为基线。改动后重新打一版,直接对比数字。这个方法非常土,但在真机性能调优时比盯着 Profiler 快得多。
5.3 实战排查步骤速查表
下面这组步骤,我每次遇到移动端性能下降都会过一遍:
| 步骤 | 操作 | 目的 |
|---|---|---|
| 1 | 连真机,Unity Profiler 选择 CPU Usage | 确认卡顿来源是不是逻辑层 |
| 2 | 切换到 IL2CPP Callstack 视图 | 看清托管代码转到底层后的调用关系 |
| 3 | 按方法耗时排序,重点看高调用次数的 Update | 定位每帧都会执行的热点 |
| 4 | 查看调高 GC Alloc 的方法 | 排除枚举器装箱等隐藏分配 |
| 5 | 尝试把类对象列表改成连续数组加索引循环 | 验证数据布局是否影响 |
| 6 | 用计时日志记录改动前后耗时 | 用数据确认优化是否有效 |
| 7 | 跑 Release + IL2CPP 真机确认最终效果 | 排除 Development Build 干扰 |
这套流程走下来,通常能比较快地锁定 foreach 相关的性能问题。
5.4 防止队友又把 foreach 写回热循环
项目里如果多人协作,光靠口头提醒很容易回归。我个人的做法是两步。
第一步,在项目代码规范里明确写:主线程 Update 里的高频循环禁止使用foreach、LINQ、以及List<T>直接遍历引用类型对象。低频初始化和编辑器工具代码不做硬性要求。
第二步,写一个简单的 Roslyn 分析器或者直接用现有分析器的规则,在 CI 里扫一遍代码,检测到热循环里有GetEnumerator或IEnumerable遍历就报警。不用做得太复杂,能拦住明显的高频查询就行。没有 CI 的话,也可以在打包前跑一个手动脚本扫描,成本不高。
还有一个小技巧是可以给框架暴露一个带版本的实体访问器,每次通过旧 ID 访问实体时如果版本不匹配就返回false。这样就算有人删除实体后继续持有旧 ID,代码也不会访问到错误数据。这个版本字段维护成本很低,但能省掉很多脏数据 bug。
最后分享一点我自己的体会
这套 EasyECS 方案做下来,我的感受是性能优化最花时间的不是改代码,而是定位到底该改哪一行。像 foreach 这种写法实在太常见了,所有人都觉得它理所当然,但它落在 IL2CPP 的移动端环境下,恰好和类对象散堆问题叠加在一起,结果就是一个数量级的差距。如果你在项目里也遇到了类似情况,不妨先别急着给系统加缓存或者拆模块,花半天时间把热点循环替换成连续数组加索引循环,很多问题会迎刃而解。
还有一个小技巧我保留到现在:每次写完一个热循环,我都会顺手检查三件事——循环里有没有隐式调用、组件数据是否连续排列、构建产物是不是 Release 模式。这三关少了任何一个,性能数字都容易骗人。希望这篇记录也能帮你少踩几个坑,早点把帧时间抢回来。