卡顿监测 · 方案篇 · Android卡顿监测指导原则

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

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

一、引言

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

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

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

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

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

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

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

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

image.png

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

二、 卡顿定义

什么是卡顿?

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

什么是卡死?

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

卡顿原因

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中。

流程图 (1).jpg

洪荒时代(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 来进行渲染:

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

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

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

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

流程图 (2).jpg

什么是RenderNode?

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

什么是DisplayList?

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

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

为什么要用到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

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

硬件加速和软件绘制可以通过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。

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

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

什么是屏幕刷新频率?

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

什么是帧率?

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

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

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

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

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

getWindow().getDecorView().getViewTreeObserver().addOnDrawListener()
复制代码

什么是画面撕裂问题?

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

怎么解决画面撕裂问题?

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

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

什么是双缓冲?

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

图片来源于: 高爷

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

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

如何理解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缓冲区。

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

末法时代(5.0后): RenderThread

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

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

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

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

RenderThread阻塞

什么是RenderThread?

所谓的有可见内容的时候进行渲染的线程,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接口方案,对卡顿的判定是需要等每个任务执行完,才去计算耗时的,而当任务执行完,再来采集堆栈,主线程可能已经开始执行下一个任务了,这个时候,采集到的堆栈,就已经不是卡顿任务的堆栈了。

流程图 (3).jpg

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

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

image.png

问题挑战3: 无响应机制

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

解决方案: 精准采集方案

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

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

流程图 (4).jpg

缺陷二

问题挑战: 堆栈采集失效

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覆盖检测

流程图 (6).jpg

设置前检查

在设置前,我们通过反射去检查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 取出进行合成,如下图:

图片来源: 高爷: 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, 03);
            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);
        }
    }
}
复制代码

方案三: 字节码插桩

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

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

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

  1. 选择在该编译任务执行时插桩,是因为 proguard 操作是在该任务之前就完成的,意味着插桩时的 class 文件已经被混淆过的。

  2. 选择 proguard 之后去插桩,是因为如果提前插桩会造成部分方法不符合内联规则,没法在 proguard 时进行优化,最终导致程序方法数无法减少,从而引发方法数过大问题。

  1. 为了减少插桩量及性能损耗,通过遍历 class 方法指令集,判断扫描的函数是否只含有 PUT/READ FIELD 等简单的指令,来过滤一些默认或匿名构造函数,以及 get/set 等简单不耗时函数。
  1. 为了方便及高效记录函数执行过程,我们为每个插桩的函数分配一个独立 ID,在插桩过程中,记录插桩的函数签名及分配的 ID,在插桩完成后输出一份 mapping,作为数据上报后的解析支持。

六、 分析工具

工具对比

image.png

使用指南

  • 需要分析Native代码,选Simpleperf
  • 需要分析系统调用,选Systrace
  • 需要分析应用流程和耗时,选TraceView / 插桩之后的Systrace
  • 需要分析其他应用,选Nanoscope
  • 灰度环境分析Java代码,选Rhea

七、 卡顿指标

怎么用一个指标直观反映卡顿呢?

监测指标

image.png

流畅Smoothness计算

  1. 当Vsync信号到达时会会调用HAL层的HWComposer.vsync()函数,通知HWComposer引擎进行GPU渲染和显示,,然后发送Vsync信号给SurfaceFinger处理

  2. SurfaceFinger接收到Vsync信号后,调用SurfaceFlinger的addResyncSample函数用来处理 Vsync 信号,addResyncSample函数可以将App的渲染帧同步到显示器的刷新时间,以避免出现撕裂和卡顿等问题。

  3. EventThread通过onVsyncEvent函数将Vsync信号分发给需要使用Vsync信号的App,实现更平滑和流畅的渲染效果

  4. 当系统收到显示器的 Vsync 信号时,DisplayEventReceiver.onVsync() 函数会被调用,并将时间戳和Displayer物理属性传递给App

  5. App收到时间戳和Displayer物理属性后,FrameHandler可以帮助应用程序将渲染帧与 Vsync 信号同步,当 FrameHandler 接收到 Vsync 信号时,FrameHandler会调用 sendMessage() 方法,并将帧同步消息作为参数传递给该方法

image.png

流畅度存在哪些痛点问题?

