【Android】项目维护几年了,为啥还这么卡?

浅谈

前段时间有个客户问我,为啥你们项目都搞了好几年了,为啥线上还会经常反馈卡顿,呃呃呃。。

于是根据自己的理解以及网上大佬们的思路总结了一篇关于卡顿优化这块的文章。

卡顿问题是一个老生常谈的话题了,一个App的好坏,卡顿也许会占一半,它直接决定了用户的留存问题,各大app排行版上,那些知名度较高,但是排行较低的,可能就要思考思考是不是和你app本身有关系了。

卡顿一直是性能优化中相对重要的一个点,因为其涉及了UI绘制垃圾回收(GC)、线程调度以及BinderCPU,GPU方面等JVM以及FrameWork相关知识

如果能做好卡顿优化,那么也就间接证明你对Android FrameWork的理解之深。

下面两篇是笔者之前总结的两篇关于启动优化和内存优化的文章

Android 性能优化(一): 启动优化理论与实践

Android性能优化(二):内存优化你一定要了解的知识点

下面我们就来讲解下卡顿方面的知识。

什么是卡顿:

对用户来讲就是界面不流畅,滞顿。 场景如下

  • 1.视频加载慢,画面卡顿,卡死,黑屏
  • 2.声音卡顿,音画不同步。
  • 3.动画帧卡顿,交互响应慢
  • 4.滑动不跟手,列表自动更新,滚动不流畅
  • 5.网络响应慢,数据和画面展示慢、
  • 6.过渡动画生硬。
  • 7.界面不可交互,卡死,等等现象。

卡顿是如何发生的

卡顿产生的原因一般都比较复杂,如CPU内存大小,IO操作,锁操作,低效的算法等都会引起卡顿

站在开发的角度看: 通常我们讲,屏幕刷新率是60fps,需要在16ms内完成所有的工作才不会造成卡顿

为什么是16ms,不是17,18呢?

下面我们先来理清在UI绘制中的几个概念:

SurfaceFlinger:

SurfaceFlinger作用是接受多个来源的图形显示数据Surface,合成后发送到显示设备,比如我们的主界面中:可能会有statusBar,侧滑菜单,主界面,这些View都是独立Surface渲染和更新,最后提交给SF后,SF根据Zorder,透明度,大小,位置等参数,合成为一个数据buffer,传递HWComposer或者OpenGL处理,最终给显示器

在显示过程中使用到了bufferqueue,surfaceflinger作为consumer方,比如windowmanager管理的surface作为生产方产生页面,交由surfaceflinger进行合成。

VSYNC

Android系统每隔16ms发出VSYNC信号,触发对UI进行渲染,VSYNC是一种在PC上很早就有应用,可以理解为一种定时中断技术。

tearing 问题:

早期的 Android 是没有 vsync 机制的,CPU 和 GPU 的配合也比较混乱,这也造成著名的 tearing 问题,即 CPU/GPU 直接更新正在显示的屏幕 buffer 造成画面撕裂。 后续 Android 引入了双缓冲机制,但是 buffer 的切换也需要一个比较合适的时机,也就是屏幕扫描完上一帧后的时机,这也就是引入 vsync 的原因。

早先一般的屏幕刷新率是 60fps,所以每个 vsync 信号的间隔也是 16ms,不过随着技术的更迭以及厂商对于流畅性的追求,越来越多 90fps 和 120fps 的手机面世,相对应的间隔也就变成了 11ms 和 8ms。

VSYNC信号种类:

  • 1.屏幕产生的硬件VSYNC:硬件VSYNC是一种脉冲信号,起到开关和触发某种操作的作用。
  • 2.由SurfaceFlinger将其转成的软件VSYNC信号,经由Binder传递给Choreographer

Choreographer:

编舞者用于注册VSYNC信号并接收VSYNC信号回调,当内部接收到这个信号时最终会调用到doFrame进行帧的绘制操作

Choreographer在系统中流程

如何通过Choreographer计算掉帧情况:原理就是:

