【卡顿优化】卡顿问题如何监控?

6,669 阅读9分钟

前言

卡顿问题是 Android 开发中的一个常见但容易忽视的问题,毕竟又不是不能用。 同时 App 卡顿问题有着不易衡量卡顿程度,不易复现,难以定位等特点。

但是 App 卡顿会给用户体验带来较大的影响,从而影响用户的留存。本文主要包括以下内容:

  1. 我们应该如何衡量卡顿程度?如何对 app 的卡顿程度建立数据指标?
  2. 如何定位卡顿代码,找到带来卡顿的堆栈?

如何衡量卡顿程度

说到卡顿程度,大家一开始想起来的或许就是 FPS,FPS 即每秒显示的帧数,可以看出这是一个平均值,FPS 高并不代表页面流畅,比如下面这个例子

p1.gif

图片来源:卡顿率降低50%!京东商城APP卡顿监控及优化实践

可以看出,在滚动过程中,页面 FPS 最低也有 57 帧每秒,但却能感受到明显的滑动卡顿,这是因为 1s 内前半段某几帧的超时绘制被后半段的平稳绘制给平均了

可以看出,FPS 并不能完全表现出页面的卡顿程度,FPS 高并不代表页面流畅

那么我们应该用什么指标来表示页面卡顿程序呢?我们可以使用卡顿率来衡量

卡顿率 = 卡顿的帧数 / 总帧数

有了公式,那么我们如何确定卡顿的帧数,怎么样才算卡顿呢?

假如屏幕刷新率是 60/s,那么每帧耗时约 16ms,那么当一帧耗时超过 16ms 时,就发生了掉帧,也就是卡顿。掉帧数越多,说明卡顿也就越严重,比如如果某一帧实际绘制时间是 160ms,则说明其掉了 9 帧,对用户体验的影响也就更大

我们可以根据掉帧程度对卡顿进一步细化,比如按照下表定义卡顿的程度

卡顿程度定义正常范围轻微卡顿中等卡顿严重卡顿冻结帧
掉帧数[0:3)[3:9)[9:24)[24:42)[42:∞)

如上所示,我们可以定义掉帧数 3 到 9 帧为轻微卡顿,其他依次类推,通过这种方式,我们只需要获取每一帧的耗时,就可以获取页面的总体卡顿率,轻微卡顿率,严重卡顿率等,对页面的卡顿程度有了一个量化的指标

具体实现

为了获取页面的总体卡顿率,轻微卡顿率等指标,我们需要获取以下数据

  1. 页面总帧数
  2. 卡顿的帧数
  3. 卡顿各帧的耗时

获取各帧耗时业界一般有以下两种方案

  1. 通过设置自定义android.util.Printer,监听LooperdispatchMessage耗时
  2. 通过向Choreographer循环注册FrameCallback,统计两次Vsync事件时间间隔

以上两种方式都可以实现,但其实 JetPack 已经提供了一个用于监控线上卡顿的库:JankStats 库,我们可以直接使用这个库监控即可

JankStats 基于现有的 Android 平台功能构建,在 Android 7及更高版本中使用 FrameMetrics API 实现,在低版本中使用 OnPreDrawListener 实现

class JankLoggingActivity : AppCompatActivity() {

    private lateinit var jankStats: JankStats

    private val jankFrameListener = JankStats.OnFrameListener { frameData ->
            // 在实际使用中可以将日志上传到远端统计
            Log.v("JankStatsSample", frameData.toString())
    }


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
            // 初始化 JankStats,传入 window 和卡顿回调
            jankStats = JankStats.createAndTrack(window, jankFrameListener).apply {
            // 支持设置卡顿阈值,默认为2
            this.jankHeuristicMultiplier = 3f
        }

        // 设置页面状态
        val metricsStateHolder = PerformanceMetricsState.getHolderForHierarchy(binding.root)
        metricsStateHolder.state?.putState("Activity", javaClass.simpleName)
        // ...
    }

    override fun onResume() {
    	super.onResume()
    	// onResume后重新开始统计
    	jankStats.isTrackingEnabled = true
    }

    override fun onPause() {
    	super.onPause()
    	// onPause后停止统计
    	jankStats.isTrackingEnabled = false
    }