流畅度存在两个痛点:

第一个痛点是: 数据量大,不方便统计,导致淹没真实卡顿Case。

第二个痛点是: 不能简单使用平均值和方差。因为不同设备的卡顿标准线不一样,我们应该按照设备等级划分标注线

流畅度指标是怎样衡量的?

  • 指标一: 流畅度评分

    • 压缩数据
    • 加权放大卡顿

  • 指标二: XPM评分(denzelzhou):离散程度

    • 帧绘制时长到标准绘制时长的距离(点到线的距离)
    • 距离标准绘制时长越远就越卡顿

八、监测SOP

监测范围

慢函数监测

技术需求: 通过外部配置阈值记录Android慢函数,如果超过阈值,那么将慢函数方法名和耗时间信息记录在本地JSON文件

思路:

  1. 首先定义一个类 SlowFunctionClassVisitor,继承自 ClassVisitor,用于实现对类的字节码的修改。
  2. 在 SlowFunctionClassVisitor 中重写 visitMethod 方法,用于实现对方法的字节码的修改。在 visitMethod 方法中,先调用父类的 visitMethod 方法,然后使用 ASM 的 API 生成新的方法字节码,并将原来的方法字节码替换为新的方法字节码。
  3. 在生成新的方法字节码时,使用 Label 和 JumpInsnNode 等 ASM 的 API 插入代码,实现对方法的耗时进行判断。如果方法耗时超过阈值,则记录慢函数信息,并将其写入本地 JSON 文件中。
  4. 为了实现记录慢函数信息和将其写入本地 JSON 文件中,使用了 org.json.JSONObject 和 java.io.FileWriter 等相关的 API。

假设我们要记录的慢函数是指执行时间超过100ms的函数,并且阈值的配置方式是通过一个配置文件,其中包含一个键值对 "slow_function_threshold=100",存放在assets目录下的config.json文件中。

首先,在Android Studio中创建一个新的Android项目,并将以下代码添加到build.gradle文件的dependencies块中:

dependencies {
    implementation 'org.ow2.asm:asm:9.2'
    implementation 'org.ow2.asm:asm-util:9.2'
}
复制代码

接下来,我们需要创建一个ASM的ClassVisitor,用于在方法调用前后插入代码。


public class SlowFunctionClassVisitor extends ClassVisitor {

    private String className;

    public SlowFunctionClassVisitor(ClassVisitor classVisitor) {
        super(Opcodes.ASM9, classVisitor);
    }

