Android 流畅性三板斧之帧率监控

5,179 阅读7分钟

前言

Android 流畅性监控的三板斧,这里所指是【帧率的监控】,【卡顿监控】和【ANR的监控】。之所以讲这三者放在一起是他们的联系比较密切。帧率的下降往往伴随着有卡顿,【过分卡顿】往往就会产生ANR。

严谨的讲,帧率下降不一定会有卡顿(这里对卡顿是从技术角度定义在主线程执行了耗时任务),卡顿产生的原因还有其他因素导致,比如系统负载、CPU繁忙等。关于卡顿的详细内容放在流畅性三板斧的第二篇。

【过分的卡顿】也不一定产生ANR,卡顿但未触发ANR产生的条件就不会产生ANR。关于ANR的详细内容我们放在三板斧系列文章的第三篇。

Android 流畅性三板斧之帧率监控juejin.cn/post/721780…

Android 流畅性三板斧之卡顿监控juejin.cn/post/721877…

Android 流畅性三板斧之ANR监控 (juejin.cn/post/722004…)

该篇我们从应用开发者的角度,探索在应用层监控帧率的四种方式。

温馨提示,本文涉及的实现的代码以上传至github github.com/drummor/Get…,结合代码食用更佳

1 什么是帧率

帧率(Frame rate)是以帧称为单位的位图图像连续出现在显示器上的频率(速率)。

2 Android 中帧率的监控

线下开发我们可以使用开发者选项的帧率监控或者 adb shell dumpsys gfxinfo packagename进行监控针对性优化。这些方案不能带到线上。

惯常我们在Android里线下对帧率的监控主要依托Choreographer,关于Choreographer不再赘述在其他的文章有比较全面的介绍可以看这两篇文章

3 简单监控帧率方案

利用Choreographer的postcallback方法接口轮询方式,能够对帧率进行统计。

image.png

choreographer.postCallback()内部是挂载了一个CALLBACK_ANIMATION类型的callback。轮训方式往choreographer内添加callback,相邻两个callback执行时间间隔即能粗略统计单帧的耗时。严谨的讲这不是单帧的耗时而是两个【半帧】拼凑的耗时。

代码示例如下。

class PoorFrameTracker {
    private var mLastFrameTime = -1L
    private var mFrameCount: Int = 0
    val calRate = 200 //ms
    fun startTrack() {
        mLastFrameTime = 0L
        mFrameCount = 0
        Choreographer.getInstance().postFrameCallback(object : FrameCallback {
            override fun doFrame(frameTimeNanos: Long) {
                if (mLastFrameTime == -1L) {
                    mLastFrameTime = frameTimeNanos
                }
                val diff = (frameTimeNanos - mLastFrameTime) / 1_000_000.0f
                if (diff > calRate) {
                    var fps = mFrameCount / diff * 1000
                    if (fps > 60) {fps = 60.0f}
                    //todo :统计
                    mFrameCount = 0
                    mLastFrameTime = -1
                } else {
                      mFrameCount++
                }
                Choreographer.getInstance().postFrameCallback(this);
            }
        })
    }
}

优点

  • 简单快捷,无黑科技

缺点

  • 无活动时,也会监控,无效信息会把帧率较低时给平均掉。
  • 对应用带来不必要的负担。

4 帧率监控进化之一 hook Choreographer

针对章节三的方案,首先我们有两个主要的优化方向希望在主线程不活动的时候不进行帧率的检测

我们调用公开api Choreographer.postCallback()时会触发垂直同步(这部分可以参考另一篇文章)。

 # choreographer
 private final class FrameDisplayEventReceiver extends DisplayEventReceiver
             implements Runnable {
        private long mTimestampNanos;
         @Override
         public void onVsync(long timestampNanos, long physicalDisplayId, int frame,
                 VsyncEventData vsyncEventData) {
                ...
                 mTimestampNanos = timestampNanos;
                 Message msg = Message.obtain(mHandler, this);
                 msg.setAsynchronous(true);
                 mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
                  ...
         }
         @Override
         public void run() {
             mHavePendingVsync = false;
             doFrame(mTimestampNanos, mFrame, mLastVsyncEventData);
         }
     }
  • 【采集每帧的开始】利用Looper中Printer采集Message的开始和结束。上段代码是Choreographer中的一段代码。当收到底层垂直同步信号的时,利用Handler机制post的一个Runable,执行该帧的动作doFrame()。依次我们可以采集到每帧的开始和结束。
