image.png

友情链接: BaguTree 《Android 面试、卡顿、ANR》 分享

Tips: 关注微信公众号 小木箱成长营 ,回复 "卡顿监测" 可获得卡顿监测免费思维导图

一、引言

Hello,我是小木箱,欢迎来到小木箱成长营系列教程,今天将分享卡顿监测 · 方案篇 · Android卡顿监测指导原则。小木箱从七个维度将Android卡顿监测技术方案解释清楚。

第一个维度是卡顿定义,第二个维度是卡顿原因,第三个维度是业界方案,第四个维度是相关预研,第五个维度是分析工具,第六个维度是卡顿指标,第七个维度是监测SOP。

其中,卡顿原因主要是通过绘制机制的历史演进过程,分析了卡顿本质原因。

其中,业界方案主要是通过ArgusAPM、BlockCanary、QQ空间卡慢组件、Matrix和微信广研分析大厂是如何做卡顿监测的。

其中,相关预研主要是讲解了主线程Printer监测、Choreographer帧率测量和字节码插桩方案。

其中,分析工具主要是讲解了工具对比和使用指南。

其中,卡顿指标主要是教大家如何定义监测指标。

其中,监测SOP主要是以监测范围、上报时机、业务降级、注意事项和方案优化五个维度讲解整个监测流程。

image.png 作者:小木箱

如果学完小木箱卡顿监测的方案篇,那么任何人做Android卡顿监测都可以拿到结果。

二、 卡顿定义

什么是卡顿?

Android5.0及以上系统中,如果主线程 + 渲染线程每一帧的执行都超过 16.6ms(60fps 的情况下),那么就可能会出现掉帧,这就是我们俗称的卡顿。

image.png

什么是卡死?

如果界面线程被阻塞超过几秒钟时间,那么用户可能会看到ANR对话框,这就是我们俗称的卡死。

image.png

卡顿原因

APP为什么滑动卡顿、不流畅? 什么情况下应用会卡?

如果想要回答APP为什么滑动卡顿、不流畅? 什么情况下应用会卡? 那么需要先简单了解安卓的屏幕刷新机制:

绘制机制历史演进

Android的绘制机制大概经历了以下四个阶段:

image.png

混沌时代(3.0前): 软件绘制

第一个阶段是混沌时代,即Android版本小于3.0版本,绘制库底层实现是基于Skia图形库,所有绘制在主线程,并不区分绘制和渲染。

如果ViewGroup控件有子View, 那么invalidate从根ViewGroup到子View全部要重绘,会造成不必要的渲染。

执行绘制和渲染是在主线程进行的,首先,调用View的Draw方法, 然后Canvas通过Skia图形库把Graphic Buffer数据通过View进行逐级派发,从而影响整个Canvas的Graphic Buffer数据,最后,SurfaceFlinger对Graphic Buffer数据进行加工合成,最终显示在DisPlayer中。

image.png

洪荒时代(3.0~4.1): 硬件加速 + DisplayList

第二个阶段是 洪荒时代 ,即Android版本在3.0~4.1版本之间,硬件加速目前主流机型是开启的。开启硬件加速之后,所有绘制在主线程和渲染线程,硬件加速绘制库底层实现是基于openGLRender/Vulkan接口对GPU进行封装的跨平台库。

什么是硬件加速?

硬件加速,指的就是 GPU 加速,这里可以理解为用渲染线程调用 GPU 来进行渲染加速 。

硬件加速在目前的 Android 中是默认开启的, 所以如果我们什么都不设置,那么我们的进程默认都会有主线程和渲染线程(有可见的内容)。

在硬件加速过程,和软件绘制不同的是View.Draw没有真正干活,Canvas录制所有绘制命令,如果硬件加速的Window设置View为软件绘制,硬件加速便降级为软件绘制。

我们如果在 App 的 AndroidManifest 里面,在 Application 标签里面加一个

android:hardwareAccelerated="false"

