Android 性能优化(二)流畅度优化

1,862 阅读16分钟

文章内容根据《性能优化入门与实战》总结而来

为什么要做流畅度优化

流畅度是与用户体验直接关联的指标,当App的流畅度不够高时,会直接导致用户放弃使用该App,从而降低产品的用户留存率。根据第三方平台公布的数据,诺德斯特龙网站上,用户操作的响应时间每增加0.5s,产品的转化率下降11%;沃尔玛网站的响应时间每减少0.1s,其公司的收入增加1%。因此我们可以说:App的流畅度与业务的成功有直接关系。

判断 APP 是否流畅的指标主要是App对用户交互的响应是否及时,比如

  • 打开页面速度是否够快
  • 滑动界面是否连贯
  • 播放动画是否连续

如何线上监控流畅度

流畅度监控一般需要监控如下指标:

  • FPS,即帧率,代表页面的刷新流畅情况。
  • 掉帧数,代表页面的不连贯情况。
  • 卡顿率,代表主线程消息执行的超时情况。

Android绘制原理

VSync 是什么

VSync(Vertical Synchronization)即垂直同步,用于CPU、GPU和显示器的同步,主要有以下作用:

  1. 通知切换buffer,显示前一个buffer的数据到屏幕上。
  2. 通知CPU进行下一帧数据的计算。

我们在App中发起的布局绘制操作,会由CPU在收到VSync信号后进行处理,以生成GPU可以执行的绘制命令。处理完成后同步给GPU进行绘制。等下一个VSync信号来临时,显示器从缓冲池中取一个buffer数据进行显示,同时CPU开始进行下一帧绘制内容的计算。

Android 图形系统为什么使用三缓冲,而不是双缓冲

Android 缓冲的使用详情可以见Android VSYNC与图形系统中的撕裂、双缓冲、三缓冲浅析 - 简书

三缓冲和双缓冲的不同之处在于,此时CPU不需要等待GPU绘制完成,可以在收到第一个VSync信号时就开始处理下一帧的数据。这样的结果是:只会卡顿一次。GPU在绘制上一帧的数据时,CPU就可以开始下一帧数据的计算,这样可以让界面显示的流畅度大大提升。

FPS和掉帧数

评估App的流畅度时最常使用的指标之一是FPS,FPS的上限值根据手机的屏幕刷新率的不同有所不同。目前Android手机的主流屏幕刷新率为60Hz、90Hz和120 Hz这3档。

获取帧数和每帧耗时,一般有如下3种常见的方案。

  1. 通过Choreographer注册FrameCallback,在其中计算两帧之间的间隔时长。
  2. 在Looper任务执行结束后判断当前任务是否为绘制帧,是的话计算帧耗时。
  3. 通过FrameMetrics获取每帧耗时。

使用 Choreographerd 的方案

Choreographer是App界面渲染机制的核心组成,它帮我们封装了VSync信号的请求和分发操作,在我们发起界面绘制、播放动画、事件处理请求时,会先向Choreographer注册VSync信号的“监听”,同时请求VSync信号,这样在收到渲染子系统发出的VSync信号后,App的输入事件处理、动画播放和布局测量和渲染等就得以执行。

Choreographer使用比较简单,因此用的比较多。代码示例如下:

    Choreographer.getInstance().postFrameCallback(new Choreographer.
FrameCallback() {
         @Override
         public void doFrame(long frameTimeNanos) {
              //这里做帧数和耗时统计
         }
    });

Choreographer中5种类型的队列,分别是:

image.png

callback添加到队列后,接收到 VSync 信号的时候会执行。执行逻辑如下所示:

//frameworks/base/core/java/android/view/Choreographer.java
    void doFrame(long frameTimeNanos, int frame,
                 DisplayEventReceiver.VsyncEventData vsyncEventData) {
             //执行 CALLBACK_INPUT
             doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos,
                         frameIntervalNanos);
 
             //先后执行 CALLBACK_ANIMATION 和 CALLBACK_INSETS_ANIMATION
             doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos,
                         frameIntervalNanos);
             doCallbacks(Choreographer.CALLBACK_INSETS_ANIMATION, frameTimeNanos,
                        frameIntervalNanos);
 
             //执行 CALLBACK_TRAVERSAL
             doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos,
                         frameIntervalNanos);
 
             //执行 CALLBACK_COMMIT
             doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos,
                         frameIntervalNanos);
 
    }

