实战PerfDog优化小游戏性能
背景:
我们的引擎是Egret,使用的是原生的EUI,转微信小游戏;
工程第一版出来后使用PerfDog测试一波数据。结果发现很多问题,本文主要分两部分
- 第一部分主要介绍通过PerfDog发现问题,
- 第二部分主要介绍通过PerfDog的数据定位并解决问题。
PerfDog具体操作可以看文档PerfDog使用说明
第一部分————数据分析
本次的案例多见于游戏第一版时的情况,比较常见,所以拿出来做个分析。
这里强调一点。分析问题需要整体数据联动分析,单独看某单一信息是没是意义的
第一次测试数据
FPS:
内存:
CPU:
结论:
1.我们发现在战斗时FPS波动较大
2.内存呈现持续上升的趋势
3.CPU的APP Usage太小,仅占1%左右
首先针对问题3的说明:
我之前选择测试的是微信app,而小游戏是作为子进程而存在的,所以应该选择PerfDog的子进程进行测试,这样得到的数据会更加的精准;下图的深色进程表示正在运行的顶层进程
针对这种多进程的应用测试:
iOS平台,APP多进程分为APP Extension和系统XPC Server。
比如:某电竞直播软件用到APP Extension扩展进程(扩展进程名LABroadcastUpload)。当然也可能用到系统XPC Server服务进程,如一般web浏览器会用到webkit。
Android平台,一般大型APP,比如游戏有时候是多进程协作运行(微信小游戏,微视等APP及王者荣耀等游戏多子进程),可选择目标子进程进行针对性测试。默认是主进程。如图王者荣耀
详细的使用说明可以看这里:PerfDog使用说明书
为了判断是什么导致的FPS波动较大,也为了判断是否存在OOM,现在我们来选择子进程进行第二次测试;
第二次测试数据
测试数据组成:
为了验证我的一些猜想,也为了更细致的定位问题,我们在测试过程中做了一些特殊操作:
1.战斗挂机 【为了判断是否是战斗过程中触发的内存泄露】
2.反复打开关闭UI 【为了判断UI创建与销毁是否存在内存泄露】
3.静止在某一UI页面 【为了与其他场景作区分】
4.息屏挂机 【为了判断是否是由图像资源引起的内存泄露还是代码资源引起的泄露】
FPS数据:
CPU数据:
内存数据:
GPU压力山大
FPS与GPU分析:
我们通过FPS数据发现在游戏过程Jank十分严重,FPS波动过于剧烈,尤其是集中在UI开启或者关闭的时候,游戏来说,渲染画面,相对来说GPU可能出现瓶颈,逐对GPU进行查看,这个时候我们进行数据排查发现GPU的使用率也变得异常高,很明显渲染的压力很大,而我们游戏UI打开时实际上战斗也会被渲染,这和我们游戏的设计有关,所以渲染的压力很大。
内存分析:
我们通过PerfDog的数据发现内存是呈现一直上升的状态,这样下去最终的结果就是被System Kill掉。其实现在已经可以确定是发生了内存泄露,在72分钟的时间里内存从726M到了956M,而且还在不断上升;
这里额外说下,看是否存在OOM不能只看PSS(PerfDog默认的memory是PSS),同样要注意VSS,有的游戏可能会存在PSS一般大小,VSS不断增大的情况,这也是不科学的。
简单分享下常见内存指标关系
内存耗用
VSS - Virtual Set Size 虚拟耗用内存(包含共享库占用的内存)
RSS - Resident Set Size 实际使用物理内存(包含共享库占用的内存)
PSS - Proportional Set Size 实际使用的物理内存(比例分配共享库占用的内存)
USS - Unique Set Size 进程独自占用的物理内存(不包含共享库占用的内存)
一般来说内存占用大小有如下规律:VSS >= RSS >= PSS >= USS
这里再稍微介绍下安卓的LMK(Low memory killer),详细信息就不多赘述了。
1.Android系统 会定时执行一次检查,内存达到某个值后,就会杀死相应的进程,释放掉内存。
2.每个程序都会有一个oom_adj值,这个值越小,程序越重要,被杀的可能性越低
3.Low memory killer 主要是通过进程的oom_adj 来判定进程的重要程度。oom_adj的大小和进程的类型以及进程被调度的次序有关
4.阈值表可以通过/sys/module/lowmemorykiller/parameters/adj和/sys/module/lowmemorykiller/parameters/minfree进行配置
现在综合两次测试数据得出结论
结论:
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堆内存、节点数量、监听器数量。鼠标移到曲线上,可以在左下角显示具体数据。这些数据若有一个在持续上涨,没有下降趋势,都有可能是泄漏。
由于篇幅原因,这里不过多介绍这些工具的使用,网上有很多相关教程;
卡顿优化
我们通过PerfDog的数据发现GPU压力很大,游戏来说,渲染画面久一般是drawcall过多,或者每次draw的时间较长。
而我们的游戏在查看在drawcall后确定是由于游戏运行时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 的构造函数这里添加一个断点,在运行时去检查调用堆栈就排查就可以了。
查看PerfDog详情:https://perfdog.qq.com/?ADTAG=media.dev_website