我们就可以关闭硬件加速,系统检测到App关闭了硬件加速,就不会初始化RenderThread ,直接 cpu 调用 libSkia 来进行渲染:

image.png

图片来源: 高爷: Android Systrace 基础知识 - MainThread 和 RenderThread 解读

渲染过程是阻塞的,当View.Draw完成遍历,进入一个渲染信息同步的过程,会把主线程记录的绘制信息同步到渲染线程。

当绘制信息同步完毕,主线程会重新唤醒,根据记录的绘制命令,调用openGLRender/Vulkan接口与GPU通信,绘制命令同步给GPU,GPU根据绘制命令生成Graphic Buffer数据,Graphic Buffer数据交给SurfaceFlinger合成,最终显示在DisPlayer中。

如果ViewGroup控件有子View, 调用invalidate方法,当前的View才会被重绘,解决了3.0以前系统不必要的渲染问题。

image.png

什么是RenderNode?

创建视图会创建RenderNode,硬件加速中用RenderNode标识对应视图。

image.png

什么是DisplayList?

Android 使用 DisplayList 进行绘制而非直接使用 CPU 绘制每一帧。DisplayList 是一系列绘制操作的记录,抽象为 RenderNode 类。

RenderNode调用Canvas时,申请一个DisplayListCanvas并把具体的操作缓存到View的DrawOp树中, 接着将View缓存中的DrawOp树同步到RenderNode中,最后,遍历所有View进行绘制,当前根视图树绘制操作叫DisplayList。

image.png

为什么要用到DisplayList?而不是CPU直接操作?

  1. DisplayList 可以按需多次绘制而无须同业务逻辑交互
  2. 特定的绘制操作(如 translation, scale 等)可以作用于整个 DisplayList 而无须重新分发绘制操作
  3. 当知晓了所有绘制操作后,可以针对其进行优化:例如,所有的文本可以一起进行绘制一次
  4. 可以将对 DisplayList 的处理转移至另一个线程(也就是 RenderThread)
  5. 主线程在 sync 结束后可以处理其他的 Message,而不用等待 RenderThread 结束
