淘宝 Android 帧率采集与监控详解
APM 提供帧率的相关数据,即 FPS(Frames Per Second) 数据。FPS 在一定程度上反映了页面流畅程度,但 APM 提供的 FPS 并不是很准确。恰逢手淘低端机性能优化项目开启,亟需相关指标来衡量对滑动体验的优化,帧率数据探索实践就此拉开。
在探索实践中,我们遇到了许多问题:
高刷手机占比相对不低,影响整体 FPS 数据
非人为滑动数据参杂在 FPS 中,不能直接体现用户操作体验
计算平均数据时,卡顿数据被淹没在海量正常数据中,一次卡顿是否只影响一个 FPS 值还是一次用户操作体验?
经过一段时间的探索,我们沉淀下来了一些指标,其中包括:滑动帧率、冻帧占比、scrollHitchRate、卡顿帧率。除了相关帧率指标之外,为了更好的指导性能优化,APM 还提供了帧率主因分析,同时为了更好的定位卡顿问题,也提供了卡顿堆栈。
-
CALLBACK_INPUT,输入事件
-
CALLBACK_ANIMATION,动画处理
-
CALLBACK_TRAVERSAL,UI 分发
-
CALLBACK_COMMIT
APM 原始方案
当收到 Touch 事件后,APM 会采集页面 1s 内 draw 的次数。这个方案的优点是性能损耗低,但是存在致命缺陷。如果页面渲染总时长不足 1s 就停止刷新,会导致数据人为偏低。其次,触碰屏幕不一定会带来刷新,刷新也不一定是 Touch 事件带来的。而以上情况计算出来的都是脏数据。
但是,Android 在 ViewRootImpl 实现了一个Debug 的 FPS 方案,原理与上诉方案类似,都是在 draw 时累积时长到 1s,所以,如果是想要一个低成本性能无损的线下测试 FPS,这不失为一个方案。
感兴趣可以看 ViewRootImpl 的 trackFPS 方法。
Matrix
常规
FPS 是业界简单而又通用的一个指标,是 Frames Per Second 的简写,即每秒渲染帧数,通俗来讲就是每秒渲染的画面数。
计算出 FPS 并不是我们的目标,我们一直希望计算出的是滑动帧率,针对 FPS,我们更为关注的是用户在交互过程中的帧率,监控这一类帧率才能更好反映用户体验。
首先,面对之前的采集方案,根本不能采集出符合定义的 FPS,所以原始的方案就必须要进行舍弃,需要进行重新设计。当看到 Matrix 的方案时,觉得想法很棒,但是太过 hack,我们更倾向于维护成本更低、稳定性高的系统开放 API。
所以,在选择上,我们还是决定使用最普通的 Choreographer.FrameCallback 进行实现。当然,它不是最完美的,但是可以尽量在设计上去避免这种缺陷。
那我们怎么计算出一个 FPS 值呢?
Choreographer.FrameCallback 被回调时,doFrame 方法都带上了一个时间戳,计算与上一次回调的差值,就可以将之视之为一帧的时间。当累加超过 1s 后,就可以计算出一个 FPS 值。
在这个过程中,有个点要大家知晓,doFrame 在什么时机回调:
首先,我们每一次回调后,都需要对 Choreographer 进行 postFrameCallback 调用,而调用 postFrameCallback 就是在下一帧 CALLBACK_ANIMATION 类型的链表上进行添加一个节点。所以,doFrame 回调时机并不是这一帧开始计算,也不是这一帧上屏,而是 CPU 处理动画过程中的一个 callback。
当计算出一个 FPS 值后,就需要在上面叠加以下状态了:
View 滑动帧率
在最开始实现时,View 只要滑动就监控帧率,一直帧率产出到不滑动为止。根据需求,我们的帧率采集就变成了如下这样:
那怎么监控 View 是否有滑动呢?那就需要介绍一下这个 ViewTreeObserver.OnScrollChangedListener。毕竟只有了解实现原理,才能决定是否可用。
// ViewRootImpl#draw
private void draw(boolean fullRedrawNeeded) {
// ...
if (mAttachInfo.mViewScrollChanged) {
mAttachInfo.mViewScrollChanged = false;
mAttachInfo.mTreeObserver.dispatchOnScrollChanged();
}
// ...
mAttachInfo.mTreeObserver.dispatchOnDraw();
// ...
}
我们可以看到,在 ViewRootImpl#draw 中,判断了 mAttachInfo 信息中 View 是否产生了滑动,如果产生滑动就分发出来。那么什么时候设置的 View 位置变化(产生滑动)的呢?在 View 的 onScrollChanged 被调用的时候:
// View#onScrollChanged
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
// ...
final AttachInfo ai = mAttachInfo;
if (ai != null) {
ai.mViewScrollChanged = true;
}
// ...
}
onScrollChanged 就直接连接着 View#scrollTo 和 View#scrollBy,在大多数场景下,已经足够通用。
根据我们之前讲解的渲染流程:我们可以看到 ViewTreeObserver.OnScrollChangedListener 的回调是在 ViewRootImpl#draw 中,那么 Choreographer.FrameCallback 的回调先于 ViewTreeObserver.OnScrollChangedListener 的。
对于单帧,就可以如下表示:
这样,每一帧都带上了是否滑动的状态,当某一帧是滑动的帧,就可以开始计数,一直累积时间到 1s,一个滑动帧率数据计算出来就出来了。
手指滑动帧率
View 滑动帧率,在线下验证时,与测试平台出的数据一致,并且能够符合基本需求,验收通过。上线后,也开始了运行,并能够承担起帧率相关工作。
但是,View 滚动并不代表着是用户操作导致,数据始终不全是用户体验的结果。所以,我们开始实现手指的滑动帧率。
手指滑动帧率,首先我们需要能够接收到手指的 Touch 行为。由于 APM 中已有对 Callback 的 dispatchTouchEvent 接口的 hook,所以决定直接使用此接口识别手指滑动。
有 dispatchTouchEvent 不会立马产生 doFrame
通过 dispatchTouchEvent 计算移动时间/距离超过 TapTimeout/ScaledTouchSlop,不一定立马产生 doFrame
性能优化/滑动次数识别
起点(什么时候开始 postFrameCallback):在第一次收到 scroll 事件的时候(onSrollChanged)
终点(什么时候不再 postFrameCallback):在计算完一个手指滑动 FPS 后,如果下一帧不再滑动,那么就停止注册下一帧的回调。
冻帧是 Google 官方定义的一种帧:
Frozen frames are UI frames that take longer than 700ms to render.
冻帧作为一种特殊的帧,不是被强烈建议不要出现的帧,在华为等文档中也被提及过。一旦出现此类帧,页面也就像冻住似的。所以,在 APM 中,也将这一类特殊的帧纳入监控范围,计算出冻帧占比:
冻帧占比 = 滑动过程中的冻帧数量 / 滑动产生的帧数
这是因为 FPS 并不适用于所有的情况。比如当一个动画中有停顿时间, FPS 就无法反应该动画的流畅程度,而且并不是所有的应用都以达到 60 fps/120 fps 为目标,比如有些游戏只想以 30 fps 运行。而对于 Hitch rate 而言,我们的目标永远是让它达到 0。
▐ 帧率主因分析
-
FrameMetrics API 是在 Android 24 上提供的,查看手淘用户数据可以发现,能够满足基本需求;
-
一帧数据处理不及时会有丢数据的风险,但可以通过接口知晓丢弃了几帧数据。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
-
滑动过程获得每一帧的时间间隔; -
按照100(99.6ms,6帧的时间)毫秒左右的时间细化卡顿区间; -
从时间间隔大于33.3毫秒的帧开始记录,作为区间起点; -
结束点是从起点开始的帧耗时相加,达到99.6ms并且后面的一帧耗时小于17毫秒(或者到达最后一帧),否则会继续寻找结束点; -
这段时间内在统计帧率,是这里要寻找的卡顿帧率。
第二次从17帧开始,5帧114ms,FPS为43ms,最大帧间隔是61ms。
第三次从26帧开始,98+10=108ms,但是后面帧的耗时时间为19ms,超过16.6ms,所以仍然会加入一起统计。3帧,127ms,FPS为23。最大帧间隔是98。
按照这次的统计,总共有3次卡顿FPS,分别是30,43,23,最大的帧耗时帧是98。
▐ 卡顿 堆栈
如果使用主线程的 Looper Printer 来进行卡顿堆栈 dump,会因为大量的字符串拼接而带来性能损耗。在 Android 10 上,Looper 中新增 Observer,能够性能无损的回调,但由于是 hide 的 API,则无法使用。最终的办法只能是不断向主线程 post 消息,可每隔一段时间就给主线程抛消息又会给主线程带来压力。
是否有更好的方式呢?有的,通过 Choreographer postFrameCallback,本身就会 post 主线程消息,利用两次回调之间的差值高于某一个阈值,就可以认为是卡顿。而且这个识别的卡顿,还是滑动过程中的卡顿。
知道什么是卡顿,那什么时候 dump 呢?我们使用了 watchdog 的机制 dump 出卡顿堆栈,即在子线程 post 一个 dump 主线程的消息,如果单帧耗时超过阈值就进行 dump,如果在规定时间内完成当前帧,就取消 dump 的消息。当我们采集上来堆栈后,我们会将卡顿的堆栈进行聚类,便于更好的决定主要矛盾、告警处理。
AB 与 APM 结合使用
上文主要还是讲解了我们怎么计算出一个指标、怎么去排查问题,可是对于一个大盘指标而言,重之又重的当然是需要用来衡量优化成果的,那怎么去衡量优化呢?最好的手段是 AB。APM 指标数据与 AB 测试平台打通,性能数据随 APM 实验产出。
对于手淘性能监控而言,帧率监控、卡顿监控只是性能监控其中的一小环,打磨好每一个细节也至关重要。相关数据除了与 AB 平台搭配使用之外,已经与全链路排查数据、舆情数据、版本发布性能关口相打通,借用后台聚类、告警、自动化邮件报告等数据手段透出,专有数据平台进行承接。对于数据的态度,我们不仅是要有,而且要全面而强大。
在一轮又一轮的技术迭代下,手淘的高可用体现也不断完善与重构,希望在未来,手淘客户端高可用相关数据能够更好的助力研发各个环节,预防用户体验腐化,帮助不断提升用户体验。
淘宝Android体验技术团队,以打造极致的移动用户体验为愿景,立志于研发体验相关技术、中间件,以及提供产品化解决方案,一站式为手淘及其他移动应用核心场景体验赋能。团队在应用级优化有丰富的经验,并深耕于系统级能力,目前已有多个成熟技术方案服务于大促会场,应用启动,外链拉端等,已建立全链路的线上性能监控体系,探索非确定性性能问题的监控及排查能力,我们长期招聘志同道合的伙伴,欢迎有志人士加入。简历投递邮箱:[email protected]。