# Choreographer
private final CallbackQueue[] mCallbackQueues; 

image.png

  • 【过滤出每帧的执行动作】我们知道主线程中不单单执行每帧的动作,还会执行其他动作。如何过滤出执行的是每帧的动作。反射往Choreographer往里添加callback不触发垂直同步,同时在同步信号回调时,会调用我们传入的callback,如果执行了传入的callbacl就可以标识该次执行动作是帧的执行动作。
  • 【采集真实的垂直同步到达时间】反射拿到mTimestampNanos
  • 结合以上,我们能够采集到每帧执行耗时,依次可以计算出准确的帧率。且比我们第一种方案要优雅很多。
  void doFrame(long frameTimeNanos, int frame, DisplayEventReceiver.VsyncEventData vsyncEventData) {
        ...
        final long frameIntervalNanos = vsyncEventData.frameInterval;
        doCallbacks(Choreographer.CALLBACK_INPUT, frameData, frameIntervalNanos);
        doCallbacks(Choreographer.CALLBACK_ANIMATION, frameData, frameIntervalNanos);
        doCallbacks(Choreographer.CALLBACK_INSETS_ANIMATION, frameData, frameIntervalNanos);
        doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameData, frameIntervalNanos);
        doCallbacks(Choreographer.CALLBACK_COMMIT, frameData, frameIntervalNanos);
        ...
    }
  • 同时我们还可以通过反射的方式给Chorographer 里 mCallbackQueues添加不同的类型动作,采集不同类型动作的耗时。

补充

image.png

  • 严格意义上,该方案统计的也不是真实的帧率,而是一帧所有耗时中在UI Thread执行部分的耗时,上图doFrame部分。其他线程和进程还会执行其他动作最终才能完成一帧的绘制。但对于我们应用层来说更关注监控doFrame,我们在应用开发层面大部分能够干预的也在doFrame这部分。

(方案思路Matrix)

关于这个方案可查看: github.com/drummor/Get…

5 帧率监控进化之二 滑动帧率

#View
    protected void onScrollChanged(int l, int t, int oldl, int oldt) {
        ...
        final AttachInfo ai = mAttachInfo;
        if (ai != null) {
            ai.mViewScrollChanged = true;
        }
      ...
    }
  • View里如果有滑动行为产生最终都会调用到onScrollChanged(),当该方法调用的时候,会将mAttachInfo的mViewScrollChanged值设为true
#ViewRootImpl
    private boolean draw(boolean fullRedrawNeeded, boolean forceDraw) {
        ...
        if (mAttachInfo.mViewScrollChanged) {
            mAttachInfo.mViewScrollChanged = false;
            mAttachInfo.mTreeObserver.dispatchOnScrollChanged();
        }
    }
​
  • 如上代码ViewRootImpl的draw方法会如果check到mAttachInfo.mViewScrollChanged值为true就会就会调用ViewTreeObserverdispatchOnScrollChanged()方法,只要我们在viewTreeObserver设置监听,就能获取到界面是否正在滑动这一重要事件。

image.png

  • 整个过程的如上图所示,我们收到滑动回调这一事件的时候,其实是choreographer的doFrame()调用而来。

  • 结合上面我们就可以在收到【滑动事件】的时候使用Choreographer的postCallback开始统计帧率。

  • 什么时候结束呢?在没有【滑动信息】生成出来的时候看下面代码

       private var isScroll = false
        init {
            window.decorView.viewTreeObserver.addOnScrollChangedListener {
                //标识正在滑动
                isScroll = true
                //开始统计帧率            
                Choreographer.getInstance().postFrameCallback(FrameCallback())
            }
        }
    ​
       private inner class FrameCallback : Choreographer.FrameCallback {
            override fun doFrame(frameTimeNanos: Long) {
                if (isScroll) {
                    isScroll = false //重置滑动状态
                    if (lastFrameTime != 0L) {
                        val dropFrame =
                            (((frameTimeNanos - lastFrameTime) / 1000000f / 16.6667f) + 1f).toInt()
                        notifyListener(dropFrame)
                    }
                    lastFrameTime = frameTimeNanos
                } else {
                    lastFrameTime = 0
                }
            }
        }
    

    这样我们就实现了一个监控滑动帧率的方案,代码实现放在了 github.com/drummor/Get…

(方案来自淘宝技术团队)

6 帧率监控进化 之三 官方方案