mAttachInfo.mThreadedRenderer.draw(mView,mAttachInfo,this);
void draw(View view,AttachInfo attachInfo,DrawCallbacks callbacks) {
    final Choreographer choreographer = attachInfo.mViewRootImpl.mChoreographer;
    choreographer.mFrameInfo.markDrawStart();
   // 更新到DisplayList里面
    updateRootDisplayList(view,callbacks);
    if (attachInfo.mPendingAnimatingRenderNodes != null) {
        final int count = attachInfo.mPendingAnimatingRenderNodes.size();
        for (int i = 0; i < count; i++) {
            registerAnimatingRenderNode(
                    attachInfo.mPendingAnimatingRenderNodes.get(i));
        attachInfo.mPendingAnimatingRenderNodes.clear();
        attachInfo.mPendingAnimatingRenderNodes = null;
    int syncResult = syncAndDrawFrame(choreographer.mFrameInfo);
    if ((syncResult & SYNC_LOST_SURFACE_REWARD_IF_FOUND) != 0) {
        setEnabled(false);
        attachInfo.mViewRootImpl.mSurface.release();
        attachInfo.mViewRootImpl.invalidate();
    if ((syncResult & SYNC_REDRAW_REQUESTED) != 0) {
        attachInfo.mViewRootImpl.invalidate();
}

DisplayList绘制流程是怎么样的?

DisplayList绘制操作大概分为以下6步:

  1. 当调用父ViewGroup的子视图的invalidate方法进行绘制
  2. 给没有绘制的子视图的DisplayList标记一个dirty
  3. 调用子视图的父ViewGroup的invalidate方法进行绘制
  4. 调用视图的根节点ViewRoot的invalidate方法进行根视图绘制
  5. 调用rebuild方法重构根视图树DisplayList
  6. 调用rebuild方法重构根视图树DisplayList所有子视图树DisplayList

那么硬件加速和软件绘制有什么区别呢?

image.png

硬件加速和软件绘制可以通过 BuildLayer 进行配置化。

switch (mLayerType) {
            case LAYER_TYPE_HARDWARE:
                updateDisplayListIfDirty();
                if (attachInfo.mThreadedRenderer != null && mRenderNode.isValid()) {
                    attachInfo.mThreadedRenderer.buildLayer(mRenderNode);
                break;
            case LAYER_TYPE_SOFTWARE:
                buildDrawingCache(true);
                break;
        }

如果支持硬件加速度,在绘制子view时候, 子view会hook硬件加速度方法, 直接执行updateDisplayListIfDirty。

如果支持软件绘制,那么首先,通过BuildCache来创建bitmap。然后,将bitmap绘制在硬件录音机Canvas上。最后,Canvas执行onDraw和dispatchDraw。

image.png

上古时代(4.1~5.0): Vsync + 三缓冲区

第三个阶段是上古时代,即Android版本在 4.1~5.0 版本之间。

什么是 屏幕刷新频率?

一秒内显示了多少帧的图像,单位 Hz。

image.png

什么是 帧率?

GPU 在一秒内绘制操作的帧数,单位 fps,Android 系统则采用更加流畅的60FPS,即每秒钟GPU最多绘制 60 帧画面。

帧率是动态变化的,例如当画面静止时,GPU 是没有绘制操作的,屏幕刷新的还是Buffer中的数据,即GPU最后操作的帧数据。

FPS 可以衡量一个界面的流畅性,但往往不能很直观的衡量卡顿的发生。一个稳定在 40、50 FPS 的页面,我们不会认为是卡顿的,但一旦 FPS 很不稳定,人眼往往很容易感知到,因此我们可以通过掉帧程度来衡量卡顿。 业界都是使用 Choreographer 来监听应用的帧率,跟卡顿不同的是,需要排除掉页面没有操作的情况,我们应该只在界面存在绘制的时候做统计。

image.png

如何监听界面是否存在绘制行为呢?

如何监听界面是否存在绘制行为呢?可以通过 addOnDrawListener 实现

getWindow().getDecorView().getViewTreeObserver().addOnDrawListener()

什么是画面撕裂问题?

一个屏幕内的数据来自2个不同的帧,画面会出现撕裂感。

image.png

怎么解决画面撕裂问题?

为了解决Android系统画面撕裂问题,即一个屏幕内的Back Buffer数据来自2个不同的帧,画面错位。

引入了双缓 概念, 当屏幕刷新时,Back Buffer并不会发生变化,后台Back Buffer准备就绪后,Back Buffer和Frame Buffer才进行交换。

什么是双缓 冲?

所谓的双缓存就是CPU/GPU写数据到Back Buffer,DisPlayer从Frame Buffer取数据。


image.png

Back Buffer和Frame Buffer的交换时机是什么时候呢?

Vsync是Back Buffer数据和Frame Buffer数据进行交换的最佳时间点。

image.png

如何理解Vsync+双缓存

Android4.1以前使用的是双缓存+Vsync , 怎么理解双缓存+Vsync呢?

双缓存+Vsync是指双缓存交换最佳时间点是在Vsyn,在Frame Buffer 和Back Buffer交换后,屏幕获取Frame buffer的新数据,而Back Buffer可以为GPU准备下一帧Back Buffer数据。

Google在Android 4.1系统中,如何触发Vsync时机窗口呢?

在Android 4.1系统中,系统每隔16.6ms会触发一次Vsync通知,CPU和GPU收到Vsync通知后,CPU和GPU立刻开始计算,然后把数据写入Back Buffer,一定程度上避免了丢帧。

Vsync + 双缓冲区为什么解决不了丢帧问题?

但执行Back Buffer数据和Frame Buffer数据交换过程中,是有时间开销的,下一次执行Back Buffer数据和Frame Buffer数据交换时间依然超过16.6ms的,所以依然会出现丢帧情况。

Vsync + 三缓冲区是如何解决丢帧问题的?

Android4.1引入了三缓存,即Back Buffer和Frame Buffer双缓冲机制基础上增加了Graphic Buffer缓冲区。

image.png

虽然Graphic Buffer 占用了内存, 而且相比双缓冲区有所延迟,但是Back Buffer、Frame Buffer和Graphic Buffer三缓冲有效利用了等待Vsync的时间,减少了丢帧。以上, 就是Android屏幕刷新原理

image.png

末法时代(5.0后): RenderThread

第三个阶段是末法时代,即Android版本在5.0以后的版本。

因为View自身创建、绘制复杂和主线程被阻塞,无法及时绘制等原因,而16.6ms内需要完成UI创建、绘制、渲染、上屏。

影响CPU的大头是UIThread, 分别对应着View的Create、Measure、Layout和Draw ,为此在16.6ms内,提升View的绘制效率是解决卡顿问题的关键。

image.png

非UIThread的执行逻辑导致的卡顿需要根据具体业务场景分析,比如影视播放卡顿可能是播放器原因,可能是网络原因等等。UI Thread和RenderThread 里面的卡顿有如下几类的原因:

RenderThread阻塞

什么是 RenderThread?

image.png

所谓的有可见内容的时候进行渲染的线程,RenderThread就是指渲染线程。

流畅的应用渲染需要16.6ms,但是具体16.6ms要做哪些事情。

一个Vsync的16.6ms要UIThread和RenderThread配合完成才能保证流畅的体验,UIThread是执行View的Create、Measure、Layout和Draw时候调用,即遍历View过程,RenderThread跟GPU通信会将图片上传GPU,上传图片期间UI Thread是阻塞状态的。

image.png

UIThread阻塞

UIThread被 阻塞 的因素多种多样,有Binder 阻塞 、IO 阻塞 等等。

Surfaceview刷新为什么用户界面没有卡顿?

因为Surfaceview拥有独立的surface Canvas,所以Surfaceview可以在开发者自定义的线程中刷新,视频刷新就不会影响到 UIThread

GLSurfaceView本质上是将UI数据当成纹理,放在自定义的子线程中传入GPU后,将Bitmap数据也放到子线程,并传入GPU,即“异步纹理”,TextureView,SurfaceTexture等控件会将图片数据放在自定义的线程中渲染。

后台进程 CPU 高负载

如果CPU被后台进程或者线程消耗,前台应用流畅性会受到影响。

复杂View

复杂的布局会导致inflate时间变长,同时也会导致遍历View时间变长,如果遍历View和 RenderThread 渲染部分不能在16ms内完成会出现掉帧。

requestLayout

布局发生变化,需要重新进行measure/layout/draw的流程,requestLayout比invalidate调用更重,invalidate只是标记一个“脏区域”,不需要执行meausre/layout调用,只需要重绘即可。

requestLayout调用意味着频繁的遍历所有子View,会导致卡顿掉帧问题。

了解这些原因之后,我们就可以根据业界的APM方案定制化我们企业内部的APM方案呢。

四、业界方案

经过 统计 ,我们发现到的卡顿问题,90%都是 来自用户反馈的 ,我们自己发现的 只有10%。

所以,我们想建设一套卡顿APM监测体系,来帮助我们在开发中,主动发现问题。

在预研了业界各个卡顿监测方案后,我们发现有几套可参考的APM技术方案分别是: BlockCanaryEx、ArgusApm、QAPM、微信广研卡顿方案、BlockCanary、QQ空间卡慢组件、美团Hertz、Blue和Matrix。

image.png

今天我们着重分析一下ArgusAPM、BlockCanary、QQ空间卡慢组件、微信广研卡顿方案和Matrix的卡顿监测原理。市面上QAPM、BlockCanary和美团Hertz是通过监测主线程Printer实现的。

ArgusAPM

比如: 360的ArgusAPM是在消息分发时候,postDelay一个 Runnable,消息分发结束移除Runnable,如果指定 delay时间内没有移除,说明发生了卡顿。

BlockCanary

比如: BlockCanary通过替换Looper的Printer实现,在每一个消息的执行前后打印日志,设置Printer后,通过两次调用println时间间隔,作为一个消息执行的耗时。去dump当前主线程执行堆栈和耗时,上报到观测平台。通过观测平台找到卡顿原因,但是打印参数有字符串拼接,性能损耗比较严重。

QQ空间卡慢组件

比如: QQ空间卡慢组件通过子线程,每隔 1 秒向主线程消息队列头部插入条空消息。假设 1 秒后消息没有被主线程消费,说明阻塞消息运行时间在 0~1 秒之间。

如果需要监测 3 秒卡顿,那么在第4次轮询中头部消息没有被消费,可以确定主线程出现一次 3 秒以上卡顿。

ArgusAPM、BlockCanary方案,可以捕获到卡顿的堆栈,最大不足在于无法获取各个函数执行耗时,很难找出稍微复杂堆栈的耗时函数,卡顿原因定位难度高。

QQ空间卡慢组件通过子线程循环获取主线程的堆栈中。

如果处理不及时,导致堆栈获取有偏移,不够准确。

如果没有耗时信息,那么卡顿更难定位。

因为获取主线程堆栈,需要暂停主线程运行,所以性能开销大。这里小木箱给大家推荐一款APM监测神器Matrix。

Matrix

比如: Matrix做法是在编译期间收集所有生成的 class 文件,扫描文件内的方法指令进行统一的打点插桩。

为了减少插桩量以及性能损耗,通过遍历 class 方法指令集,判断扫描的函数是否只含有 PUT/READ/FIELD 等简单的指令,来过滤一些默认或匿名构造函数,以及 get/set 等简单不耗时的函数。

为了方便以及高效记录函数执行过程,会为每个插桩的函数分配一个独立的 ID,在插桩过程中,记录插桩的函数签名以及分配的 ID,在插桩完成后输出一份 mapping,作为数据上报后的解析支持。

微信广研

比如: 微信广研卡顿方案通过向 Choreographer 注册监听,每一帧 doFrame 回调时判断距离上一帧的时间差是否超过阈值,如果超过了阈值即判定发生了卡顿。

将两帧之间的所有函数执行信息进行上报分析。同时,在每一帧 doFrame 到来时,重置一个计时器,如果 5s 内没有 cancel,则认为是发生了 ANR。

预研一套高维护、高可用、高扩展、可监测、可告警的卡顿APM方案并落地,同时解决采集不准、采集失效的业界痛点势在必行。

五、相关预研

我们知道造成卡顿的直接原因通常是,主线程执行繁重的UI绘制、大量的计算或IO等耗时操作。

从监测主线程的实现原理上,主要分为3大类:

  1. 主线程Printer监测
    依赖主线程 Looper,监测每次 dispatchMessage 的执行耗时(BlockCanary)。
  2. Choreographer帧率测量
    依赖 Choreographer 模块,监测相邻两次 Vsync 事件通知的时间差(LogMonitor)。
  3. 字节码插桩
    ASM字节码插桩分析慢函数耗时,超过阈值上报观测平台(Matrix)。

方案一: 主线程Printer监测

Looper.Printer基本能满足绝大部分场景,下面小木箱带大家看一下看下 Looper#loop 代码片段:

public static void loop() {
    for (;;) {
        // This must be in a local variable, in case a UI event sets the logger
        Printer logging = me.mLogging;
        if (logging != null) {
            logging.println(">>>>> Dispatching to " + msg.target + " " +
                    msg.callback + ": " + msg.what);
        msg.target.dispatchMessage(msg);
        if (logging != null) {
            logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}

主线程所有执行的任务都在dispatchMessage方法中派发执行完成,我们通过setMessageLogging 的方式给主线程的Looper设置一个 Printer接口

因为dispatchMessage执行前后都会打印对应信息,在执行前利用另外一条线程,通过Thread#getStackTrace 接口,以轮询的方式获取主线程执行堆栈信息并记录起来。

同时统计每次 dispatchMessage 方法执行耗时,当超出阈值时,将该次获取的堆栈进行分析上报,从而来捕捉卡顿信息,否则丢弃此次记录的堆栈信息。

Android System提供了一个 Printer接口 Printer接口 能在主线程执行 每个绘制任 务的 前后都进行一次 回调, Printer接口 类似于iOS里runloop中的observer。

Printer接口设置给主线程后,用 Printer接口 在方法执行前后 进行打点 ,然后计算出 任务的 执行时间,若如果超过阈值,就认为发生卡顿。就触发子线程去dump当前主线程执行堆栈和 耗时 ,上报到观测平台。

缺陷一

问题挑战1: 堆栈采集不准

因为 Printer接口 方案,对卡顿的判定是需要等每个任务执行完,才去计算耗时的,而当任务执行完,再来采集堆栈, 主线程可能已经开始执行下一个任务了,这个时候,采集到的堆栈,就已经不是 卡顿任务的堆栈了。

image.png

问题挑战2:非耗时任务函数采集

部分场景,堆栈定位到非耗时函数,A和B相对好使,抓取几率更大,但仍然抓到C。

image.png

问题挑战3: 无响应机制

当卡顿时间 超过5s ,就会触发安卓系统的 无响应机制 ,可能会 强制停止app, 导致我们 ,无法采集。

解决方案: 精准采集方案

这两种问题的原因, 就是在于 我们依赖了主线程,所以我们的解决方案是 单独建立一个计时器

每个任务开始 的时候,就会触发监测线程的 计时器计时 当计时时间超过16ms ,就会触发采集线程采集上报,不依赖任务执行为了 能保证准确 统计到 任务最终的 卡顿时间,计时器会继续计时,直到任务 真正结束。

image.png

缺陷二

问题挑战: 堆栈采集失效

public final class Looper {
    private Printer mLogging;
     * Control logging of messages as they are processed by this Looper.  If
     * enabled,a log message will be written to <var>printer</var>
     * at the beginning and ending of each message dispatch,,dentifying the
     * target Handler and message contents.
     * @param printer A Printer object that will receive log messages, ,
     * null to disable message logging.
     public void setMessageLogging(@Nullable Printer printer) {
        mLogging = printer;
}

不支持同时持有多个Printer实例,存在相互覆盖异常情况:

  1. 卡顿监测Printer被覆盖,导致监测方案失效
  2. 覆盖其他业务Printer,导致其他业务异常

第二个问题是方案失效,无法采集。方案失效的原因,是用来监测主线程的Printer接口, 它只能绑定一个 ,并且 任何业务都 能对他设置。

因此监测Printer有可能会被,后设置的业务方所覆盖掉,导致监测失效。 同时我们也可能覆盖,别的业务,导致他们异常。

解决方案: Top堆栈精简

所以这里会存在卡顿被多次统计的情况,因此在上报的时候还会对这部分堆栈做合并,同时我们也做了配置下发,上报的时候根据后台下发的top堆栈做精简。

用了Top堆栈精简方案,可以看到,之前两种case都能准确上报,也提升了百分之5到10的堆栈收集量。并且方案本身的性能损耗很少,CPU只有1%不到,基本没有影响。

第二个问题是方案失效,无法采集。方案失效的原因,是用来监测主线程的Printer接口,它只能绑定一个,并且任何业务都能对他设置因此监测Printer有可能会被,后设置的业务方所覆盖掉,导致监测失效。

同时我可能覆盖别的业务,导致异常。所以解决方案是在设置的前和后2个时机去处理

解决方案: Printer覆盖检测

image.png

设置前检查

在设置前,我们通过反射去检查printer引用,判断是否有业务在使用,如果有,我们会保留引用,后面通过printermanager中间层去转发,在设置后,如果我们被覆盖,因为引用丢失会被java进行垃圾回收,所以我们在 finalize方法监听到回收事件,然后再发起一次设置。

设置后检查

另外因为垃圾回收时间是不固定的, 所以我们还会依赖刚刚的计时器,在每次任务超时之后再进行一次检查,看它引用是否被修改,如果是再重设。

通过这2个手段我们就能解决覆盖失效的问题,提升了监测的稳定性。

方案二: Choreographer帧率测量

了解RenderThread之前,我们不得不提到Choreographer的渲染流程。

Choreographer渲染流程

  1. 主线程处于 Sleep 状态,等待 Vsync 信号
  2. Vsync 信号到来,主线程被唤醒,Choreographer 回调 FrameDisplayEventReceiver.onVsync 开始一帧的绘制
  3. 处理 App 这一帧的 Input 事件(如果有的话)
  4. 处理 App 这一帧的 Animation 事件(如果有的话)
  5. 处理 App 这一帧的 Traversal 事件(如果有的话)
  6. 主线程与渲染线程同步渲染数据,同步结束后,主线程结束一帧的绘制,可以继续处理下一个 Message(如果有的话,IdleHandler 如果不为空,这时候也会触发处理),或者进入 Sleep 状态等待下一个 Vsync。
  7. 渲染线程首先需要从 BufferQueue 里面取一个 Buffer(dequeueBuffer) ,进行数据处理之后,调用 OpenGL 相关的函数,真正地进行渲染操作,然后将渲染好的 Buffer 还给 BufferQueue (queueBuffer) ,SurfaceFlinger 在 Vsync-SF 到了之后,将所有准备好的 Buffer 取出进行合成,如下图:

image.png

图片来源: 高爷: Android Systrace 基础知识 - MainThread 和 RenderThread 解读

Choreographer测量流程

利用系统 Choreographer 模块,向该模块注册一个 FrameCallback 监听对象,同时通过另外一条线程循环记录主线程堆栈信息,并在每次 Vsync 事件 doFrame 通知回来时,循环注册该监听对象,间接统计两次 Vsync 事件的时间间隔,当超出阈值时,取出记录的堆栈进行分析上报。

简单代码实现如下:

Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
    @Override    
    public void doFrame(long frameTimeNanos) {
        if(frameTimeNanos - mLastFrameNanos > 100) {
        mLastFrameNanos = frameTimeNanos;
        Choreographer.getInstance().postFrameCallback(this);
});

可以较方便的捕捉到卡顿的堆栈,但其最大的不足在于,无法获取到各个函数的执行耗时,对于稍微复杂一点的堆栈,很难找出可能耗时的函数,也就很难找到卡顿的原因。

另外,通过其他线程循环获取主线程的堆栈,如果稍微处理不及时,很容易导致获取的堆栈有所偏移,不够准确,加上没有耗时信息,卡顿也就不好定位。所以我们需要借助字节码插桩技术来解决这一个痛点问题。

@Override
public void dispatchBegin(long beginNs, long cpuBeginMs, long token) {
    super.dispatchBegin(beginNs, cpuBeginMs, token);
    // 记录当前方法执行的sIndex,单链表
    indexRecord = AppMethodBeat.getInstance().maskIndex("EvilMethodTracer#dispatchBegin");
@Override
public void dispatchEnd(long beginNs, long cpuBeginMs, long endNs, long cpuEndMs, long token, boolean isVsyncFrame) {
    super.dispatchEnd(beginNs, cpuBeginMs, endNs, cpuEndMs, token, isVsyncFrame);
    long start = config.isDevEnv() ? System.currentTimeMillis() : 0;
    long dispatchCost = (endNs - beginNs) / Constants.TIME_MILLIS_TO_NANO;
    try {
       // 超出时间,解析上传
        if (dispatchCost >= evilThresholdMs) {
            long[] data = AppMethodBeat.getInstance().copyData(indexRecord);
            long[] queueCosts = new long[3];
            System.arraycopy(queueTypeCosts, 0, queueCosts, 0, 3);
            String scene = AppActiveMatrixDelegate.INSTANCE.getVisibleScene();
            MatrixHandlerThread.getDefaultHandler().post(new AnalyseTask(isForeground(), scene, data, queueCosts, cpuEndMs - cpuBeginMs, dispatchCost, endNs / Constants.TIME_MILLIS_TO_NANO));
    } finally {
        indexRecord.release();
        if (config.isDevEnv()) {
            String usage = Utils.calculateCpuUsage(cpuEndMs - cpuBeginMs, dispatchCost);
            MatrixLog.v(TAG, "[dispatchEnd] token:%s cost:%sms cpu:%sms usage:%s innerCost:%s",
                    token, dispatchCost, cpuEndMs - cpuBeginMs, usage, System.currentTimeMillis() - start);
}

方案三: 字节码插桩

image.png

采用方法插桩监听每个方法的耗时,通过设置Looper的Printer,来监听监听每个Message的耗时,超过阀值,触发方法上报。

通过代理编译期间的任务 transformClassesWithDexTask,将全局 class 文件作为输入,利用 ASM 工具,高效地对所有 class 文件进行扫描及插桩。

修改字节码的方式,在编译期修改所有 class 文件中的函数字节码,对所有函数前后进行打点插桩。不过,有四点需要注意一下:

  1. 选择在该编译任务执行时插桩,是因为 proguard 操作是在该任务之前就完成的,意味着插桩时的 class 文件已经被混淆过的。
  2. 选择 proguard 之后去插桩,是因为如果提前插桩会造成部分方法不符合内联规则,没法在 proguard 时进行优化,最终导致程序方法数无法减少,从而引发方法数过大问题。
  3. 为了减少插桩量及性能损耗,通过遍历 class 方法指令集,判断扫描的函数是否只含有 PUT/READ FIELD 等简单的指令,来过滤一些默认或匿名构造函数,以及 get/set 等简单不耗时函数。
  4. 为了方便及高效记录函数执行过程,我们为每个插桩的函数分配一个独立 ID,在插桩过程中,记录插桩的函数签名及分配的 ID,在插桩完成后输出一份 mapping,作为数据上报后的解析支持。
2023 Android 模块化完整方案实现
本套模块化方案实现来源于公司的业务需求,因为公司业务太多,代码越来越臃肿,越来越难维护,为了提升开发效率,减低代码的维护成本,所以采取了模块化开发方案。 既然是模块化开发,必然要考虑到各个module的开发,调试,迭代拓展及维护,module之间不仅需要做到业务代码隔离,还需要方便的跳转(路由引导模块),方便的传递数据(包括大容量的数据),能够独立编译调。最后,各个module,完成之后,一起打包到主APP即可。
Android卡顿优化 | 自动化卡顿检测方案与优化(AndroidPerformanceMonitor / BlockCanary)
Android卡顿优化 | 自动化卡顿检测方案与优化(AndroidPerformanceMonitor / BlockCanary)
Android Bintray、JCenter 替代方案MavenCentral(发布jar,aar到Maven中央仓库)
Android Bintray、JCenter 替代方案MavenCentral(发布jar,aar到Maven中央仓库)
【Android 安全】DEX 加密 ( Application 替换 | 加密不侵入原则 | 替换 ActivityThread 的 mInitialApplication 成员 )
【Android 安全】DEX 加密 ( Application 替换 | 加密不侵入原则 | 替换 ActivityThread 的 mInitialApplication 成员 )