通过Choreographer计算FPS和掉帧数的示例如下所示:

    private final Choreographer.FrameCallback myCallback = new Choreographer.
                                                               FrameCallback() {
         @Override
         public void doFrame(long frameTimeNanos) {
 
              if (lastFrameTimeNanos != 0) {
 
                  //上一帧的耗时
                   long frameCost = frameTimeNanos - lastFrameTimeNanos;
 
                  //转换为ms
                   final long jitter = frameCost/ 1000000;
 
                  //计算出掉帧数
                   final int dropFrameCount = (int) (jitter / frameIntervalMillis);
 
                  //帧数加一
                  frameCount ++;
                  //掉帧数加一
                  dropFrameCount += dropFrameCount;
                  //异常数据处理
                  float frameDuration = Math.max(jitter, frameIntervalMillis);
                  //累加总时长
                  frameTotalDurationMillis += frameDuration;
 
 
                  long now = System.currentTimeMillis();
                  if (now - lastFPSTime > 1000) {
                        float fps = Math.min(refreshRate, 1000.f * frameCount /
                                   frameTotalDurationMillis);
                        lastFPSTime = now;
                  }
              }
 
              lastFrameTimeNanos = frameTimeNanos;
 
              //持续注册
              Choreographer.getInstance().postFrameCallback(frameCallback);
          }
      };

上面的代码中做了如下几件事:

  1. 通过这一帧的开始时间减去上一帧的开始时间得到上一帧的耗时frameCost。
  2. 用上一帧的耗时除以理论上每帧的耗时,得到上一帧的掉帧数。
  3. 累加帧数和掉帧数。
  4. 每帧耗时的异常数据处理,每帧耗时应不低于理论帧间隔时间。
  5. 累加总时长。
  6. 超出1s后,计算FPS。

其中 frameIntervalMillis 是理论帧间隔时间即VSync信号的发送间隔,它与屏幕刷新率相关,不能固定为16.666ms。我们可以通过 android.view.Display的getRefreshRate获取当前设备的屏幕刷新率。代码示例如下:

WindowManager windowManager = (WindowManager) context.getSystemService(Context.
                               WINDOW_SERVICE);
Display display = windowManager.getDefaultDisplay();
float refreshRating = display.getRefreshRate();

需要注意的是,Choreographer是ThreadLocal(线程间不共享)的,不同的线程调用Choreographer.getInstance,其返回的值不同

使用Choreographer和Looper监控结合使用的方案

使用Choreographer#FrameCallback可以获取上一帧的耗时和FPS,但这样做的缺点是只能获取指标,在得知卡顿时上一帧已经过去,无法定位到卡顿原因。要获取掉帧的原因,我们可以将Choreographer和Looper的卡顿监控机制结合起来.

实现原理:在收到Choreographer的doFrame回调后,则认为当前正在执行绘制任务,如果此时Looper执行的消息耗时超过卡顿阈值,即认为出现了卡顿和掉帧。这时去抓栈的话就可以获取引起这一帧掉帧的原因。Matrix的卡顿监控里就有这个思路的实现(Matrix是微信开源的App性能管理框架,可以帮助监控和定位Android/iOS/macOS App的性能问题)

使用 FrameMetrics 的方案

FrameMetrics(android.view.FrameMetrics)是从Android 7.0开始提供的新API,它提供了每帧的详细耗时数据,其中也包括当前帧的总耗时数据。代码示例如下:

private void getRenderPerformance() {
//新建一个监听器
    Window.OnFrameMetricsAvailableListener frameMetricsAvailableListener = new
           Window.OnFrameMetricsAvailableListener() {
         @Override
         public void onFrameMetricsAvailable(Window window, FrameMetrics frameMetrics,
                  int dropCountSinceLastInvocation) {
            //获取这帧的总耗时,单位为ns
            long totalDurationInNanos = frameMetrics.getMetric(FrameMetrics.
                                        TOTAL_DURATION);
        }
    };
//将监听器添加到窗口上
getWindow().addOnFrameMetricsAvailableListener(frameMetricsAvailableListener,
                                               new Handler());
}

除了总耗时,FrameMetrics提供的耗时信息如下表所示:

