一、引言
\\微信读书中,阅读引擎负责解析并呈现书本每一页的内容,是整个app最重要的一个模块,也是用户使用最多,产生交互最频繁的一个模块。
\\微信读书发布之初,从支持最基本的TXT纯文本书籍,再经过快速迭代同时支持EPUB格式书籍,整个过程当中阅读引擎的功能一直在快速膨胀,加上项目前期快速试错,进度紧张等原因,逐渐积累的卡顿问题开始凸显,通过对用户反馈的问题进行统计分析,读书时翻页卡顿的投诉占比高达51%,远超其它问题。为提升读书的用户体验和口碑,我们必须优化解决掉卡顿的问题。
\\ \\二、测试如何重现并定位
\\先来看看什么是卡顿,又是什么原因导致的呢?
\\我们知道大多数手机的屏幕刷新频率是60hz,如果在1000/60=16.67ms内没有办法把一帧的任务执行完毕,就会发生丢帧的现象。丢帧越多,用户感受到的卡顿情况就越严重。
\\而一个绘制周期中,即每一帧的刷新,需要通过CPU和GPU两个过程的处理,CPU主要负责程序内部逻辑处理包括Measure,Layout,Record,Execute的计算操作,GPU则主要负责图形的渲染工作。显示图片的时候,需要先经过CPU的计算加载到内存中,然后传递给GPU进行渲染。一旦GPU或者CPU的工作超过了规定时间,就会出现卡顿现象。
\\ \\从图上可以看到,第一帧中CPU处理的时间是合理的,但是由于GPU的处理耗时过长,导致在第一帧的16.6ms内无法完成对B帧的渲染,那么屏幕只能保持A帧画面,如果此时是正在运行着动画,那么就会出现卡屏的情况。
\\同样,在第三帧的时候,画面B已经Ready,被显示到屏幕上,重新更新A画面时,由于CPU中发生了耗时过长的处理,导致无法在16.6ms内完成对A画面的渲染,第四帧只能仍然保持B画面,又发生了一次卡顿,直到第五帧时,A画面才得以显示。
\\清楚了卡顿产生的基本原因后,我们再来结合读书的业务逻辑流程,分析可能导致卡顿的地方在哪里:
\\ \\在书架中点击一本书到内容完整的呈现出来,会经历以下几步的逻辑处理流程:
\\ \\1、选择书籍打开。
\\2、打开书籍时判断书籍当前章节内容是否存在,本地不存在则下载该章节(一个zip包),后续内容也会以章节为单位进行下载并解压得到书源文件(html、css)。
\\3、建立关系文件,对书源文件进行一系列样式拆分,文本提取,文字和样式关系建立,加密等,期间会生成TextStream、StyleList和RangeTree三类文件:
\\TextStream是从html提取出来的文字部分,为了版权信息该文件会进行加密处理
\\StyleList是从html和css拆分出来的样式集合
\\RangeTree是存储TextStream文字的区间和StyleList样式集合对应关系的文件,举个例子:
\\1) 从TextStream读取文本内容WeReadRocks!
\\2) 在StyleList查询并拆分得到该文本内容对应的样式集合
\\3) 通过RangeTree找出文本与样式集合的索引区间(有兴趣可参考https://medium.com/weread/android-typesetting-engine-index-design-e1581d3085ea)
\\4) 通过索引区间对应的样式对文本内容进行样式填充,如下图中红色样式块对应文字区间是[6-11),所以rocks的样式是红色,粗体。
\\ \\可能隐藏的问题:
\\对书源文件进行处理、加密等操作需要消耗较多的内存和cpu资源。
\\4、获取设备屏幕尺寸数据并在内存中对章节的字体、图片等资源进行预布局和排版的计算操作,并且生成字体、图片元素在页面的位置索引信息,包括坐标[x, y]和宽高[width, height]等。
\\ \\可能隐藏问题:
\\预排版和计算索引位置信息,需要消耗较多的cpu资源。
\\5、对索引位置信息进行压缩,并存储到磁盘中,下次再阅读该章节时就无需再次进行排版布局的计算了。
\\可能隐藏问题:
\\存储可能会对磁盘IO进行频繁读写操作,压缩可能要消耗较多的cpu资源。
\\6、通过读取磁盘的索引信息在Canvas画布中依次对页面每个元素进行布局,再通过rangeTree文件查找符合该元素的样式规则应用到这个元素上。
\\可能隐藏问题:
\\从磁盘读取索引信息和样式文件,图片等元素会触发IO读写,同时恢复布局排版也消耗cpu。
\\7、渲染书籍内容。
\\8、若章节已存在已存在证明该章节已经排过版了,接着判断该章节是否需要重排版(用户可能改变阅读引擎的字体大小/横竖屏切换等设置会触发重排版),如需要则转到步骤5。
\\通过对阅读引擎的数据流程分析得出,第(3)、(4)、(5)、(6)步涉及到对页面内容进行排版布局,压缩存储和读取恢复,需要消耗较多的CPU、内存或者频繁的IO读写,容易发生卡顿。尤其是对图片的处理,更加消耗资源。
\\因此测试重现问题,分为两个方向:
\\1、 全图片的书籍:可直接用漫画分类中书籍。
\\通过测试发现,的确全图片的书籍在阅读时,页面完全渲染出来会比较慢,主要因为图片size比较大,而且直接利用UI线程通过磁盘来读取图片内容,容易导致IO阻塞,同时也占用较多的内存,这里建议开发对图片大小进行压缩并且对图片内容异步加载。
\\2、数据量大、页面样式多的epub书籍,包括文字数量、样式数量。
\\那么问题来了,现网有十几万本书籍,每本书籍又分多个章节,怎样选出精准的测试样本呢?
\\人工挑选个人主观认为数据量大样式又复杂的书籍?答案是显而易见的NO,很有可能花了时间而又徒劳无功。
\\为了还原用户的最真实声音和场景,我们决定通过自动化脚本,在现网十几万本书籍中,找出最符合条件的top20的书籍章节。
\\思路:每本书籍都由HTML和CSS两个书源文件构成,样式都包含在css文件的类选择器中,而文字则包含在HTML文件中,因此,我们可以通过脚本扫出书籍中每章节对应的样式和文字,再建立起一个评分模型,计算出章节的页面复杂度,从而筛出页面复杂度top20的书籍章节。
\\ \\1) 从书库下载并解压得出书源文件html+css。
\\2) 解析html文件得出书籍的文本节点,计算出每个文本节点的文字数a,及其对应父节点个数b,标签个数代表了这段文本节点的样式层级数。
\\3) 计算每个文本节点的复杂度分值X:X=a*b
\\4) 计算每个章节的复杂度分值Y:\\(Y=\\frac{\\sum_{i=1}^{n} X_{i}}{\\sum_{i=1}^{n}a_{i}}\\)
\\5) 比较书籍所有章节的复杂度分值并输出分值最高的章节名。
\\通过以上方法筛选出的top20书籍如下:
\\ \\我们先拿两本书进行人工验证,发现在翻页过程当中的确感受到有停顿和页面不连贯的问题。
\\到此,我们已经重现了用户反馈的卡顿问题,但是卡顿不是必现的,有时出现在图片或样式多的页面,也有出现在只有文字的页面;同时在阅读时进行快速翻页过程中屏幕右边出现白边的问题,但都是比较难找到必现的规律,要想推动开发快速定位并修复,我们还必须提供准确的、有说服力的数据支撑,以及更多的现场信息。
\\三、如何衡量卡顿,卡了多长时间?能否获取到卡顿时的堆栈信息?
\\业内通常用来衡量一个app是否卡顿,有以下三种方法:
\\1、gfxinfo:
\\在android4.1及以上系统中,谷歌提供了一个工具来,叫做“ GPU呈现模式分析(Profile GPU rendering)”,在开启这个功能后,系统就会记录保留每个界面最后128帧图像绘制的相关时间信息。
\\ \\优点:
\\通过设备开发者选项开启“adb shelldumpsysgfxinfo”命令行方式获取数据,操作会比较简单直观,直接在app界面上就可以看到数据。
\\缺点:
\\1) 只监听Draw,Process,Execute三个过程的耗时。
\\2) 一段时间内不渲染,数据返回0,无法与卡顿导致帧率为0时区分开来,导致帧率计算错误。
\\3) 只能通过adb命令行获取系统文件的方式来得到数据信息,而在app层面却没有权限去读取这些系统文件,所以无法得知运行时是否出现超时,从而不能捕获当时的堆栈信息。
\\2、SurfaceFlinger :
\\SurfaceFlinger服务是Android的系统服务,负责管理Android系统的显示帧缓冲信息,Android应用程序通过调用SurfaceFlinger服务将Surface渲染到显示屏。
\\优点:
\\通过“adb shell dumpsysSurfaceFlinger”命令行方式获取,需要一边操作一边通过命令行拉取日志,操作简单。
\\缺点:
\\1) 只能获取最近127帧的数据。
\\2) 一段时间内不渲染,会一直输出上一次的信息,导致帧率计算错误。
\\3) 只能通过adb命令行获取系统文件的方式来得到数据信息,而在app层面却没有权限去读取这些系统文件,所以无法得知运行时是否出现超时,从而不能捕获当时的堆栈信息。
\\3、利用Choreographer.FrameCallback监控卡顿
\\https://developer.android.com/reference/android/view/Choreographer.FrameCallback.html
\\Android系统从4.1(API 16)开始加入Choreographer类,用于同Vsync机制配合,实现统一调度界面绘图。 系统每隔16.6ms发出VSYNC信号,来通知界面进行重绘、渲染,理想情况下每一帧的周期为16.6ms,代表一帧的刷新频率。开发者可以通过Choreographer的postFrameCallback设置自己的callback,你设置的callcack会在下一个frame被渲染时触发。因此,1S内有多少次callback,就代表了实际的帧率。然后我们再记录两次callback的时间差,如果大于16.6ms,那么就说明UI主线程发生了卡顿。同时,设置一个报警阀值100ms,当UI主线程卡顿超过100ms时,就上报卡顿的耗时以及当时的堆栈信息。示例如下:
\\ \\优点:
\\1) 通过调用系统函数自动获取数据,我们只需要在APP进行业务操作即可。
\\2) 每一次APP进行绘制轮询时postFrameCallback都会被调用,即时页面没有更新,能更准确计算帧率和掉帧。
\\3) 在卡顿出现的时刻可以获取应用堆栈信息。
\\缺点:
\\需要另开子线程获取堆栈信息,会消耗少量系统资源
\\由于需要获得堆栈信息来定位问题,因此我们选择了Choreographer.FrameCallback来进行卡顿监控测试,实现流程如下:
\\卡顿监控测试流程:
\\ \\自动化脚本将top20的样本,设置为最小的字号,并连续500次快速翻页和慢速翻页,监控页面上每一帧的渲染耗时,当卡顿大于100ms,即1S内丢帧大于6帧时,打印出当前线程池的堆栈信息,上报到itil,测试对上报的信息进行汇总,去重后排序,展示在卡顿监控系统页面上,结果如图:
\\ \\上图中,我们可以通过版本号进行筛选卡顿数据,并对每个卡顿问题进行次数排重,达到次数的阈值(目前100次)就会自动上报到tapd记录;同时测试同学也可以根据卡顿耗时数值高低来进行手动一键提单记录到tapd,方便开发同学跟踪修复,最后把修复情况同步回卡顿平台进行展示。
\\展开查看调用堆栈详情:
\\ \\ \\四、开发根据卡顿时的堆栈信息,分析定位出以下几个问题场景,并做了针对性的优化
\\1、阅读时翻页出现卡顿:每翻5页会卡顿一次
\\问题原因:
\\读取数据buffer策略不合理,每翻5页会缓存下5页数据,触发一次磁盘数据读取导致IO阻塞,体验上产生明显的卡顿现象。
\\优化策略:
\\采用滑动窗口且异步读取磁盘数据的缓存策略来平滑IO时延,实现如下:
\\1) 页面在Page3时,滑动窗口已经从磁盘读取page1到page5的数据(图片资源/关系文件/索引文件)并放到内存当中,同时page2和page4的完成预layout等待绘制。
\\2) 翻到page4后,page5完成预layout,同时异步从磁盘读取page6的数据,优化后数据读取量降低80%,有效平滑IO时延达到削峰效果。
\\ \\优化后效果:
\\卡顿现象消失,翻页流畅度的帧率也从之前30+帧提升到50,效果非常明显。
\\ \\2、阅读时快速翻页出现白边:
\\问题原因:
\\翻页过程同时恢复排版(onlayout),时机不合理。
\\1) 每次翻页都会创建一个PageView(CreatePageView)。
\\2) CreatePageView的同时获取排版数据,恢复元素位置,布局,页面绘制。
\\3) 恢复排版会令CPU耗时过多导致拖长UI线程的执行时间,导致页面刷新滞后引发白边的出现。
\\优化策略:
\\PageView页面复用,缓存当前页、上一页和下一页的PageView。
\\1) 阅读引擎维护三个物理页(PageView0、PageView1、PageView2),随着页面滚动不停更新页面数据。
\\2) 滚动结束后(PageView1→PageView2),将失效的PageView0复用起来,清除页面数据,同时对复用页面进行排版数据的获取,恢复元素位置,布局(layout)。
\\3) 当页面滚动到PageView3时直接对页面进行绘制即可。
\\ \\优化后效果:
\\经过对PageView页面复用的优化后,对样本进行快速和慢速翻页的回归,并记录帧率。未出现卡顿的情况,同时帧率数据也显示慢划翻页的帧率数据很平缓,平均在57帧左右;快划翻页时虽然还有部分毛刺,但平均帧率也达到55帧以上,基本感受不到卡顿。
\\ \\五、线上监控
\\在测试阶段,我们已经通过样本来发现了典型的卡顿问题,但不能完全反应出线上用户的所有问题,很可能暴露的只是冰山一角,那么我们可以将卡顿监控测试植入到现网的生产环境,实时监控用户的卡顿情况吗?
\\先来看看,开启卡顿监控测试和未开启的版本相比,会额外占用多少系统资源,是否会对用户造成负担?
\\于是,我们进行了性能对比测试,结果如下:
\\ \\CPU占用比例只增加0.08%,内存消耗增加1MB左右,对比APP的运存超过100MB来说只增加不到1%的内存消耗,几乎感知不到影响。
\\因此,我们灰度一部分现网用户开启卡顿监控,观察用户使用过程中卡顿的真实情况,Itil上会记录每个业务的卡顿堆栈数目及对应的卡顿耗时、业务帧率数据,页面如下:
\\卡顿堆栈数目及对应的卡顿耗时(阅读器翻页为例):
\\ \\业务帧率数据(阅读器翻页为例):
\\ \\六、总结
\\经过本次阅读引擎的卡顿监控测试,总结一下卡顿常见原因,主要有以下几点:
\\1、GPU耗时导致卡顿:
\\造成GPU耗时原因与画面的绘制有关,比如界面存在严重的过度绘制,绘制高清大图等,通常与UI View的这些绘制方法相关,如draw(),onDraw(),dispatchDraw()等。
\\——建议减少不合理的UI布局,视图过多,层次过深的问题,避免耗费UI线程去做更多的测量、布局、响应时间。在这方面,阅读的表现还算不错。
\\2、 CPU的耗时导致卡顿:
\\主要是由于UI线程有耗时较久的操作,比如处理大图片、进行耗时的IPC通信等,自然会拖长UI线程处理的时间,导致无法在16.6ms内处理完相关逻辑,进而导致了界面刷新滞后,给人带来的直接感受就是连续的动画过程发生了卡屏的现象。
\\——主线程只做与UI相关的事情,其它耗时长的操作异步处理
\\3、 GC导致卡顿:
\\如果发生内存抖动或短时间申请大内存等情况,会引发GC,导致主线程停止,从而发生卡顿。
\\——减少临时对象的使用,减小Bitmap对象的内存占用,使用更小的资源图片
\\作者
\\ 赵公卓,来自腾讯,已有8年在移动客户端的各项测试工作经验,主要负责QQMailApp和微信读书等产品。