    @Override
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        className = name;
        super.visit(version, access, name, signature, superName, interfaces);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        MethodVisitor mv = cv.visitMethod(access, name, descriptor, signature, exceptions);
        return new SlowFunctionMethodVisitor(Opcodes.ASM9, mv, access, name, descriptor, className);
    }
    
    private static class SlowFunctionMethodVisitor extends AdviceAdapter {

        private final String methodName;
        private final String className;

        protected SlowFunctionMethodVisitor(int api, MethodVisitor mv, int access, String name, String descriptor, String className) {
            super(api, mv, access, name, descriptor);
            this.methodName = name;
            this.className = className;
        }

        private static final String startTimeFieldName = "_start_time";

        @Override
        protected void onMethodEnter() {
            //在方法进入时插入代码
            visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
            visitLdcInsn("enter " + methodName);
            visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
            visitVarInsn(Opcodes.ALOAD, 0);
            visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
            visitFieldInsn(Opcodes.PUTFIELD, className, startTimeFieldName, "J");
        }

        @Override
        protected void onMethodExit(int opcode) {
            //在方法退出时插入代码
            visitVarInsn(Opcodes.ALOAD, 0);
            visitFieldInsn(Opcodes.GETFIELD, className, startTimeFieldName, "J");
            visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
            visitInsn(Opcodes.LSUB);
            visitVarInsn(Opcodes.LSTORE, 2);

            Label l1 = new Label();
            visitVarInsn(Opcodes.LLOAD, 2);
        visitLdcInsn(100L);  // 100ms
        visitInsn(Opcodes.LCMP);
        visitJumpInsn(Opcodes.IFLE, l1);

        // 超过阈值,记录慢函数信息
        visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
        visitLdcInsn("exit " + methodName + " cost " + Long.toString(2L) + " ms");
        visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

        // 加载阈值
        visitLdcInsn("slow_function_threshold");
        visitMethodInsn(Opcodes.INVOKESTATIC, "android/content/res/Resources", "getSystem", "()Landroid/content/res/Resources;", false);
        visitMethodInsn(Opcodes.INVOKEVIRTUAL, "android/content/res/Resources", "getAssets", "()Landroid/content/res/AssetManager;", false);
        visitLdcInsn("config.json");
        visitMethodInsn(Opcodes.INVOKEVIRTUAL, "android/content/res/AssetManager", "open", "(Ljava/lang/String;)Ljava/io/InputStream;", false);
        visitTypeInsn(Opcodes.NEW, "org/json/JSONObject");
        visitInsn(Opcodes.DUP);
        visitTypeInsn(Opcodes.NEW, "java/io/InputStreamReader");
        visitInsn(Opcodes.DUP);
        visitVarInsn(Opcodes.ALOAD, 4);
        visitMethodInsn(Opcodes.INVOKESPECIAL, "java/io/InputStreamReader", "<init>", "(Ljava/io/InputStream;)V", false);
        visitMethodInsn(Opcodes.INVOKESPECIAL, "org/json/JSONObject", "<init>", "(Ljava/io/Reader;)V", false);
        visitLdcInsn("slow_function_threshold");
        visitMethodInsn(Opcodes.INVOKEVIRTUAL, "org/json/JSONObject", "optInt", "(Ljava/lang/String;)I", false);

        // 比较耗时和阈值
        visitVarInsn(Opcodes.LLOAD, 2);
        visitInsn(Opcodes.LCMP);
        visitVarInsn(Opcodes.ILOAD, 5);
        Label l2 = new Label();
        visitJumpInsn(Opcodes.IF_ICMPLE, l2);

        // 超过阈值,写入本地JSON文件
        visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
        visitLdcInsn("write to local json file: " + methodName + " cost " + Long.toString(2L) + " ms");
        visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
        visitTypeInsn(Opcodes.NEW, "org/json/JSONObject");
        visitInsn(Opcodes.DUP);
        visitVarInsn(Opcodes.ALOAD, 0);
        visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Class", "getName", "()Ljava/lang/String;", false);
        visitLdcInsn(methodName);
        visitVarInsn(Opcodes.LLOAD, 2);
        visitMethodInsn(Opcodes.INVOKESPECIAL, "org/json/JSONObject", "<init>", "()V", false);
        visitVarInsn(Opcodes.ASTORE, 6);
        visitTypeInsn(Opcodes.NEW, "java/io/FileWriter");
        visitInsn(Opcodes.DUP);
        visitLdcInsn("slow_function.json");
        visitMethodInsn(Opcodes.INVOKESPECIAL, "java/io/FileWriter", "<init>", "(Ljava/lang/String;)V", false);
        visitVarInsn(Opcodes.ASTORE, 7);
        // 将慢函数信息写入JSON文件
        visitVarInsn(Opcodes.ALOAD, 7);
        visitVarInsn(Opcodes.ALOAD, 6);
        visitMethodInsn(Opcodes.INVOKEVIRTUAL, "org/json/JSONObject", "toString", "()Ljava/lang/String;", false);
        visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/FileWriter", "write", "(Ljava/lang/String;)V", false);
        visitVarInsn(Opcodes.ALOAD, 7);
        visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/FileWriter", "flush", "()V", false);
        visitVarInsn(Opcodes.ALOAD, 7);
        visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/FileWriter", "close", "()V", false);
        visitLabel(l2);
    }
    super.visitInsn(opcode);
}
}

复制代码

以上是一段使用ASM技术实现记录Android慢函数的代码。它通过在方法的字节码中插入代码来判断方法耗时,如果超过阈值,就记录慢函数信息,并将其写入本地JSON文件中。在实现过程中,使用了ASM的相关API来生成字节码,并在方法执行过程中进行修改。

流畅性监测

Activity、Service、Receiver 组件生命周期的耗时和调用次数也是我们重点关注的性能问题。

例如Activity的onCreate不应该超过 1 秒,不然会影响用户看到页面的时间。