image.png

滑动帧率

使用Choreographer Framecallback的方式计算FPS,会出现当没有绘制界面时FPS数值偏低的情况,无法真实反映用户使用体验。在实际监控中,我们更关注App在用户产生交互后的流畅性,比如滑动时是否卡顿。因此,我们需要在帧率的基础上,增加一个更加细致的指标:滑动帧率

布局滑动本质上是一种事件,要获取整个布局所有层级的事件信息,可以通过ViewTreeObserver实现。代码示例如下:

View decorView = activity.getWindow().getDecorView();
        if (decorView != null) {
              decorView.getViewTreeObserver().addOnScrollChangedListener(new
ViewTreeObserver.OnScrollChangedListener() {
                  @Override
                  public void onScrollChanged() {
                       //开始滑动后向Handler中提交一个延迟执行的任务
                       //下次滑动后移除
 
                      isScrolling = true;
 
                   mHandler.removeCallbacks(onScrollFinishRunnable);
                   mHandler.postDelayed(onScrollFinishRunnable, 100);
                }
            });
        }

由于OnScrollChangedListener的onScrollChanged没有状态,因此我们需要在onScrollChanged每次调用时向Handler中提交一个延时任务,再次滑动时移除。当延时任务执行时,说明滑动已经停止。这样我们可以获取是否滑动的状态,在获取每帧的时长时,可以根据当前是否滑动,判断这帧是否是滑动帧,累加后计算即可得到滑动帧率。

主线程卡顿监控

监控卡顿,等同于监控主线程是否存在长耗时消息,有如下几种常见的思路:

思路1:定时向主线程的消息队列中提交任务,并在子线程中延迟一定时间后检查这个任务是否被执行。该思路的优点是实现简单,缺点是获取的数据准确度有限,同时对性能也有不小的影响

思路2:计算主线程Looper执行每个任务的时长,若有任务耗时超过自定义的阈值即可认为发生了卡顿。该思路的优点是可以在任务执行完就立刻检测到其是否耗时过久,可以极大提升问题捕获成功率,在很多公司的App中都使用了这种思路。

监视Looper执行每个任务的耗时,有三种方式:

  1. Printer#println。
  2. Trace的traceBegin和traceEnd。
  3. Observer的messageDispatchStarting和messageDispatched。

Printer#println

通过 Looper.getMainLooper().setMessageLogging(myPrinter) 即可设置监听,从而获取消息执行的开始时间和结束时间。该方式实现简单,业内很多卡顿监控库都使用这种方法实现,比如Android Performance Monitor和Matrix。缺点是调用Printer的println时会增加额外的字符串拼接逻辑,带来一定的性能损耗。

Trace的traceBegin和traceEnd

Trace的traceBegin和traceEnd也可以用来统计消息耗时。可以在线下使用Systrace以图形化的方式查看某个任务的耗时情况,搜索的关键字是msg.target.getTraceName(msg) 的返回值,也就是Handler的getTraceName方法返回的内容。

不过这种方式的实现成本比Printer#println的要高不少,如果要在线上使用需要开启atrace,会带来一些性能损耗,所以使用得不多。

Observer的messageDispatchStarting和messageDispatched

通过设置Observer获取消息耗时的方式,性能损耗最少,可以获取明确的消息执行的开始时间和结束时间,也可以获取执行的消息信息。

但很可惜,这个接口在target API目标版本为31上被列入了hidden API,App无法直接使用,通过反射访问也会报错,因此对上层App来说基本不可用。

综合来看,实现主线程长耗时任务监控目前比较好的方式还是使用Looper.getMainLooper().setMessageLogging(myPrinter)

线程运行情况监控

可以通过以下数据判断主线程的调度情况。

  1. CPU time和Wall time。
  2. 主线程的优先级。
  3. 主线程被抢占的次数。

Wall time指客观过去的时间,CPU time指进程真正在CPU上执行的时间。一个进程的CPU time包括两部分:用户态时间(utime)和内核态时间(stime)。用户态时间指执行App代码的时间,而内核态时间指执行App调用的系统API花费的时间

要获取当前进程的CPU time,我们可以通过/proc/${pid}/stat实现

获取主线程的优先级和调度情况,可以通过/proc/pid/task/{pid}/task/{pid}/sched实现

