注册

浅谈 Android 线上帧率统计方案演进

帧率是我们衡量应用流畅度的一个重要基准指标。本文将简单介绍 Android 线上帧率计算方案的演进和业界基于帧率来衡量卡顿的相关指标设计。


帧率计算方案的演进


Choreographer.postFrameCallback


自 Android 4.1 引入 Choreographer 来调度 UI 线程的绘制相关任务之后,我们便有了一个简单衡量 UI 线程绘制效率的方案:通过持续调用 Choreographer 的 postFrameCallback 方法来得到基于 VSync 周期的回调,基于回调的间隔或者方法参数中的当前帧起始时间 frameTimeNanos 来计算帧率( 注意到基于回调间隔计算帧率的情况,由于 postFrameCallback 注册的回调类型是 Animation,早于 Traversal 但晚于 Input,实际回调的起点并不是当前帧的起点 )。


这一方案简单可靠,但关键问题在于 UI 线程通常都不会不间断地执行绘制任务,在不需要执行绘制任务( scheduleTraversals )时,UI 线程原本是不需要请求 VSync 信号的,而持续调用 postFrameCallback 方法的情况,则会连续请求 VSync 信号,使得 UI 线程始终处于比较活跃的状态,同时计算得到的帧率数据实际也会包含不需要绘制时的情况。准确来说,这一方案实际衡量的是 UI 线程的繁忙程度。


Matrix 早期方案


怎么能得到真正的帧率数据呢?腾讯 Matrix 的早期实现上,结合 Looper Printer 和 Choreographer 实现了一个比较巧妙的方案,做到了统计真正的 UI 线程绘制任务的细分耗时。


具体来说,基于 Looper Printer 做到对 UI 线程每个消息执行的监控,同时反射给 Choreographer 的 Input 回调队列头部插入一个任务,用来监听下一帧的起点。队头的 Input 任务被调用时,说明当前所在的消息是处理绘制任务的,则消息执行的终点也就是当前帧的终点。同时,在队头的 Input 任务被调用时,给 Animation 和 Traversal 的回调队列头部也插入任务,如此总共得到了四个时间点,可以将当前帧的耗时细分为 Input,Animation 和 Traversal 三个阶段。在当前处理绘制任务的消息执行完后,重新注册一个 Input 回调队列头部的任务,便可以继续监听下一帧的耗时情况。


这一方案没有使用 postFrameCallback( 不主动请求 VSync 信号 ),避免了前一个方案的问题,但整体方案上偏 hack,可能存在兼容性问题( 实际 Android 高版本上对 Choreographer 的内部回调队列确实有所调整 )。此外,当前方案也会受到上一方案的干扰,如果存在其他业务在持续调用 postFrameCallback,也会使得这里统计到的数据包含非绘制的情况。


JankStats 方案


实际在 Android 7.0 之后,官方已经引入了 FrameMetrics API 来提供帧耗时的详细数据。androidx 的 JankStats 库主要就是基于 FrameMetrics API 来实现的帧耗时数据统计。


在 Android 4.1 - 7.0 之间,JankStats 仍然是基于 Choreographer 来做帧率计算的,但方案和前两者均不同。具体来说,JankStats 通过监听 OnPreDrawListener 来感知绘制任务的发生,此时,通过反射 Choreographer 的 mLastFrameTimeNanos 来获取当前帧的起始时间,再通过往 UI 线程的消息队列头部抛任务的方式来获取当前帧的 UI 线程绘制任务结束时间( 在支持异步消息情况下,将该消息设置为异步消息,尽量保证获取结束时间的任务紧跟在当前任务之后 )。


这一方案简单可靠,而且得到的确实是真正的帧率数据。


在 Android 7.0 及以上版本,JankStats 则直接通过 Window 的新方法 addOnFrameMetricsAvailableListener,注册回调得到每一帧的详细数据 FrameMetrics。
FrameMetrics 的数据统计具体是怎么实现的?简单来说,Android 官方在整个绘制渲染流程上都做了打点来支持 FrameMetrics 的数据统计逻辑,具体包括了



  • 基于 Choreographer 记录了 VSync,Input,Animation,Traversal 的起始时间点
  • 基于 ViewRootImpl 记录了 Draw 的起始时间点( 结合 Traversal 区分开了 MeasureLayout 和 Draw 两段耗时 )
  • 基于 CanvasContext( hwui 中 RenderThread 的渲染流程 )记录了 SyncDisplayList,IssueDrawCommand,SwapBuffer 等时间点,Android 12 上更是进一步支持了 GPU 上的耗时统计

可以看到,FrameMetrics 提供了以往方案难以给到的详细分阶段耗时( 特别注意 FrameMetrics 提供的数据存在系统版本间的差异,具体的数据处理可以参考 JankStats 的内部实现 ),而且在内部实现上,相关数据在绘制渲染流程上默认便会被统计( 即使我们不注册监听 ),基于 FrameMetrics 来做帧率计算在数据采集阶段带来的额外性能开销微乎其微。


帧率相关指标设计


简单的 FPS( 平均帧率 )数据并不能很好的衡量卡顿。在能够准确采集到帧数据之后,怎么基于采集到的数据做进一步处理得到更有实际价值的指标才是更为关键的。


Android Vitals 方案


Android Vitals 本身只定义了两个指标,基于单帧耗时简单区分了卡顿问题的严重程度。将耗时大于 16ms 的帧定义为慢帧,将耗时大于 700ms 的帧定义为冻帧。


JankStats 方案


JankStats 的实现上,则默认将单帧耗时在期望耗时 2 倍以上的情况视为卡顿( 即满帧 60FPS 的情况,将单帧耗时超过 33.3ms 的情况定义为卡顿 )。


Matrix 方案


Matrix 的指标设计则在 Android Vitals 的基础上做了进一步细分,以掉帧数为量化指标( 即满帧 60FPS 的情况,将单帧耗时在 16.6ms 到 33.3ms 间的情况定义为掉一帧 ),将帧耗时细化为 Best / Normal / Middle / High / Frozen 多类情况,其中 Frozen 实际对应的就是 Android Vitals 中的冻帧。


可以看到以上三者都是基于单帧耗时本身而非平均帧率来衡量卡顿。


手淘方案


手淘的方案则给出了更多的指标。


基于帧率数据本身的,细分场景定义了滑动帧率和卡顿帧率。
滑动帧率比较好理解;
卡顿帧率指的是,在出现卡顿帧( 定义为单帧耗时 33.3ms 以上 )之后,持续统计一段时间的帧耗时( 直到达到 99.6ms ( 6 帧 )并且下一帧不是卡顿帧 )来计算帧率,通过单独统计以卡顿帧为起点的细粒度帧率来避免卡顿被平均帧率掩盖的问题。和前面几个方案相比,卡顿帧率的特点在于一定程度上保留了帧数据的时间属性,一定程度上可以区分出离散的卡顿帧和连续的卡顿。


基于单帧耗时的,将冻帧占比( 即大于 700 ms 的帧占总帧数的比例 )作为独立指标;此外还有参考 iOS 定义的 scrollHitchRate,即滑动场景下,预期外耗时( 即每一帧超过期望耗时部分的累加 )的占比。


此外,得益于 FrameMetrics 的详细数据,手淘的方案还实现了简单的自动化归因,即基于 FrameMetrics 的分阶段耗时数据简单判断是哪个阶段导致了当前这一帧的卡顿。


作者:低性能JsonCodec
链接:https://juejin.cn/post/7225596319448449083
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册