通过给Choreographer设置FrameCallback,在每次绘制前后看时间差是16.6ms的多少倍,即为前后掉帧率。

使用方式如下:

//Application.java
public void onCreate() {
     super.onCreate();
     //在Application中使用postFrameCallback
     Choreographer.getInstance().postFrameCallback(new FPSFrameCallback(System.nanoTime()));
}
public class FPSFrameCallback implements Choreographer.FrameCallback {

  private static final String TAG = "FPS_TEST";
  private long mLastFrameTimeNanos = 0;
  private long mFrameIntervalNanos;

  public FPSFrameCallback(long lastFrameTimeNanos) {
      mLastFrameTimeNanos = lastFrameTimeNanos;
      mFrameIntervalNanos = (long)(1000000000 / 60.0);
  }

  @Override
  public void doFrame(long frameTimeNanos) {

      //初始化时间
      if (mLastFrameTimeNanos == 0) {
          mLastFrameTimeNanos = frameTimeNanos;
      }
      final long jitterNanos = frameTimeNanos - mLastFrameTimeNanos;
      if (jitterNanos >= mFrameIntervalNanos) {
          final long skippedFrames = jitterNanos / mFrameIntervalNanos;
          if(skippedFrames>30){
            //丢帧30以上打印日志
              Log.i(TAG, "Skipped " + skippedFrames + " frames!  "
                      + "The application may be doing too much work on its main thread.");
          }
      }
      mLastFrameTimeNanos=frameTimeNanos;
      //注册下一帧回调
      Choreographer.getInstance().postFrameCallback(this);
  }
}

UI绘制全路径分析:

有了前面几个概念,这里我们让SurfaceFlinger结合View的绘制流程用一张图来表达整个绘制流程:

  • 生产者:APP方构建Surface的过程。
  • 消费者:SurfaceFlinger

UI绘制全路径分析卡顿原因:

接下来,我们逐个分析,看看都会有哪些原因可能造成卡顿:

1.渲染流程

  • 1.Vsync 调度:这个是起始点,但是调度的过程会经过线程切换以及一些委派的逻辑,有可能造成卡顿,但是一般可能性比较小,我们也基本无法介入;

  • 2.消息调度:主要是 doframe Message 的调度,这就是一个普通的 Handler 调度,如果这个调度被其他的 Message 阻塞产生了时延,会直接导致后续的所有流程不会被触发

  • 3.input 处理:input 是一次 Vsync 调度最先执行的逻辑,主要处理 input 事件。如果有大量的事件堆积或者在事件分发逻辑中加入大量耗时业务逻辑,会造成当前帧的时长被拉大,造成卡顿,可以尝试通过事件采样的方案,减少 event 的处理

  • 4.动画处理:主要是 animator 动画的更新,同理,动画数量过多,或者动画的更新中有比较耗时的逻辑,也会造成当前帧的渲染卡顿。对动画的降帧和降复杂度其实解决的就是这个问题;

  • 5.view 处理:主要是接下来的三大流程,过度绘制、频繁刷新、复杂的视图效果都是此处造成卡顿的主要原因。比如我们平时所说的降低页面层级,主要解决的就是这个问题;

  • 6.measure/layout/draw:view 渲染的三大流程,因为涉及到遍历和高频执行,所以这里涉及到的耗时问题均会被放大,比如我们会降不能在 draw 里面调用耗时函数,不能 new 对象等等;

  • 7.DisplayList 的更新:这里主要是 canvas 和 displaylist 的映射,一般不会存在卡顿问题,反而可能存在映射失败导致的显示问题;

  • 8.OpenGL 指令转换:这里主要是将 canvas 的命令转换为 OpenGL 的指令,一般不存在问题

  • 9.buffer 交换:这里主要指 OpenGL 指令集交换给 GPU,这个一般和指令的复杂度有关

  • 10.GPU 处理:顾名思义,这里是 GPU 对数据的处理,耗时主要和任务量和纹理复杂度有关。这也就是我们降低 GPU 负载有助于降低卡顿的原因;

  • 11.layer 合成:Android P 修改了 Layer 的计算方法 , 把这部分放到了 SurfaceFlinger 主线程去执行, 如果后台 Layer 过多, 就会导致 SurfaceFlinger 在执行 rebuildLayerStacks 的时候耗时 , 导致 SurfaceFlinger 主线程执行时间过长。 可以选择降低Surface层级来优化卡顿。

  • 12.光栅化/Display:这里暂时忽略,底层系统行为; Buffer 切换:主要是屏幕的显示,这里 buffer 的数量也会影响帧的整体延迟,不过是系统行为,不能干预。