emulator64_arm64: # cat /proc/26993/task/26993/sched
ndroid.settings (26993, #threads: 31)
-------------------------------------------------------------------
se.exec_start                                :      36594087.581945
se.vruntime                                  :        626925.913763
//总运行时间
se.sum_exec_runtime                          :          1892.188025
//总休眠时间
se.statistics.sum_sleep_runtime              :      25930068.572341
 
//累计等待运行的时间,即可运行状态的时间
se.statistics.wait_sum                       :          7306.111963
se.statistics.wait_count                     :                 8547
//累计等待 I/O 操作的时间
se.statistics.iowait_sum                     :            86.472741
se.statistics.iowait_count                   :                  272
 
//总切换次数
nr_switches                                  :                 8542
//主动切换次数,比如等待 I/O
nr_voluntary_switches                        :                 4551
//被动切换次数,比如被其他线程抢占 CPU
nr_involuntary_switches                      :                 3991
//…
policy                                       :                    0
//优先级
prio                                         :                  110

通过这些指标,我们就能判断出卡顿发生,究竟是否是因为App的主线程没有被CPU及时调度、发生过多I/O操作、被其他线程频繁抢占。如果是,就需要从CPU调度层面考虑优化方案。

如何线下分析流畅度

线下分析页面是否卡顿一般有如下几种方式。

  1. 使用开发者选项。
  2. 使用Android Studio Profiler。
  3. 使用Systrace。

使用开发者选项分析卡顿问题

GPU呈现模式分析

GPU呈现模式分析可以用来查看界面刷新频率和绘制过程各阶段的耗时。如下图所示:

image.png

GPU呈现模式条形图由不同颜色的分段组成,每个分段表示绘制过程中的一个阶段。绿色表示输入处理、动画和测量/布局,深蓝色表示绘制,浅蓝色表示同步和上传,红色表示发起绘制命令。GPU呈现模式条形图的颜色含义如下图所示:

image.png

查看呈现模式条形图时,主要关注下面几点:

  1. 是否存在整体过高问题、条形是否刷新频繁。当条形图的高度超过底部的红色横线时,我们就可以认为出现掉帧了。
  2. 掉帧时,如果绿色分段占比较大,可以从列表滚动事件处理、动画绘制、布局层级角度进行分析;
  3. 掉帧时,如果蓝色分段占比较大,可以对自定义View的onDraw、dispatchDraw等方法进行分析;
  4. 掉帧时,如果红色分段占比较大,可以从绘制命令是否过多、绘制内容是否过于复杂(比如大量的图片)的角度进行分析。

GPU过度绘制

GPU过度绘制可以用来查看布局层级是否合理。打开过度绘制检测开关后,会在App界面上增加绿色、蓝色、红色等的遮罩,我们可以根据遮罩的颜色判断布局层级是否过深

image.png

不同遮罩颜色的含义与是否需要优化见下图:

image.png

使用Android Studio Profiler分析卡顿问题

Profiler集成了Systrace和采样抓栈的功能,可以提供采样期间的如下信息:

  • 正在交互的页面(Activity/Fragment)名称。
  • CPU频率和繁忙程度。
  • 物理内存情况。
  • 不同线程的堆栈执行耗时情况。

使用Systrace分析卡顿

除了Android Studio Profiler,另外一个常用的卡顿分析工具是Systrace。相较于Profiler,Systrace的功能更为强大,可以提供如下信息:

  • CPU的繁忙程度及执行的任务的信息。
  • 用户进程和系统进程的执行信息。
  • 线程状态、线程的详细执行耗时。
  • 内存分配情况、帧率是否正常等。

Systrace 的详细介绍可以看Android Systrace -- 系列文章目录。不过由于随着 Google 宣布 Systrace 工具停更,推出 Perfetto 工具,因此更加推荐使用 Perfetto。Perfetto 的教程可以见 Android Perfetto 系列

如何优化流畅度

常见的卡顿原因如下:

  1. 绘制相关线程获得的CPU时间过少。
  2. 主线程消息队列里绘制无关任务(如I/O、Binder、锁)的耗时太多。
  3. 绘制任务耗时过久。

因此,我们可以从如下3个方面进行优化:

  1. 增加绘制相关线程的运行时间。
  2. 减少主线程非绘制任务耗时。
  3. 减少绘制任务耗时。

增加绘制相关线程的运行时间

Linux中目前使用的主流进程/线程调度方式是CFS(Completely Fair Scheduler,完全公平调度器),CFS在分配时间片时,会根据当前可运行的线程数及其优先级计算线程的权重,优先级越高权重越高。可以简单理解为:单个时间片等于当前线程的权重除以所有线程的权重之和,再乘调度周期。可以看到如果要增加绘制相关线程的运行时间,有两种方法:

  • 提升核心线程优先级。为主线程和RenderThread这两个绘制相关的线程设置高优先级,同时监控、拦截到业务代码或者系统代码中将这两个线程优先级调低的操作
  • 减少线程抢占。方法有,减少线程数,复用线程池;及时停止子线程。

减少主线程非绘制任务耗时

一般来说,主线程执行的非绘制任务的功能和耗时点如下图所示:

image.png

减少布局加载和解析耗时

常用的优化方法有:

  • LayoutInflater.Factory 和 Factory2
  • AsyncLayoutInflater异步加载布局,不能设置Factory,并且View中不能有依赖于主线程的操作
  • Java代码写布局,不足:不便于开发,可维护性差
  • x2c,在编译区间编译成Java代码;不足:部分属性Java不支持,失去了系统的兼容(AppCompat)
  • 减少view树层级;尽量宽而浅
  • merge标签,只能用于根view
  • PrecomputedText
  • 优化layout的开销。尽量不使用RelativeLayout或者基于weighted LinearLayout,它们layout的开销非常巨大。这里我推荐使用ConstraintLayout替代RelativeLayout或者weighted LinearLayout
  • Litho:异步布局
  • RenderThread与RenderScript

减少主线程文件的读写耗时

常见的文件的读写操作有,读取Assets文件、调用ContentResolver query或者insert、数据库读写等。

StrictMode(严格模式)是Android官方提供的不合理代码检测工具,可以帮助我们发现主线程执行的磁盘操作、网络请求等。使用起来也很简单,只需使用如下代码即可开启相关检测。

    StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
            .detectCustomSlowCalls()
            .detectDiskReads()      //检测主线程的磁盘读
            .detectDiskWrites()
            .detectNetwork()        //检测主线程的网络请求
            .penaltyDialog()        //检测到后怎么处理
            .penaltyLog()
            .penaltyFlashScreen()
            .build());