可以看出,JankStats 使用起来非常简单,主要要做下面几件事

  1. 初始化 JankStats,需要传入关联的 window 和卡顿监听
  2. 支持设置页面状态,比如当日志上报时需要了解卡顿是在哪个页面发生的,我们这里就传入了当前 Activity 的名字,在卡顿回调中可以读取。这个特点非常重要,我们可以通过这个 API 区分卡顿的场景,比如当页面发生滚动时和不滚动时设置不同的 state,就可以统计出滚动和非滚动时的卡顿率
  3. 支持设置卡顿阈值,默认为 2,即本帧耗时大于一帧预期耗时的2倍就判定为卡顿,我们这里修改为 3
  4. 支持开始与暂停统计,当 Activity 退到后台时可以暂时关闭统计
  5. JankStats 库会将每一帧的所有跟踪数据报告给已启用的 JankStats 对象的 OnFrameListener, 应用可以存储和聚合这些数据,以便日后上传。

这里的聚合是指我们可以先将卡顿数据存储在内存或者本地存储中,当卡顿数量达到一定程度或者页面切换时,再统一上传卡顿数据,减少上传次数,如下所示:

internal class JankActivityLifecycleCallback : ActivityLifecycleCallbacks {
    private val jankAggregatorMap = hashMapOf<String, JankStatsAggregator>()
    // 聚合回调
    private val jankReportListener = JankStatsAggregator.OnJankReportListener { reason, totalFrames, jankFrameData ->
            jankFrameData.forEach { frameData ->
            	// 获取当前 Activity name
            	Log.v("Activity",frameData.states.firstOrNull { it.key == "Activity" }?.value ?: "")
            	// 获取掉帧数
                val dropFrameCount = frameData.frameDurationUiNanos / singleFrameNanosDuration
                if (dropFrameCount <= JankMonitor.SLIGHT_JANK_MULTIPIER) {
                    slightJankCount++
                } else if (dropFrameCount <= JankMonitor.MIDDLE_JANK_MULTIPIER) {
                    middleJankCount++
                } else if (dropFrameCount <= JankMonitor.CRITICAL_JANK_MULTIPIER) {
                    criticalJankCount++
                } else {
                    frozenJankCount++
                }
            }
            // 实际使用中可以上传到远端统计
            Log.v("JankMonitor","*** Jank Report ($reason), " +
                        "totalFrames = $totalFrames, " +  // 总帧数
                        "jankFrames = ${jankFrameData.size}, " + // 总卡顿数
                        "slightJankCount = $slightJankCount, " + // 轻微卡顿数
                        "middleJankCount = $middleJankCount, " + // 中等卡顿数
                        "criticalJankCount = $criticalJankCount, " + // 严重卡顿数
                        "frozenJankCount = $frozenJankCount" // 冻结帧数
            )
        }
    }

    override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
        // 为所有 Activity 添加卡顿监听
        activity.window.callback = object : WindowCallbackWrapper(activity.window.callback) {
            override fun onContentChanged() {
                val activityName = activity.javaClass.simpleName
                if (!jankAggregatorMap.containsKey(activityName)) {
                    val jankAggregator = JankStatsAggregator(activity.window, jankReportListener)
                    PerformanceMetricsState.getHolderForHierarchy(activity.window.decorView).state?.putState("Activity", activityName)
                    jankAggregatorMap[activityName] = jankAggregator
                }
            }
        }
    }

    // ...
}

如上所示,主要做了以下事

  1. 为所有 Activity 添加了聚合的卡顿监听,当卡顿数达到阈值或者 Activity 退到后台时会触发聚合回调
  2. 在聚合回调中可以获取这段时间的总帖数,与卡顿的帧的列表,通过计算卡顿帧的掉帧数,我们可以获取总卡顿数,轻微卡顿数,严重卡顿数等。将这些数据上传就可以计算出页面的卡顿率
  3. 在聚合回调中我们同样可以获取页面的状态,比如我们这里设置的activityName,通过设置状态我们可以统计不同场景下的卡顿率,比如滚动与非滚动

