一、引言
微信读书中,阅读引擎负责解析并呈现书本每一页的内容,是整个 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 样式集合对应关系的文件,举个例子:
从 TextStream 读取文本内容 WeReadRocks!
在 StyleList 查询并拆分得到该文本内容对应的样式集合
通过 RangeTree 找出文本与样式集合的索引区间(有兴趣可参考 https://medium.com/weread/android-typesetting-engine-index-design-e1581d3085ea)
通过索引区间对应的样式对文本内容进行样式填充,如下图中红色样式块对应文字区间是 [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**** 的书籍章节。
从书库下载并解压得出书源文件 html+css。
解析 html 文件得出书籍的文本节点,计算出每个文本节点的文字数 a,及其对应父节点个数 b,标签个数代表了这段文本节点的样式层级数。
计算每个文本节点的复杂度分值 X:X=a*b
计算每个章节的复杂度分值 Y:(Y=\frac{\sum_{i=1}^{n} X_{i}}{\sum_{i=1}^{n}a_{i}})
比较书籍所有章节的复杂度分值并输出分值最高的章节名。
通过以上方法筛选出的 top20 书籍如下:
我们先拿两本书进行人工验证,发现在翻页过程当中的确感受到有停顿和页面不连贯的问题。
到此,我们已经重现了用户反馈的卡顿问题,但是卡顿不是必现的,有时出现在图片或样式多的页面,也有出现在只有文字的页面;同时在阅读时进行快速翻页过程中屏幕右边出现白边的问题,但都是比较难找到必现的规律,要想推动开发快速定位并修复,我们还必须提供准确的、有说服力的数据支撑,以及更多的现场信息。
三、如何衡量卡顿,卡了多长时间?能否获取到卡顿时的堆栈信息?
业内通常用来衡量一个app 是否卡顿,有以下三种方法:
1****、gfxinfo:
在 android4.1 及以上系统中,谷歌提供了一个工具来,叫做“ GPU 呈现模式分析 (Profile GPU rendering)”,在开启这个功能后,系统就会记录保留每个界面最后 128 帧图像绘制的相关时间信息。
优点:
通过设备开发者选项开启“adb shelldumpsysgfxinfo”命令行方式获取数据,操作会比较简单直观,直接在app 界面上就可以看到数据。
缺点:
只监听 Draw,Process,Execute 三个过程的耗时。
一段时间内不渲染,数据返回 0,无法与卡顿导致帧率为 0 时区分开来,导致帧率计算错误。
只能通过 adb 命令行获取系统文件的方式来得到数据信息,而在 app 层面却没有权限去读取这些系统文件,所以无法得知运行时是否出现超时,从而不能捕获当时的堆栈信息。
2****、SurfaceFlinger :
SurfaceFlinger 服务是 Android 的系统服务,负责管理 Android 系统的显示帧缓冲信息,Android 应用程序通过调用 SurfaceFlinger 服务将 Surface 渲染到显示屏。
优点:
通过“adb shell dumpsysSurfaceFlinger”命令行方式获取,需要一边操作一边通过命令行拉取日志,操作简单。
缺点:
只能获取最近 127 帧的数据。
一段时间内不渲染,会一直输出上一次的信息,导致帧率计算错误。
只能通过 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 时,就上报卡顿的耗时以及当时的堆栈信息。示例如下:
优点:
通过调用系统函数自动获取数据,我们只需要在 APP 进行业务操作即可。
每一次 APP 进行绘制轮询时 postFrameCallback 都会被调用,即时页面没有更新,能更准确计算帧率和掉帧。
在卡顿出现的时刻可以获取应用堆栈信息。
缺点:
需要另开子线程获取堆栈信息,会消耗少量系统资源
由于需要获得堆栈信息来定位问题,因此我们选择了Choreographer.FrameCallback来进行卡顿监控测试,实现流程如下:
卡顿监控测试流程:
自动化脚本将top20 的样本,设置为最小的字号,并连续500 次快速翻页和慢速翻页,监控页面上每一帧的渲染耗时,当卡顿大于100ms,即1S 内丢帧大于6 帧时,打印出当前线程池的堆栈信息,上报到itil,测试对上报的信息进行汇总,去重后排序,展示在卡顿监控系统页面上,结果如图:
上图中,我们可以通过版本号进行筛选卡顿数据,并对每个卡顿问题进行次数排重,达到次数的阈值(目前100 次)就会自动上报到tapd 记录;同时测试同学也可以根据卡顿耗时数值高低来进行手动一键提单记录到tapd,方便开发同学跟踪修复,最后把修复情况同步回卡顿平台进行展示。
展开查看调用堆栈详情:
四、开发根据卡顿时的堆栈信息,分析定位出以下几个问题场景,并做了针对性的优化
1****、阅读时翻页出现卡顿:每翻 5****页会卡顿一次
问题原因:
读取数据 buffer 策略不合理,每翻 5 页会缓存下 5 页数据,触发一次磁盘数据读取导致 IO 阻塞,体验上产生明显的卡顿现象。
优化策略:
采用滑动窗口且异步读取磁盘数据的缓存策略来平滑 IO 时延,实现如下:
页面在 Page3 时,滑动窗口已经从磁盘读取 page1 到 page5 的数据(图片资源 / 关系文件 / 索引文件)并放到内存当中,同时 page2 和 page4 的完成预 layout 等待绘制。
翻到 page4 后,page5 完成预 layout,同时异步从磁盘读取 page6 的数据,优化后数据读取量降低 80%,有效平滑 IO 时延达到削峰效果。
优化后效果:
卡顿现象消失,翻页流畅度的帧率也从之前30+ 帧提升到50,效果非常明显。
2****、阅读时快速翻页出现白边:
问题原因:
翻页过程同时恢复排版(onlayout),时机不合理。
每次翻页都会创建一个 PageView(CreatePageView)。
CreatePageView 的同时获取排版数据,恢复元素位置,布局,页面绘制。
恢复排版会令 CPU 耗时过多导致拖长 UI 线程的执行时间,导致页面刷新滞后引发白边的出现。
优化策略:
PageView 页面复用,缓存当前页、上一页和下一页的 PageView。
阅读引擎维护三个物理页(PageView0、PageView1、PageView2),随着页面滚动不停更新页面数据。
滚动结束后(PageView1→PageView2),将失效的 PageView0 复用起来,清除页面数据,同时对复用页面进行排版数据的获取,恢复元素位置,布局(layout)。
当页面滚动到 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 对象的内存占用,使用更小的资源图片