需要注意,StrictMode对性能损耗较大,所以只能在线下使用,并且StrictMode无法检测到C/C++ 代码中的文件操作。我们使用的Android File等API的文件操作,最终都会执行到Linux的read、write等文件操作API。因此要想在线上检测到所有文件读写操作,可以通过native hook拦截这些API。开源库Matrix和btrace中都实现了文件I/O相关API的拦截。

减少主线程阻塞等锁耗时

减少主线程的阻塞等锁耗时,主要可以通过如下两种方式实现。

  1. 合理使用锁。
  2. 掌握分析锁问题的工具

在编写加锁或者访问锁的代码时,需要注意如下3点:

  1. 减少主线程取锁情况。
  2. 减小锁范围。
  3. 使用合理的数据结构。

减少主线程Binder调用耗时

除了文件I/O和等锁,另一种典型的耗时任务就是Binder调用。Binder虽然高效,但毕竟采用的是跨进程通信的方式,每次Binder调用需要经过Client端的Java到Native、Native到驱动,然后在Service端从驱动到Native、Native到Java,整个流程的耗时不容小觑。

由于Android Framework中大量使用Binder实现功能,稍有不慎主线程中就会出现大量不必要的Binder调用。如下图所示,最终会产生Binder调用的常见功能:

image.png

要检测App有哪些代码频繁触发Binder调用,有如下两种工具可以选择:

  1. adb shell am trace-ipc。就是用于对App的Binder调用进行追踪记录
  2. Systrace/Perfetto。

减少主线程中其他不必要的操作

  • Service、BroadcastReceiver、ContentProvider生命周期方法里的耗时操作。
  • 调试日志引入的额外开销。
  • 多层嵌套重复抛出异常。
  • 监听文字输入、播放进度、滑动距离等频繁执行的函数。

减少绘制任务耗时

常见的绘制任务耗时点和改进方法,如下图所示:

image.png