事因

切换到ILRuntime执行之后,总体感觉手机上执行效率十分差,周末翻看了官方的源码,发现可以在代码里手动attach profiler(添加代码appdomain.UnityMainThreadID = System.Threading.Thread.CurrentThread.ManagedThreadId;)来查看真实堆栈,仔细查看profiler之后发现,在ILRuntime模式下,在Editor中,每3s也会强制执行一次GC,造成非常不好的体验.

分析

  1. 通过profiler数据分析发现,强制gc是由大量gc alloc导致,且均匀大批量分布在ET.EventSystem频繁派发事件的地方.简述原理:ET.EventSystem每次派发事件(添加/查找/删除/更新)的时候,都会通过foreach来遍历得到需要派发事件的对象,本身foreach在Unity中已经修复,不会有gc(但是会比for循环慢一倍,所以也尽量不使用),但在ILR中,foreach仍然会产生gc.所以GC频繁的地方一般是批量Awake事件(例:战场小兵新建),批量Update事件(例:战场小兵战斗逻辑循环).
  2. 同时在ILRuntime中,复杂对象的方法传递都会带有装箱操作,也会造成gc.空方法也会被编译执行,造成0.1ms+的性能开销
  3. 由于逻辑都是由系统挂载的Update驱动,可以控制系统的Update帧率来变相降低消耗.同时,原生C#调ILRuntime的开销最大,ILRuntime调用原生C#的消耗次之,ILRuntime内部跨域调用的消耗再次之,因此要找准优化点.
  4. 由于ET的写法,大部分变量作为静态变量存储于堆内存上,堆内存过大也是容易导致gc的一个点.

优化思路实现

  1. 源头即是各种UpdateSystem,所以尽量砍掉不必要的System,通过自定制定时器来实现,这样也方便控制刷新间隔.
  2. 通过与知名ECS框架Entitas,以及Unity的DOTS对比思路可以发现,System应当尽量独立于Entity之外,而不是像ET这样直接依附于Entity.尤其是大批量创建的Entity更是如此.因此第一步是将每个小兵身上挂载的UpdateSystem去掉,重新建立一个System来驱动管理战斗逻辑.同时为了避免过多的调用装箱操作,暂时不将战斗内模块做更多的封装拆分.降低gc频率.
  3. 尽量摒弃ET.EventSystem,目前已经仅仅使用创建于销毁事件.战斗内的Entity可以把创建事件也不走事件派发,通过定制创建事件来操作,应当可以进一步降低创建时的gc和卡顿.
  4. 由于ET的Entity查找器十分简陋,也是通过简单的foreach来查找,因此在性能要求比较高的地方,不再调用GetComponent,以战斗距离,将Unit常用的子组件全部以属性的形式存储在上面.
  5. 由于战斗中的刷新统一由一个system处理,可以将之前的一些不同system时序导致的容错处理优化掉.
  6. 趁优化重构战斗的同时,将一些通用的特效/子弹调用封装进统一缓存池.

优化效果

战斗内同时运行的system由300~500个 -> 1个. 强制gc频率由3s一次 -> 10s一次. 帧率由20帧以下,基本稳定到30帧(样本:振奋手机,另外一部分提升的点在于动画与粒子分批处理).

后续优化空间

  1. 优化AwakeSystem(简单).
  2. 整体代码逻辑上,少使用全局变量(删除不了的,在初始化的时候使用成员变量缓存),控制堆内存(简单).
  3. 参考Entitas实现高效的迭代器和查找器(困难).

事后反思

  1. 由于之前只用ILRuntime做过主UI型休闲游戏,且前期一直使用原生C#运行游戏,对于性能这块一直没达到足够的重视程度.虽然对ILRuntime上线后的问题有一定预期,仍然偏乐观,需要在前期就开始模拟纯线上版本用ILRuntime模式进行真机测试.
  2. 多阅读源码,找到合适的调试方式之后对症下药会更快的解决问题.
  3. 多阅读优秀开源项目,切换思考方式.

扩展阅读

https://ourpalm.github.io/ILRuntime/public/v1/guide/performance-optimization.html