Service 和 Receiver 虽然是后台组件,不过它们的生命周期也是占用主线程的,也是我们需要关注的问题。 对于组件生命周期我们应该采用更严格的监测,可以全量上报各个组件各个生命周期的启动时间和启动次数。 一般的做法是,通过编译时插桩来做到组件的生命周期监测。

FPS监测

我们需要采集的卡顿数据有: 卡顿次数/交互次数比,帧绘制时间采样. 处理方式可以参考下面的内容:

  1. 定义一个FPS监测类,该类中包含FPS计算的逻辑:
public class FPSMonitor {
    private static final long ONE_SECOND = 1000000000L;
    private long lastTime = System.nanoTime();
    private int frameCount = 0;
    private int fps = 0;

    public void update() {
        long currentTime = System.nanoTime();
        frameCount++;
        if (currentTime - lastTime >= ONE_SECOND) {
            fps = frameCount;
            frameCount = 0;
            lastTime = currentTime;
        }
    }

    public int getFps() {
        return fps;
    }
}
复制代码
  1. 使用ASM字节码框架在编译期间对代码进行插桩,将FPS监测的逻辑插入到游戏或应用程序的主循环中:
public class GameLoop {
    private FPSMonitor fpsMonitor = new FPSMonitor();

    public void loop() {
        while (true) {
            long startTime = System.nanoTime();
            // 游戏或应用程序的主逻辑// ...// 在主循环中插入FPS监测逻辑
            fpsMonitor.update();
            int fps = fpsMonitor.getFps();
            System.out.println("FPS: " + fps);

            // 控制FPS为60long elapsedTime = System.nanoTime() - startTime;
            long sleepTime = (1000000000L / 60) - elapsedTime;
            if (sleepTime > 0) {
                try {
                    Thread.sleep(sleepTime / 1000000L);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
复制代码
  1. FPS监测逻辑被插入到了应用程序的主循环中,在每一帧结束时计算FPS,并将计算结果输出到控制台。我们使用插件可以将上述代码转换成字节码文件。转换代码如下:
public class FpsMonitorClassVisitor extends ClassVisitor {
    public FpsMonitorClassVisitor(ClassVisitor classVisitor) {
        super(Opcodes.ASM5, classVisitor);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
        if (name.equals("loop")) {
            mv = new FpsMonitorMethodVisitor(mv);
        }
        return mv;
    }

    private static class FpsMonitorMethodVisitor extends MethodVisitor {
        public FpsMonitorMethodVisitor(MethodVisitor mv) {
            super(Opcodes.ASM5, mv);
        }

        @Override
        public void visitCode() {
            super.visitCode();
            mv.visitVarInsn(Opcodes.ALOAD, 0);
            mv.visitFieldInsn(Opcodes.GETFIELD, "GameLoop", "fpsMonitor", "LFPSMonitor;");
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "FPSMonitor", "update", "()V", false);
            mv.visitVarInsn(Opcodes.ALOAD, 0);
            mv.visitFieldInsn(Opcodes.GETFIELD, "GameLoop", "fpsMonitor", "LFPSMonitor;");
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "FPSMonitor", "getFps", "()I", false);
            mv.visitFieldInsn(Opcodes.PUTSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
            mv.visitInsn(Opcodes.SWAP);
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(I)V", false);
        }
    }
}

复制代码

创建了一个 FpsMonitorClassVisitor 类来处理插桩逻辑,它继承自 ClassVisitor 类。

visitMethod 方法中,我们判断当前访问的方法是否为 loop 方法,如果是,则创建一个 FpsMonitorMethodVisitor 对象来处理该方法的插桩逻辑。

FpsMonitorMethodVisitor 中,我们将 FPS 监测逻辑插入到了游戏或应用程序的主循环中,在每一帧结束时计算 FPS,并将计算结果输出到控制台。

最后,我们将创建的 FpsMonitorClassVisitor 对象传递给 ASM 的 ClassReader,并通过 ClassWriter 来生成修改后的字节码。

Thread监测

由于文件IO开销、线程间的竞争或者锁可能会导致主线程空等,从而导致卡顿。我们可以借助BHook对线程进行监测,需要监测以下两点:

线程数量

需要监测线程数量的多少,以及创建线程的方式。例如有没有使用统一的线程池,这块可以通过 hook 线程的 nativeCreate() 函数,主要用于进行线程收敛,减少线程数量。

线程时间

监测线程的用户时间 utime、系统时间 stime 和优先级。主要是看哪些线程 utime+stime 时间比较多,占用了过多的 CPU。

上报时机

超过设置慢函数的阈值,开始收集慢方法函数和时间,开始上报。

业务降级

可通过参数配置平台配置开关,随时回归线上版本

注意事项

插桩方案问题 解决方案
包大小增加多少 过滤简单函数:i++,getter/setter支持黑名单配置包大小控制2%
只能监测到应用堆栈耗时,无法收集系统堆栈耗时 结合Looper方案,同时上报后台根据卡顿key来聚合分析
APP性能影响范围 非业务模块不要插桩,避免对稳定性造成影响

方案优化

为了提高自身性能,我们是不是可以同步线程方式进一步优化获取堆栈性能

getStackTrace

getStackTrace获取堆栈信息有如下两大业务痛点:

  1. 性能损耗

  2. 需要暂停主线程运行

为了解决上述业务痛点,我们可以采取ThreadDump和AsyncGetCallTrace两种解决方案。

方案 实现 特点
StackSampler 依赖SafePoint收集 公开API,稳定,有停顿
AsyncGetCallTrace SIGPROF定时器,Native层收集堆栈 非公开API,兼容性问题无停顿

StackSampler

在SafePoint处,JVM可以确保所有线程都停止执行,从而保证当前状态的一致性。利用SafePoint,我们可以实现无需挂起主线程的异步堆栈采样,从而避免了主线程被挂起的影响。

public class StackSampler {

    private static final int MAX_STACK_DEPTH = 32; // 最大堆栈深度
    private static final int MAX_STACK_SAMPLES = 100; // 最大缓存采样数
    private static final long SAMPLE_INTERVAL = 100L; // 采样间隔,单位:毫秒

    private final ConcurrentLinkedQueue<String> stackSamples; // 堆栈采样缓存
    private final AtomicBoolean profiling; // 采样标志位

    public StackSampler() {
        stackSamples = new ConcurrentLinkedQueue<>();
        profiling = new AtomicBoolean(false);
    }

    // SafePoint 采样方法
    private void sample() {
        if (profiling.compareAndSet(falsetrue)) {
            // 在 SafePoint 处异步采样堆栈信息
            new Thread(() -> {
                // 延迟一段时间,等待所有线程进入 SafePoint
                try {
                    Thread.sleep(SAMPLE_INTERVAL);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // 采样堆栈信息并添加到缓存中
                String stackTrace = getStackTrace();
                stackSamples.offer(stackTrace);
                // 重置采样标志位
                profiling.set(false);
            }).start();
        }
    }

    // 获取当前线程的堆栈信息
    private String getStackTrace() {
        StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
        StringBuilder sb = new StringBuilder();
        for (int i = 2; i < Math.min(stackTrace.length,,AX_STACK_DEPTH + 2); i++) {
            sb.append(stackTrace[i].toString()).append('\n');
        }
        return sb.toString();
    }

    // 输出所有采样结果
    public void dump() {
        int count = 0;
        String stackTrace;
        while ((stackTrace = stackSamples.poll()) != null && count < MAX_STACK_SAMPLES) {
            System.out.println(stackTrace);
            count++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        StackSampler sampler = new StackSampler();
        sampler.sample(); // 开始采样
        Thread.sleep(MAX_STACK_SAMPLES * SAMPLE_INTERVAL); // 等待采样完成
        sampler.dump(); // 输出采样结果
    }
}
复制代码

用SafePoint实现了异步堆栈采样,并在每次采样时延迟一段时间,等待所有线程进入SafePoint,以确保采样的堆栈信息是当前线程的真实状态。同时,采样的结果存储在一个线程安全的队列中,等待输出。当采样完成后,通过 dump() 方法输出所有的采样。

另外,为了避免频繁地采样堆栈信息导致性能问题,我们可以通过减少采样频率的方式进行优化。例如,可以通过将采样间隔从10毫秒调整为100毫秒,来减少采样的次数,从而降低对系统性能的影响。

另外,为了更高效地采样和处理堆栈信息,我们可以考虑采用缓存和批量处理的策略。例如,可以将采样的结果存储在一个缓存队列中,当队列达到一定大小时再进行批量处理,从而减少对队列的频繁访问和操作,提高程序的执行效率。

AsyncGetCallTrace

通过使用SIGPROF定时器,Native层收集堆栈方式,优化getStackTrace获取堆栈需要暂停主线程运行和相关性能问题

#include <signal.h>
#include <sys/time.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <execinfo.h>

#define SAMPLE_INTERVAL 100 // 采样间隔,单位:毫秒
#define MAX_STACK_DEPTH 32 // 最大堆栈深度
#define MAX_STACK_SAMPLES 100 // 最大缓存采样数

volatile sig_atomic_t profiling = 0; // 采样标志位

// SIGPROF 信号处理函数
void profiling_handler(int signum) {
    profiling = 1; // 标记需要采样堆栈信息
}

// 开始采样
void start_profiling() {
    struct sigaction sa;
    struct itimerval timer;

    // 注册 SIGPROF 信号处理函数
    sa.sa_handler = profiling_handler;
    sa.sa_flags = SA_RESTART;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGPROF, &sa, NULL);

    // 设置定时器
    timer.it_value.tv_sec = SAMPLE_INTERVAL / 1000;
    timer.it_value.tv_usec = (SAMPLE_INTERVAL % 1000) * 1000;
    timer.it_interval = timer.it_value;
    setitimer(ITIMER_PROF, &timer, NULL);
}

// 停止采样
void stop_profiling() {
    struct itimerval timer;

    // 关闭定时器
    timer.it_value.tv_sec = 0;
    timer.it_value.tv_usec = 0;
    timer.it_interval = timer.it_value;
    setitimer(ITIMER_PROF, &timer, NULL);
}

// 收集堆栈信息并输出到标准输出流
void dump_stack() {
    void *stack[MAX_STACK_DEPTH];
    int depth = backtrace(stack, MAX_STACK_DEPTH);
    if (depth > 0) {
        backtrace_symbols_fd(stack, depth, STDOUT_FILENO);
    }
}

int main() {
    start_profiling(); // 开始采样

    int sample_count = 0;
    while (sample_count < MAX_STACK_SAMPLES) {
        if (profiling) { // 如果需要采样堆栈信息
            profiling = 0; // 重置采样标志位
            dump_stack(); // 收集堆栈信息
            sample_count++; // 统计采样数
        }
    }

    stop_profiling(); // 停止采样
    return 0;
}
复制代码

start_profiling()stop_profiling() 函数用于开启和关闭 SIGPROF 定时器,定时器的时间间隔由 SAMPLE_INTERVAL 宏定义指定。

profiling_handler() 函数是 SIGPROF 信号的处理函数,每次接收到信号后,将 profiling 变量置为 1。dump_stack() 函数用于收集堆栈信息,通过 backtrace() 函数获取堆栈信息,并输出到标准输出流中。

main() 函数中,循环收集堆栈信息,直到采样数达到最大值为止,最大采样数由 MAX_STACK_SAMPLES 宏定义指定。

通过 SIGPROF 定时器和信号处理函数的方式,可以在 Native 层收集堆栈信息,避免了在 Java 层获取堆栈信息的开销和性能问题。不过需要注意的是,堆栈采样对应用程序的性能有一定影响,需要权衡好采样间隔和采样深度等参数

九、总结展望

当测试提出卡顿问题,测试会新建Bug单给责任人处理。导致卡顿的原因有很多,比如函数非常耗时、I/O 非常慢、线程或锁间竞争等。

随着移动端用户越来越注重产品体验,APM系统也逐渐成为互联网公司重要基础设施。

卡顿是衡量App性能的一个重要指标,建设卡顿APM监测平台是Android卡顿优化长效治理关键。

同时,通过建设卡顿APM监测平台,帮助业务找到卡顿原因也是架构组TL考核评测员工重点OKR指向。

为了解决卡顿标准不明确问题,小木箱今天和大家着重探讨了卡顿监测的方方面面。

如果小木箱的文章对你有所帮助,那么欢迎关注小木箱的公众号: 小木箱成长营。我是小木箱,我们下一篇见~

参考链接

猜你喜欢

转载自juejin.im/post/7214635327407308859