2.系统负载

  • 内存:内存的吃紧会直接导致 GC 的增加甚至 ANR,是造成卡顿的一个不可忽视的因素;
  • CPU:CPU 对卡顿的影响主要在于线程调度慢、任务执行的慢和资源竞争,比如
    • 1.降频会直接导致应用卡顿

    • 2.后台活动进程太多导致系统繁忙,cpu \ io \ memory 等资源都会被占用, 这时候很容易出现卡顿问题 ,这种情况比较常见,可以使用dumpsys cpuinfo查看当前设备的cpu使用情况:

    • 3.主线程调度不到 , 处于 Runnable 状态,这种情况比较少见

    • 4.System 锁:system_server 的 AMS 锁和 WMS 锁 , 在系统异常的情况下 , 会变得非常严重 , 如下图所示 , 许多系统的关键任务都被阻塞 , 等待锁的释放 , 这时候如果有 App 发来的 Binder 请求带锁 , 那么也会进入等待状态 , 这时候 App 就会产生性能问题 ; 如果此时做 Window 动画 , 那么 system_server 的这些锁也会导致窗口动画卡顿

  • GPU:GPU 的影响见渲染流程,但是其实还会间接影响到功耗和发热;
  • 功耗/发热:功耗和发热一般是不分家的,高功耗会引起高发热,进而会引起系统保护,比如降频、热缓解等,间接的导致卡顿

如何监控卡顿

线下监控:

我们知道卡顿问题的原因错综复杂,但最终都可以反馈到CPU使用率上来

1.使用dumpsys cpuinfo命令

这个命令可以获取当时设备cpu使用情况,我们可以在线下通过重度使用应用来检测可能存在的卡顿点

A8S:/ $ dumpsys cpuinfo
Load: 1.12 / 1.12 / 1.09
CPU usage from 484321ms to 184247ms ago (2022-11-02 14:48:30.793 to 2022-11-02 1
4:53:30.866):
  2% 1053/scanserver: 0.2% user + 1.7% kernel
  0.6% 934/system_server: 0.4% user + 0.1% kernel / faults: 563 minor
  0.4% 564/signserver: 0% user + 0.4% kernel
  0.2% 256/ueventd: 0.1% user + 0% kernel / faults: 320 minor
  0.2% 474/surfaceflinger: 0.1% user + 0.1% kernel
  0.1% 576/[email protected]: 0.1% user + 0% kernel / faults
: 54 minor
  0.1% 286/logd: 0% user + 0% kernel / faults: 10 minor
  0.1% 2821/com.allinpay.appstore: 0.1% user + 0% kernel / faults: 1312 minor
  0.1% 447/[email protected]: 0% user + 0% kernel / faults: 11
75 minor
  0% 1855/com.smartpos.dataacqservice: 0% user + 0% kernel / faults: 755 minor
  0% 2875/com.allinpay.appstore:pushcore: 0% user + 0% kernel / faults: 744 mino
r
  0% 1191/com.android.systemui: 0% user + 0% kernel / faults: 70 minor
  0% 1774/com.android.nfc: 0% user + 0% kernel
  0% 172/kworker/1:2: 0% user + 0% kernel
  0% 145/irq/24-70900000: 0% user + 0% kernel
  0% 575/thermald: 0% user + 0% kernel / faults: 300 minor
...