这里精简了部分代码,完整代码可见:android-performance/jank-optimize

如何定位卡顿代码

通过以上方式建立了页面的卡顿指标,有了衡量页面卡顿程度的数据,下一步我们要进行优化的话,很明显我们需要定位到卡顿的代码,优化这些卡顿的代码,才可以降低我们的卡顿率

那么卡顿的慢函数该如何定位呢?业界一般也是有两种方式

堆栈抓取方案

思路其实很简单,在卡顿发生时 Dump 主线程堆栈,通过分析堆栈找到卡顿的原因。

需要注意的是,如果我们在帧结束的时候,再去判断该帧是否卡顿,如果卡顿则 Dump 堆栈,这个时候获取的堆栈很可能是不太准确的,如下图所示:

p7.png

可以看出,抓取堆栈的时机是明显偏晚了,如果这个 Message 里执行的函数特别多,你将很难定位出具体的问题

所以通常我们会启动一个子线程,开启定时任务,如果一定时间内消息没有执行完成,则判定为卡顿,从而发起 Dump 堆栈,如下图所示:

p8.jpg

如上所示,通过在子线程中开启一个定时任务,判断主线程中是否发生卡顿,如果发生卡顿则抓取主线程堆栈,通过这种方式可以比较准确的获取卡顿堆栈

滴滴开源的DoKit就是通过这种方式来获取卡顿堆栈的,感兴趣的可以去看下源码

这种方案在获取堆栈时比较准确,但如果你的定时任务周期较短,频繁 Dump,会对性能有较大影响,而如果周期较长,则会遗漏一些耗时任务,总得来说需要设置一个合适的阈值

同时通过获取堆栈的方式也无法获取各个方法的执行耗时,你无法一眼看出各个方法的耗时影响,需要进一步的线下定位

字节码插桩方案

堆栈抓取方案的最大缺陷是无法获取方法的执行耗时,而字节码插桩方式可以完美解决这一问题

通过在函数执行的开头与结尾分别插入一段代码,自然就可以计算出这个函数的执行耗时,在运行时,将前面一段时间的方法执行耗时收集起来,当发生卡顿时,则将此前一段时间的方法执行耗时全都上报,自然就可以清晰的定位出具体是哪个函数耗时了

Matrix 的慢函数定位就是通过字节码插桩实现的,字节码插桩方案的难点在于插桩方案对于运行时性能和包体积的影响,如果插桩明显地拖慢了 App 的运行性能,自然是得不偿失了。以下是 Matrix 插桩前后的对比数据

itemtraceuntraceinfo
FPS56.1656.19Android7.0 好机器朋友圈帧率
FPS41.1842.70Android4.2 差机器朋友圈帧率
apk size81.91 MB81.12 MB实际插桩方法总数 163141
memory+7.6M运行时内存

根据 Matrix 的文档,Matrix 插桩对于好机器的性能影响可忽略,对差机器性能稍有损耗,但影响很小。 对安装包大小影响,对于微信这种大体量的应用,实际插桩函数 16w+,对安装包增加了 800K 左右。

看起来非常优秀,可以直接用于线上,不过我也没有实践过,有使用过的同学可以在评论区交流下~

总结

卡顿问题也是 Android 性能优化中的一个常见问题,本文介绍了应该如何衡量页面卡顿程度,重点介绍了如何使用 JankStats 统计卡顿率,同时介绍了子线程定时 Dump 主线程堆栈,字节码插桩两种定位慢函数的方式。

基于以上内容,我们可以建立页面的卡顿指标,在发现卡顿时也可以较为准确地定位慢函数以进行治理,希望对你有所帮助~

源码

本文所有源码可见:github.com/RicardoJian…