背景:
我们的引擎是 Egret,使用的是原生的 EUI,转微信小游戏;
工程第一版出来后使用 PerfDog 测试一波数据。结果发现很多问题,本文主要分三部分
第一部分主要介绍通过 PerfDog 发现问题,
第二部分主要介绍通过 PerfDog 的数据定位并解决问题。
第三部分介绍原生小游戏常见优化的地方
PerfDog 具体操作方法不再赘述,这里可以看文档PerfDog 使用说明
FPS 感人(我们限帧 60)
Cpu 勉强还过得去
【图一】
【图二】
内存堪忧
有的同学可能发现 App 的 CPUusage 比 total cpuusage 低很多(图一),是因为我选择测试的是微信 app,小游戏 是作为子进程而存在的,所以后来选择 PerfDog 的子进程进行测试,得到的数据会更加的精准;
==深色表示正在运行的顶层进程==
再次测试就是正常的数据啦(图二),于是我们切换到子线程开始进行第二次测试
FPS 数据:
CPU 数据:
内存数据:
这时我们在测试过程中发现内存不断上升,没有呈现一个正常的内存趋势,所以调整了一下测试策略
测试数据组成:
我们在测试过程中做了一些特殊操作:
1.战斗挂机【为了判断是否是战斗过程中触发的内存泄露】
2.反复打开关闭 UI【为了判断 UI 创建与销毁是否存在内存泄露】
3.静止在某一 UI 页面【为了与其他场景作区分】
4.息屏挂机【为了判断是否是由图像资源引起的内存泄露还是代码资源引起的泄露】
GPU 压力山大
我们通过 FPS 数据发现在游戏过程 Jank 十分严重,FPS 波动过于剧烈,尤其是集中在 UI 开启或者关闭的时候,这个时候我们进行数据排查发现 GPU 的使用率也变得异常高,基本上已经爆表,很明显渲染的压力很大,而我们游戏 UI 打开时实际上战斗也会被渲染,这和我们游戏的设计有关,所以渲染的压力很大。
再来看看内存:
内存数据:
我们通过 PerfDog 的数据发现内存是呈现一直上升的状态,尤其是 VSS(VirtualMemory),如脱缰的野马一发不可收拾,这样下去最终的结果就是被 System Kill 掉。
其实现在已经可以确定是发生了内存泄露,在 72 分钟的时间里内存从 726M 到了 956M,而且还在不断上升;
现在综合两次测试数据得出结论
1.FPS 波动过于剧烈,很不稳定,尤其是在 uI 创建与关闭时候;
2.存在内存泄露
3.其实还有一些其他小问题,不过优先解决这两个
有了 PerfDog 以上的数据,接下来我们就要开始定位排查问题啦,
项目局部架构:
1.我们的项目的基础架构是所有的基础功能都调用的同一份基础 class(祖传代码),例如通信类等等;
2.我们发现内存在一直上升,无论是角色在什么环境下,甚至是在息屏的时候内存也在上升,那么我们其实可以大概率定位是项目内部的基础 class 内部出了问题;
接下来开始细细排查;
首先要先了解一些 JS 的内存管理机制
回收机制
JS 中内存的分配和回收都是 VM 自动完成的,不需要像 C/C++ 为每一个 new/malloc 操作去写配对的 delete/free 代码,JS 引擎中对变量的存储主要是在栈内存,堆内存。内存泄漏的实质是一些对象出现意外而没有被回收,而是常驻内存。
GC 原理
JavaScript 虚拟机有一个特点,就是对象创建的开销远远大于对象计算的开销,并且对象创建会导致垃圾回收,而垃圾回收会导致游戏不定期卡顿。
在堆中查看无用的对象,把这些对象占用的内存空间进行回收。浏览器上的 GC(Gabage Collection 垃圾回收) 实现,大多是采用可达性算法,关于可达性的对象,便是能与 GC Roots 构成连通图的对象。当一个对象到 GC Roots 没有任何引用链时,则会成为垃圾回收器的目标,系统会在合适的时候回收它所占的内存。
我这里使用的谷歌浏览器的 Head Profiling,或者你也可以使用白鹭引擎的 profiler:
使用很简单:
1.打开 Google 浏览器,打开要监控的网页,win 下按 F12 弹出开发者工具
2.切换到 Memory,选择堆类型,选中 Take Heap SnapShot 开始进行快照
3.右边的视图列出了 heap 里的对象列表,点击对象可以看到对象的引用层级关系
4.进入游戏后拍下快照,打开某个界面,关闭界面,拍下快照
5.将新的快照转换到 Comparsion 对比视图,进行内存对比分析
==需要额外注意的是:
每次拍快照前,都会先自动执行一次 GC,保证视图里的对象都是 root 可及的。GC 的触发是依赖浏览器的,所以不能通过时时观察内存峰值而判断是否有内存泄漏。==
我们可以每隔一段时间来拍一次快照(由于公司项目原因,我就不展示真实项目了,此处仅作为教学):
我们可以打开谷歌浏览器的内存分析工具后有三个选项,我们可以根据自己的调试方式交替使用;
1.Heap snapshot - 用以打印堆快照,堆快照文件显示页面的 javascript 对象和相关 DOM 节点之间的内存分配
2.Allocation instrumentation on timeline - 在时间轴上记录内存信息,随着时间变化记录内存信息。
3.Allocation sampling - 内存信息采样,使用采样的方法记录内存分配。此配置文件类型具有最小的性能开销,可用于长时间运行的操作。它提供了由 javascript 执行堆栈细分的良好近似值分配。
这里举例使用堆快照分析,
右侧查看详细信息
可见 rect 对象一直在增高,那么我们可以查看一下导致 rect 对象未被释放的原因:
是由于 Rect 对象中存在一个属性 rect 一直被引用导致内存无法释放,那么我们到代码对应的位置去找,就可以较快的定位原因;最终我们发现是因为在自定义的一个全局事件监听器中实例化了一个对象,但是这个对象的一些属性会持续被这个事件监听器所引用而不会被回收
当然为了更快的定位哪个函数,我们也可以使用
一般结果是这个样子
Overview 的 HEAP(堆) 曲线图表示 JS 堆。
Call Stack 通常来说,垂直方向并没有太大的意义,仅仅表示函数嵌套比较深而已,但是横向表示调用时间,如果调用时间太长,那么就需要优化优化了。录制结果的调用堆栈,横向表示时会出现带有更多详情的浮窗间,垂直方向表示调用栈,从上往下表示函数调用。滑动鼠标滚轮可以查看某段时间的调用栈信息。把鼠标放到 Call Stacks 调用栈的某个函数上面可以查看函数详细信息。这个一般是性能优化时关注,对于内存泄漏,主要用于帮助定位进行了什么操作。
Counter(计数器) 窗格。在这里你可以看到内存使用情况(与 Overview(概述) 窗格中的 HEAP(堆) 曲线图相同),分别显示以下内容:JS heap(JS 堆),documents(文档),DOM nodes(DOM 节点),listeners(侦听器) 和 GPU memory(GPU 内存)。勾选或取消勾选复选框可以将其从图表中显示或隐藏。
主要关注第三个的 JS 堆内存、节点数量、监听器数量。鼠标移到曲线上,可以在左下角显示具体数据。这些数据若有一个在持续上涨,没有下降趋势,都有可能是泄漏。
由于篇幅原因,这里不过多介绍这些工具的使用,网上有很多相关教程;
我们发现在游戏运行时 drawcall 过多,而且每帧的渲染耗时比较长,所以会呈现一种卡顿的现象;
关于查看 drawcall 等可以通过白鹭自身的 FPS 面板查看 白鹭 debug 文档
在优化前首先要了解 egret 在渲染的一帧里做了什么工作内容
细分的话又可以分成
每一帧的工作内容:
1.执行一次 EnterFrame,此时,引擎会执行游戏中的逻辑。并且抛出 EnterFrame 事件
2.引擎会执行一个 clear。将上一帧的画面全部擦除
3.Egret 内核会遍历游戏场景中的所有 DisplayObject,并重新计算所有显示对象的 transform
4.所有的图像全部 draw 到画布
现在来优化一下:
首先要降低 drawcall:
1 把小图全都换成图集
2.实现文字合批,通过自定义字体,使用图片字体的方式代替原生的字体
3.动静分离,将需要变化的和不变的分别放在不同的层级下,比如背景层、图标层和动态变化层
4.动画尽量使用 dragon bones 帧动画而不是 spine 动画
5.使用 cacheAsBitmap,把矢量图在运行时以位图形式进行计算
降低帧事件的开销:
1.不要的 DisplayObject,直接 removeChild 而不是设置他的 visible 属性为 false,否则在第三步还会参与计算
2.不在主循环里创建任何对象,游戏中的人物、怪物、技能特效统统做成对象池
3.不在 EnterFrame 事件中做过多的操作,非要用可以自定义一些事件
我们可以用以下的函数统计创建的 gameobject 的数量
它是显示了每一秒钟去拿一个 hashCount 跟上一个 hashCount 作对比,这个 hashCount 是由白鹭引擎内部 API,用于统计引擎对象的创建数量。如果游戏静止放置不动,理论上 hashCount diff 的结果应该是 0,实际上要尽可能控制在 120 以下,如果超标,只需要在引擎的 HashObject 的构造函数这里添加一个断点,在运行时去检查调用堆栈就排查就可以了。
小小游戏中避免频繁使用 setData,这里是最容易出问题的地方
setData 原理:
每一次 setData, 逻辑层向渲染层的发起一次通讯,这个通信还不是直接传给 webView, 而是通过走了 native 层,渲染层收到通讯后,还需要重新渲染出来,
一次 setData 会带来两次开销:通信的开销 + webview 更新的开销。
尽量避免频繁使用 setData
小程序和原生小游戏有很多相同的地方:
补充:
微信小程序公开课