前言

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

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

  1. 咱们应该怎么衡量卡顿程度?怎么对 app 的卡顿程度树立数据目标?
  2. 怎么定位卡顿代码,找到带来卡顿的仓库

怎么衡量卡顿程度

说到卡顿程度,咱们一开端想起来的或许便是 FPS,FPS 即每秒显现的帧数,能够看出这是一个平均值,FPS 高并不代表页面流通,比方下面这个例子

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

图片来源:卡顿率下降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 仓库,这个时候获取的仓库很可能是不太精确的,如下图所示:

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

能够看出,抓取仓库的时机是显着偏晚了,假如这个 Message 里履行的函数特别多,你将很难定位出详细的问题

所以一般咱们会发动一个子线程,开启定时使命,假如一定时刻内音讯没有履行完成,则判定为卡顿,然后发起 Dump 仓库,如下图所示:

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

如上所示,经过在子线程中开启一个定时使命,判别主线程中是否产生卡顿,假如产生卡顿则抓取主线程仓库,经过这种办法能够比较精确的获取卡顿仓库

滴滴开源的DoKit便是经过这种办法来获取卡顿仓库的,感兴趣的能够去看下源码

这种计划在获取仓库时比较精确,但假如你的定时使命周期较短,频繁 Dump,会对功能有较大影响,而假如周期较长,则会遗漏一些耗时使命,总得来说需求设置一个合适的阈值

一起经过获取仓库的办法也无法获取各个办法的履行耗时,你无法一眼看出各个办法的耗时影响,需求进一步的线下定位

字节码插桩计划

仓库抓取计划的最大缺点是无法获取办法的履行耗时,而字节码插桩办法能够完美处理这一问题

经过在函数履行的开头与结束分别刺进一段代码,自然就能够计算出这个函数的履行耗时,在运转时,将前面一段时刻的办法履行耗时收集起来,当产生卡顿时,则将此前一段时刻的办法履行耗时全都上报,自然就能够清晰的定位出详细是哪个函数耗时了

Matrix 的慢函数定位便是经过字节码插桩完成的,字节码插桩计划的难点在于插桩计划关于运转时功能和包体积的影响,假如插桩显着地拖慢了 App 的运转功能,自然是得不偿失了。以下是 Matrix 插桩前后的对比数据

item trace untrace info
FPS 56.16 56.19 Android7.0 好机器朋友圈帧率
FPS 41.18 42.70 Android4.2 差机器朋友圈帧率
apk size 81.91 MB 81.12 MB 实践插桩办法总数 163141
memory +7.6M 运转时内存

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

看起来十分优秀,能够直接用于线上,不过我也没有实践过,有运用过的同学能够在评论区交流下~

总结

卡顿问题也是 Android 功能优化中的一个常见问题,本文介绍了应该怎么衡量页面卡顿程度,重点介绍了怎么运用 JankStats 计算卡顿率,一起介绍了子线程定时 Dump 主线程仓库,字节码插桩两种定位慢函数的办法。

根据以上内容,咱们能够树立页面的卡顿目标,在发现卡顿时也能够较为精确地定位慢函数以进行管理,期望对你有所协助~

源码

本文一切源码可见:github.com/RicardoJian…