2.CPU Profiler

这个工具是AS自带的CPU性能检测工具,可以在PC上实时查看我们CPU使用情况。 AS提供了四种Profiling Model配置:

  • 1.Sample Java Methods:在应用程序基于Java的代码执行过程中,频繁捕获应用程序的调用堆栈 获取有关应用程序基于Java的代码执行的时间和资源使用情况信息。
  • 2.Trace java methods:在运行时对应用程序进行检测,以在每个方法调用的开始和结束时记录时间戳。收集时间戳并进行比较以生成方法跟踪数据,包括时序信息和CPU使用率。

请注意与检测每种方法相关的开销会影响运行时性能,并可能影响性能分析数据。对于生命周期相对较短的方法,这一点甚至更为明显。此外,如果您的应用在短时间内执行大量方法,则探查器可能会很快超过其文件大小限制,并且可能无法记录任何进一步的跟踪数据。

  • 3.Sample C/C++ Functions:捕获应用程序本机线程的示例跟踪。要使用此配置,您必须将应用程序部署到运行Android 8.0(API级别26)或更高版本的设备。
  • 4.Trace System Calls:捕获细粒度的详细信息,使您可以检查应用程序与系统资源的交互方式 您可以检查线程状态的确切时间和持续时间,可视化CPU瓶颈在所有内核中的位置,并添加自定义跟踪事件进行分析。在对性能问题进行故障排除时,此类信息可能至关重要。要使用此配置,您必须将应用程序部署到运行Android 7.0(API级别24)或更高版本的设备。

使用方式

Debug.startMethodTracing("");
// 需要检测的代码片段
...
Debug.stopMethodTracing();

优点:**有比较全面的调用栈以及图像化方法时间显示,包含所有线程的情况

缺点:本身也会带来一点的性能开销,可能会带偏优化方向**

火焰图:可以显示当前应用的方法堆栈:

3.Systrace

Systrace在前面一篇分析启动优化的文章讲解过

这里我们简单来复习下:

Systrace用来记录当前应用的系统以及应用(使用Trace类打点)的各阶段耗时信息包括绘制信息以及CPU信息等

使用方式

Trace.beginSection("MyApp.onCreate_1");
alt(200);
Trace.endSection();

在命令行中:

python systrace.py -t 5 sched gfx view wm am app webview -a "com.chinaebipay.thirdcall" -o D:\trac1.html

记录的方法以及CPU中的耗时情况:

优点

  • 1.轻量级,开销小,CPU使用率可以直观反映
  • 2.右侧的Alerts能够根据我们应用的问题给出具体的建议,比如说,它会告诉我们App界面的绘制比较慢或者GC比较频繁。

4.StrictModel

StrictModel是Android提供的一种运行时检测机制,用来帮助开发者自动检测代码中不规范的地方。 主要和两部分相关: 1.线程相关 2.虚拟机相关

基础代码:

private void initStrictMode() {
    // 1、设置Debug标志位,仅仅在线下环境才使用StrictMode
    if (DEV_MODE) {
        // 2、设置线程策略
        StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
                .detectCustomSlowCalls() //API等级11,使用StrictMode.noteSlowCode
                .detectDiskReads()
                .detectDiskWrites()
                .detectNetwork() // or .detectAll() for all detectable problems
                .penaltyLog() //在Logcat 中打印违规异常信息
//              .penaltyDialog() //也可以直接跳出警报dialog
//              .penaltyDeath() //或者直接崩溃
                .build());
        // 3、设置虚拟机策略
        StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
                .detectLeakedSqlLiteObjects()
                // 给NewsItem对象的实例数量限制为1
                .setClassInstanceLimit(NewsItem.class, 1)
                .detectLeakedClosableObjects() //API等级11
                .penaltyLog()
                .build());
    }
}

线上监控:

线上需要自动化的卡顿检测方案来定位卡顿,它能记录卡顿发生时的场景。

自动化监控原理

