前景提要
背景: 我们的引擎是 Egret,使用的是原生的 EUI,转微信小游戏; 工程第一版出来后使用 PerfDog 测试一波数据。结果发现很多问题,本文主要分两部分
第一部分主要介绍通过 PerfDog 发现问题, 第二部分主要介绍通过 PerfDog 的数据定位并解决问题。 PerfDog 具体操作可以看文档 PerfDog 使用说明
第一部分————数据分析 本次的案例多见于游戏第一版时的情况,比较常见,所以拿出来做个分析。 这里强调一点。分析问题需要整体数据联动分析,单独看某单一信息是没是意义的
第一次测试数据 FPS: https://imgchr.com/i/dcUgKS 内存: https://imgchr.com/i/dcU2Dg CPU: https://imgchr.com/i/dcURbQ 结论:
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 及王者荣耀等游戏多子进程),可选择目标子进程进行针对性测试。默认是主进程。如图王者荣耀 https://imgchr.com/i/dcaEad https://imgchr.com/i/dcaVIA 详细的使用说明可以看这里:PerfDog 使用说明书
为了判断是什么导致的 FPS 波动较大,也为了判断是否存在 OOM,现在我们来选择子进程进行第二次测试; https://imgchr.com/i/dcaePI
第二次测试数据 测试数据组成: 为了验证我的一些猜想,也为了更细致的定位问题,我们在测试过程中做了一些特殊操作:
1.战斗挂机 [为了判断是否是战斗过程中触发的内存泄露] 2.反复打开关闭 UI [为了判断 UI 创建与销毁是否存在内存泄露] 3.静止在某一 UI 页面 [为了与其他场景作区分] 4.息屏挂机 [为了判断是否是由图像资源引起的内存泄露还是代码资源引起的泄露] FPS 数据: https://imgchr.com/i/dcamGt
CPU 数据:
https://imgchr.com/i/dcauxf 内存数据: https://imgchr.com/i/dcaWQK GPU 压力山大
https://imgchr.com/i/dcahLD FPS 与 GPU 分析:
我们通过 FPS 数据发现在游戏过程 Jank 十分严重,FPS 波动过于剧烈,尤其是集中在 UI 开启或者关闭的时候,游戏来说,渲染画面,相对来说 GPU 可能出现瓶颈,逐对 GPU 进行查看,这个时候我们进行数据排查发现 GPU 的使用率也变得异常高,很明显渲染的压力很大,而我们游戏 UI 打开时实际上战斗也会被渲染,这和我们游戏的设计有关,所以渲染的压力很大。
内存分析: https://imgchr.com/i/dca5ee
我们通过 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 以上的数据,接下来我们就要开始定位排查问题啦,
项目局部架构: https://imgchr.com/i/dcaIdH 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 执行堆栈细分的良好近似值分配。 https://imgchr.com/i/dcdtTH
这里举例使用堆快照分析, https://imgchr.com/i/dcdatA 右侧查看详细信息 https://imgchr.com/i/dcd0pt 可见 rect 对象一直在增高,那么我们可以查看一下导致 rect 对象未被释放的原因: https://imgchr.com/i/dcdD6f 是由于 Rect 对象中存在一个属性 rect 一直被引用导致内存无法释放,那么我们到代码对应的位置去找,就可以较快的定位原因;最终我们发现是因为在自定义的一个全局事件监听器中实例化了一个对象,但是这个对象的一些属性会持续被这个事件监听器所引用而不会被回收
当然为了更快的定位哪个函数,我们也可以使用 https://imgchr.com/i/dcdrX8 一般结果是这个样子 https://imgchr.com/i/dcd60g
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 的时间较长。 https://imgchr.com/i/dcd2kj 而我们的游戏在查看在 drawcall 后确定是由于游戏运行时 drawcall 过多,导致每帧的渲染耗时比较长,所以会呈现一种卡顿的现象; 关于查看 drawcall 等可以通过白鹭自身的 FPS 面板查看 白鹭 debug 文档 在优化前首先要了解 egret 在渲染的一帧里做了什么工作内容 https://imgchr.com/i/dcdRts 细分的话又可以分成 https://imgchr.com/i/dcwk4A
每一帧的工作内容:
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 的数量 https://imgchr.com/i/dcwE9I 它是显示了每一秒钟去拿一个 hashCount 跟上一个 hashCount 作对比,这个 hashCount 是由白鹭引擎内部 API,用于统计引擎对象的创建数量。如果游戏静止放置不动,理论上 hashCount diff 的结果应该是 0,实际上要尽可能控制在 120 以下,如果超标,只需要在引擎的 HashObject 的构造函数这里添加一个断点,在运行时去检查调用堆栈就排查就可以了。
查看 PerfDog 详情: https://perfdog.qq.com/?ADTAG=media.dev_website