官方出手,官方在Android N 以上新增了Window.OnFrameMetricsAvailableListener可以监听每帧的执行状态。包含总耗时,绘制耗时,布局耗时,动画耗时,测量耗时。依次我们可以计算出帧率。

  private val metricsAvailableListener =
        Window.OnFrameMetricsAvailableListener { window, frameMetrics, dropCountSinceLastInvocation ->
            val intent = frameMetrics?.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP) ?: 0
            val vsync = frameMetrics?.getMetric(FrameMetrics.VSYNC_TIMESTAMP) ?: 0
            val animation = frameMetrics?.getMetric(FrameMetrics.ANIMATION_DURATION) ?: 0
            val vsyncTotal = frameMetrics?.getMetric(FrameMetrics.TOTAL_DURATION) ?: 0
            val measureCost = frameMetrics?.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION) ?: 0     
            //计算帧率
        }
​
 this.window.addOnFrameMetricsAvailableListener(//向window注册监听
                    metricsAvailableListener, 
                    Handler(handlerThread.looper)

同时配合Jetpack的FrameMetricsAggregator的可以统计出帧耗时情况。

 private val frameMetricsAggregator = FrameMetricsAggregator()
 frameMetricsAggregator.add(this@FrameActivity)
 frameMetricsAggregator.metrics?.let {
                it[FrameMetricsAggregator.TOTAL_INDEX] //总耗时概况
                it[FrameMetricsAggregator.INPUT_INDEX] //输入事件耗时
                it[FrameMetricsAggregator.DRAW_INDEX]  //绘制事件耗时概况
            }

FrameMetricsAggregator内部存储比较有意思,是有一个SparseIntArray数组SparseIntArray[] mMetrics = new SparseIntArray[LAST_INDEX + 1],存储各个阶段的耗时SparseIntArray的key为耗时,value为该耗时的个数。

mMetrics[TOTAL_INDEX]:
{3=8, 4=13, 5=2, 6=44, 7=4, 15=1, 196=1, 198=1, 204=1, 209=1, 210=1, 233=1, 265=1}

如上这是每帧总耗时的分布,耗时3ms的有8个,耗时4ms的有8个

我们可以制定自己的标准,诸如单帧耗时<30ms为优秀,单帧耗时>30ms 且<60ms为正常,单帧耗时>60ms且<200ms为过高,单帧>200为严重。

7 数据统计

首先有一个大的原则,帧耗时统计是在有渲染动作发生时统计,空闲状态不统计。

帧率的统计就是,渲染帧的数量除以有帧渲染发生动作时间得到。

另,每帧的耗时不尽相同,希望抓住主线,针对性的统计慢帧冻帧的数量以及占比。或者切割的更为精细,如Matrix里默认的把帧的耗时表现分为四个等级。

  • 正常帧,<3*16ms
  • 中间帧,<9*16ms
  • 慢帧,<24*16ms
  • 冻帧,<42*16ms

再有就是,如通过adb shell dumpsys gfxinfo packagename命令或者FrameMetricsAggregator里的统计方式,把相同耗时的帧进行合并。

帧的统计往往以page(Activity)为维度,作为一个数据大盘数据。

8 其他

  • 帧率真实一个笼统的指标,会存在单帧耗时很高,还是帧率平均下来很优秀,从数据上看问题不大,但是用户的感知会比较强烈。我们更需要做的找到那个隐藏着的【耗时高】的单帧;我们需要全面的对主线程里的执行任务进行全面的监控,也就是卡顿监控的范畴。
  • 帧率只是统计【页面绘制】的概况,不能够全面反映主线程的耗时情况。主线程如果存在耗时动作,比如一个主线程的Handler的执行了一个>100ms的任务,如果此时并没有绘制任务需要执行,此时的不一定帧率就会降低。
  • 【warning!!】最后,已经困扰好几天,实际测试中发现,使用Window.OnFrameMetricsAvailableListener与hook choreograoher方案对比,Window.OnFrameMetricsAvailableListener有漏报的情况产生。这需要看framework源码进一步追查,有对这方面有研究的同学欢迎留言讨论。
  • 本文涉及的实现的代码以上传至github github.com/drummor/Get…

关注点赞鼓励,流畅性三板斧系列

Android 流畅性三板斧之帧率监控juejin.cn/post/721780…

Android 流畅性三板斧之卡顿监控juejin.cn/post/721877…

Android 流畅性三板斧之ANR监控 (juejin.cn/post/722004…)

本文正在参加「金石计划」