采用拦截消息调度流程,在消息执行前埋点计时,当耗时超过阈值时,则认为是一次卡顿,会进行堆栈抓取和上报工作

首先,我们看下Looper用于执行消息循环的loop()方法,关键代码如下所示:

/**
 * Run the message queue in this thread. Be sure to call
 * {@link #quit()} to end the loop.
 */
public static void loop() {

    ...

    for (;;) {
        Message msg = queue.next(); // might block
        if (msg == null) {
            // No message indicates that the message queue is quitting.
            return;

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

        ...

        try {
             // 2 
             msg.target.dispatchMessage(msg);
            dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;
        } finally {
            if (traceTag != 0) {
                Trace.traceEnd(traceTag);
            }
        }

        ...

        if (logging != null) {
            // 3
            logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
        }

在Looper的loop()方法中,在其执行每一个消息(注释2处)的前后都由logging进行了一次打印输出。可以看到,在执行消息前是输出的">>>>> Dispatching to ",在执行消息后是输出的"<<<<< Finished to ",它们打印的日志是不一样的,我们就可以由此来判断消息执行的前后时间点。

具体的实现可以归纳为如下步骤

  • 1、首先,我们需要使用Looper.getMainLooper().setMessageLogging()去设置我们自己的Printer实现类去打印输出logging。这样,在每个message执行的之前和之后都会调用我们设置的这个Printer实现类。
  • 2、如果我们匹配到">>>>> Dispatching to "之后,我们就可以执行一行代码:也就是在指定的时间阈值之后,我们在子线程去执行一个任务,这个任务就是去获取当前主线程的堆栈信息以及当前的一些场景信息,比如:内存大小、电脑、网络状态等。
  • 3、如果在指定的阈值之内匹配到了"<<<<< Finished to ",那么说明message就被执行完成了,则表明此时没有产生我们认为的卡顿效果,那我们就可以将这个子线程任务取消掉。

这里我们使用blockcanary来做测试:

BlockCanary

APM是一个非侵入式的性能监控组件,可以通过通知的形式弹出卡顿信息。它的原理就是我们刚刚讲述到的卡顿监控的实现原理。 使用方式

  • 1.导入依赖
implementation 'com.github.markzhai:blockcanary-android:1.5.0'
  • Application的onCreate方法中开启卡顿监控
// 注意在主进程初始化调用
BlockCanary.install(this, new AppBlockCanaryContext()).start();
  • 3.继承BlockCanaryContext类去实现自己的监控配置上下文类
public class AppBlockCanaryContext extends BlockCanaryContext {
    ...
    ...
     /**
    * 指定判定为卡顿的阈值threshold (in millis),  
    * 你可以根据不同设备的性能去指定不同的阈值
    *
    * @return threshold in mills
    */
    public int provideBlockThreshold() {
        return 1000;
    }
    ....
}

  • 4.在Activity的onCreate方法中执行一个耗时操作
try {
    Thread.sleep(4000);
} catch (InterruptedException e) {
    e.printStackTrace();
}
  • 5.结果:

可以看到一个和LeakCanary一样效果的阻塞可视化堆栈图

那有了BlockCanary的方法耗时监控方式是不是就可以解百愁了呢,呵呵。有那么容易就好了

根据原理:我们拿到的是msg执行前后的时间和堆栈信息,如果msg中有几百上千个方法,就无法确认到底是哪个方法导致的耗时,也有可能是多个方法堆积导致

这就导致我们无法准确定位哪个方法是最耗时的。如图中:堆栈信息是T2的,而发生耗时的方法可能是T1到T2中任何一个方法甚至是堆积导致。

那如何优化这块

这里我们采用字节跳动给我们提供的一个方案:基于 Sliver trace 的卡顿监控体系

Sliver trace

整体流程图

主要包含两个方面:

  • 检测方案: 在监控卡顿时,首先需要打开 Sliver 的 trace 记录能力,Sliver 采样记录 trace 执行信息,对抓取到的堆栈进行 diff 聚合和缓存。

同时基于我们的需要设置相应的卡顿阈值,以 Message 的执行耗时为衡量。对主线程消息调度流程进行拦截,在消息开始分发执行时埋点,在消息执行结束时计算消息执行耗时,当消息执行耗时超过阈值,则认为产生了一次卡顿。

  • 堆栈聚合策略: 当卡顿发生时,我们需要为此次卡顿准备数据,这部分工作是在端上子线程中完成的,主要是 dump trace 到文件以及过滤聚合要上报的堆栈。分为以下几步:
    • 1.拿到缓存的主线程 trace 信息并 dump 到文件中。
    • 2.然后从文件中读取 trace 信息,按照数据格式,从最近的方法栈向上追溯,找到当前 Message 包含的全部 trace 信息,并将当前 Message 的完整 trace 写入到待上传的 trace 文件中,删除其余 trace 信息。
    • 3.遍历当前 Message trace,按照(Method 执行耗时 > Method 耗时阈值 & Method 耗时为该层堆栈中最耗时)为条件过滤出每一层函数调用堆栈的最长耗时函数,构成最后要上报的堆栈链路,这样特征堆栈中的每一步都是最耗时的,且最底层 Method 为最后的耗时大于阈值的 Method。

之后,将 trace 文件和堆栈一同上报,这样的特征堆栈提取策略保证了堆栈聚合的可靠性和准确性,保证了上报到平台后堆栈的正确合理聚合,同时提供了进一步分析问题的 trace 文件。

可以看到字节给的是一整套监控方案,和前面BlockCanary不同之处就在于,其是定时存储堆栈,缓存,然后使用diff去重的方式,并上传到服务器,可以最大限度的监控到可能发生比较耗时的方法。

开发中哪些习惯会影响卡顿的发生

1.布局太乱,层级太深。

  • 1.1:通过减少冗余或者嵌套布局来降低视图层次结构。比如使用约束布局代替线性布局和相对布局。
  • 1.2:用 ViewStub 替代在启动过程中不需要显示的 UI 控件。
  • 1.3:使用自定义 View 替代复杂的 View 叠加。

2.主线程耗时操作

  • 2.1:主线程中不要直接操作数据库,数据库的操作应该放在数据库线程中完成。
  • 2.2:sharepreference尽量使用apply,少使用commit,可以使用MMKV框架来代替sharepreference。
  • 2.3:网络请求回来的数据解析尽量放在子线程中,不要在主线程中进行复制的数据解析操作。
  • 2.4:不要在activity的onResume和onCreate中进行耗时操作,比如大量的计算等。
  • 2.5:不要在 draw 里面调用耗时函数,不能 new 对象

3.过度绘制

过度绘制是同一个像素点上被多次绘制,减少过度绘制一般减少布局背景叠加等方式,如下图所示右边是过度绘制的图片。

4.列表

RecyclerView使用优化,使用DiffUtil和notifyItemDataSetChanged进行局部更新等。

5.对象分配和回收优化

自从Android引入 ART 并且在Android 5.0上成为默认的运行时之后,对象分配和垃圾回收(GC)造成的卡顿已经显著降低了,但是由于对象分配和GC有额外的开销,它依然又可能使线程负载过重。 在一个调用不频繁的地方(比如按钮点击)分配对象是没有问题的,但如果在在一个被频繁调用的紧密的循环里,就需要避免对象分配来降低GC的压力。

减少小对象的频繁分配和回收操作。

好了,关于卡顿优化的问题就讲到这里,下篇文章会对卡顿中的ANR情况的处理,这里做个铺垫。

参考

Android卡顿检测及优化

一文读懂直播卡顿优化那些事儿

“终于懂了” 系列:Android屏幕刷新机制—VSync、Choreographer 全面理解!

深入探索Android卡顿优化(上)

西瓜卡顿 & ANR 优化治理及监控体系建设

作者:小余的自习室
链接:https://juejin.cn/post/7161757546875715615

你可能感兴趣的:(【Android】项目维护几年了,为啥还这么卡?)