Android性能测评与优化-流畅度测评

96
sunhapper
1.6 2019.03.12 11:48* 字数 3077

系统级别的流畅度优化

流畅度应该是终端用户感知最明显的性能指标了,提升流畅度是提升用户体验性价比最高的方式之一,我们先来看看在系统层面上Google为了优化流畅度做了哪些努力

Vsync(垂直同步)

垂直同步是一个游戏中很常见的概念,它的出现是为了解决如下图的画面撕裂的问题

画面撕裂

究其原因是屏幕的刷新并不是瞬时完成的,而GPU产生一帧新画面的速度和屏幕刷新速度不同步,当GPU速度又大于显示器的刷新速度,在显示器从上到下扫描显示的过程中图像缓冲就被更新了,显示器并不知道这个变化还是继续扫描,就产生了画面撕裂

image.png

屏幕刷新过程

android 4.1的黄油计划引入垂直同步之后,只有在接收到Vsync信号,系统才会让CPU/GPU开始下一帧的渲染工作,即每个屏幕刷新周期之间最多只会产生一帧画面,以此避免画面撕裂

一旦收到VSync信号,立刻就开始执行下一帧的绘制工作。这样也可以大大降低Jank出现的概率。只需要保证渲染一帧画面的时间在1/60s(16ms)就行了


image.png
image.png

Triple Buffer

先看看双缓冲的模型

image

两个缓存区分别为 Back Buffer 和 Frame Buffer。GPU 向 Back Buffer 中写数据,屏幕从 Frame Buffer 中读数据。VSync 信号负责调度从 Back Buffer 到 Frame Buffer 的复制操作,可认为该复制操作在瞬间完成(只是交换了内存地址)。

如果所有渲染操作都在16ms之内完成,双重缓冲可以很好的工作,但是渲染耗时超过16ms呢

image

第一个B画面的渲染超过了16ms,因为此时B画面占据了Back Buffer,所以当接收到下一帧的VSync信号时系统没有开始渲染工作,导致jank的发生

而三重缓冲增加了一个Back Buffer

image
image

在接收到VSync信号,B画面还在渲染,因为CPU已经空闲了,而且有另一块缓冲区,所以同时开始了下一帧的渲染工作

三重缓冲可以更充分的利用CPU/GPU提升画面显示的流畅度

  • 为什么不继续增加缓冲区来提升流畅度呢?

硬件加速

硬件加速就是依赖GPU实现图形绘制加速。

gpu&cpu

可以看出GPU的ALU(算术逻辑单元)比CPU多的多,而图形处理和栅格化操作实际就是大量的数学计算,所以用GPU去渲染图形比CPU快的多

android 3.0引入了硬件加速,android4.0以后默认开启了硬件加速

当启动硬件加速后,Android 使用 “DisplayList” 组件进行绘制而非直接使用 CPU 绘制每一帧。DisplayList 是一系列绘制操作的记录,抽象为 RenderNode 类。
这样间接的进行绘制操作的优点很多:

  • DisplayList 创建时并不是真正的绘制,只是记录了绘制的操作,所以对DisplayList的修改开销比较小。
  • 特定的View属性变化(如 translation, scale 等)只是修改了View对应的DisplayList的属性,而不需要重新生成新的DisplayList。
  • 当知晓了所有绘制操作后,可以针对其进行优化:例如,所有的文本可以一起进行绘制一次。
  • 可以将对 DisplayList 的处理转移至另一个线程(非 UI 线程)。

RenderThread

RenderThread是Android 5.0引入的功能。
渲染工作的真正执行者是 GPU,而 GPU是不知道什么是动画的:执行动画的唯一途径便是将每一帧的不同绘制操作分发给 GPU,但该逻辑本身不能在 GPU 上执行。
而如果在 UI 线程执行该操作,任意的重操作都将阻塞新的绘制指令及时分发,动画就很容易出现延迟和卡顿。
添加一个RenderThread专门用来处理渲染的相关操作,UI线程只管计算生成一个DisplayList,剩下的渲染相关的事情就交给RenderThread,这样减轻了UI线程的负担,也提升了动画的流畅度

image.png

  • Android 6.0不同场景的软/硬件绘制分析
渲染场景 纯软件绘制 硬件加速 加速效果分析
页面初始化 绘制所有View 创建所有DisplayList GPU分担了复杂计算任务
在一个复杂页面调用背景透明TextView的setText(),且调用后其尺寸位置不变 重绘脏区所有View TextView及每一级父View重建DisplayList 重叠的兄弟节点不需CPU重绘,GPU会自行处理
TextView逐帧播放Alpha / Translation / Scale动画 每帧都要重绘脏区所有View 除第一帧同场景2,之后每帧只更新TextView对应RenderNode的属性 刷新一帧性能极大提高,动画流畅度提高
修改TextView透明度 重绘脏区所有View 直接调用RenderNode.setAlpha()更新 加速前需全页面遍历,并重绘很多View;加速后只触发DecorView.updateDisplayListIfDirty,不再往下遍历,CPU执行时间可忽略不计

小结

系统层面对于UI流畅度的优化措施

  • VSync(解决画面撕裂)
  • 三重缓冲(提升CPU/GPU利用率)
  • 硬件加速(使用GPU加速画面渲染)
  • RenderThread(减轻UI线程负担)

收集流畅度相关信息

google为了画面的流畅度可谓是用心良苦,作为一个有追求的开发者,我们当然也要朝着如丝般顺滑努力,首先先从数据收集开始

开启GPU呈现模式分析

image.png

这个就是传说中的玄学曲线了,可以通过它可视化的直观掌握当前界面是否流畅
绿线是16ms的分界线,每一个竖条代表渲染一帧的耗时,要保证流畅理论上需要每一条都在绿线之下

image.png
image.png

有几个关键点需要注意一下

  • 表中的颜色跟实际的真机颜色有一些差别
  • 虽然叫GPU呈现模式分析,但是表中的所有阶段都发生在CPU中

优点:

  • 实时
  • 直观

缺点:

  • 无法量化

gfxinfo

android 6.0以上设备使用adb shell dumpsys gfxinfo <PACKAGE_NAME>可以获取到Aggregate frame stats

Stats since: 752958278148ns
Total frames rendered: 82189
Janky frames: 35335 (42.99%)
90th percentile: 34ms
95th percentile: 42ms
99th percentile: 69ms
Number Missed Vsync: 4706 //垂直同步失败
Number High input latency: 142  //因为处理输入耗时
Number Slow UI thread: 17270   //UI线程任务过重造成的超时
Number Slow bitmap uploads: 1542 //加载bitmap导致的超时
Number Slow draw: 23342 //绘制太慢导致的超时

使用adb shell dumpsys gfxinfo <PACKAGE_NAME> reset可以重置数据,结束进程不会重置Aggregate frame stats

使用adb shell dumpsys gfxinfo <PACKAGE_NAME> framestats 可以获取上120帧的详细耗时,这个数据跟GPU呈现模式的条形图是对应的

image.png

数据的单位是纳秒,通过统计计算可以获得每一帧的各阶段耗时
具体每一列数据代表什么可以看下面的链接
Framestats data format

优点:

  • 数据详细
  • 有整体的统计

缺点:

  • 不是实时数据
  • 只有120帧
  • framestats数据需要进一步处理

OnFrameMetricsAvailableListener

从 7.0(API 24)开始,安卓 SDK 新增 OnFrameMetricsAvailableListener 接口用于提供帧绘制各阶段的耗时,数据源与 GPU Profile 相同。

    public void startFrameMetrics(View view) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            final String activityName = getClass().getSimpleName();
            listener = new Window.OnFrameMetricsAvailableListener() {

                private int allFrames = 0;
                private int jankyFrames = 0;

                @Override
                public void onFrameMetricsAvailable(Window window, FrameMetrics frameMetrics,
                        int dropCountSinceLastInvocation) {
                    FrameMetrics frameMetricsCopy = new FrameMetrics(frameMetrics);
                    allFrames++;
                    float totalDurationMs = (float) (0.000001 * frameMetricsCopy.getMetric(
                            FrameMetrics.TOTAL_DURATION));
                    if (totalDurationMs > 17) {
                        jankyFrames++;
                        String msg = String.format("Janky frame detected on %s with total duration: %.2fms\n",
                                activityName, totalDurationMs);
                        float layoutMeasureDurationMs = (float) (0.000001 * frameMetricsCopy.getMetric(
                                FrameMetrics.LAYOUT_MEASURE_DURATION));
                        float drawDurationMs = (float) (0.000001 * frameMetricsCopy.getMetric(
                                FrameMetrics.DRAW_DURATION));
                        float gpuCommandMs = (float) (0.000001 * frameMetricsCopy.getMetric(
                                FrameMetrics.COMMAND_ISSUE_DURATION));
                        float othersMs = totalDurationMs - layoutMeasureDurationMs - drawDurationMs - gpuCommandMs;
                        float jankyPercent = (float) jankyFrames / allFrames * 100;
                        msg += String.format("Layout/measure: %.2fms, draw:%.2fms, gpuCommand:%.2fms others:%.2fms\n",
                                layoutMeasureDurationMs, drawDurationMs, gpuCommandMs, othersMs);
                        msg += "Janky frames: " + jankyFrames + "/" + allFrames + "(" + jankyPercent + "%)"
                                + dropCountSinceLastInvocation;
                        Log.e("FrameMetrics", msg);
                    }
                }
            };
            getWindow().addOnFrameMetricsAvailableListener(listener, new Handler());

        } else {
            Log.w("FrameMetrics", "FrameMetrics can work only with Android SDK 24 (Nougat) and higher");
        }
    }

FrameMetrics中包含了渲染一帧各个阶段的耗时数据


image.png

优点:

  • 实时
  • 数据全面
  • 直接给出了每个阶段的耗时,不用再算一遍了

缺点:

  • 只支持7.0及以上系统

Choreographer.FrameCallback

这种检测流畅度的方法起源于FaceBook的一次关于UI流畅度的技术分享The Road to 60FPS

Choreographer是一个接收VSync信号并分发的组件,Choreographer 收到通知依次处理 Input、Animation、Draw,这三个过程都是通过 FrameCallback 回调的方式完成的。

通过Choreographer.FrameCallback可以获取到VSync信号开始被处理的时间戳,减去上一个时间戳,可以近似为上一帧的渲染耗时(只计算了UI线程的耗时,计算不到渲染线程和GPU耗时)

    public void postFrameCallback(View view) {
        lastTime = System.nanoTime();
        Choreographer.getInstance().postFrameCallback(this);
    }

    @Override
    public void doFrame(long frameTimeNanos) {
        //每个FrameCallback都只会回调一次,所以需要在回调中注册下一帧VSync信号的回调
        Choreographer.getInstance().postFrameCallback(this);
        long jitterNanos = frameTimeNanos - lastTime;
        if (jitterNanos > FRAME_INTERVAL_NANOS) {
        Log.i(TAG,
                    "doFrame: lastTime:" + lastTime + "   frameTimeNanos:" + frameTimeNanos
                            + "  frame:" + jitterNanos);
        lastTime = frameTimeNanos;

    }

Choreographer.FrameCallback和之前的gfxinfo的数据有一些不同

  • Choreographer的回调是基于VSync信号的,而gfxinfo的数据是基于每一帧的渲染。
  • 当画面没有发生变化,画面是不需要重新渲染的,此时在GPU呈现模式上的条形图也不会移动,framestats也不会记录。
  • 而VSync信号总是会发出来,所以Choreographer.FrameCallback在画面没有重新渲染时也会被回调到。

优点:

  • 实时
  • 基于VSync信号

缺点:

  • 小于VSync信号间隔的渲染时间不知道精确值
  • 数据不够全面,不知道每个阶段的具体耗时
  • 对VSync信号的响应只依赖于UI线程空闲与否,渲染线程和GPU的阻塞无法判断

adb shell dumpsys SurfaceFlinger --latency

这个是网上比较多介绍的获取数据计算fps的方式,但是自己试验获取不到有效数据,所以计算fps只能通过gfxinfo中获取的数据了

小结

流畅度相关数据的收集

  • 每一帧的渲染时间
    • GPU呈现模式(直观,无法量化)
    • adb shell gfxinfo(可以获取到总的统计数据,和最近120帧的详细渲染数据)
  • VSync信号被响应的时间
    • Choreographer(无法精确计算每一帧的耗时)

流畅度的性能指标

通过上面的介绍,我们收集到了两种与流畅度相关的核心数据

  • 渲染每一帧的耗时
  • VSync信号的响应时间点
    那么这些数据如何和流畅度对应起来呢?

渲染超时的帧数量

最直观的数据就是每一帧的渲染耗时了,当一帧耗时大于1/60s,即使只超过1ms,这一帧依然会错过一个VSync,到下一个VSync信号产生时才能显示到屏幕上。看上去这个值是和流畅度紧密相关的,但是会不会有什么问题呢?

先来看一个极端情况

image.png

这里每一帧渲染都是超过16ms的,但是因为三重缓冲和RenderThread的存在,这里是有60fps的,只不过显示出现了1/30s的延迟,并不会让用户察觉有什么异样。

所以单纯用单位时间渲染超时的帧数量来衡量流畅度其实并不太合理,甚至于google做的那些底层优化就是为了让我们的应用在渲染超过16ms时依然有良好的流畅度表现。

帧率

FPS这是最常用的衡量画面流畅度的指标。

不过在Android系统中用FPS用fps来衡量流畅度却有些缺陷和不便

  • 首先如果画面没有变化,其实是没有画面刷新的,此时FPS为0,但是这种场景下并没有发生卡顿。
  • 第二在android 7.0以下的系统中只能通过adb shell 来获取120帧的数据,限制比较大
  • 第三framestats数据虽然全面,但是计算比较复杂
    • 有脏数据(flags不为0,测试这样的数据不多,影响有限,可以直接剔除)
    • 两帧之间可能有重叠(三重缓冲和RenderThread)
    • 两帧之间可能有间隔(画面不需要更新)
    • 每一帧的时间不确定,按固定帧数计算FPS误差比较大,按单位时间计算需要自己处理时间周期

丢帧与流畅度

首先先说明下什么是丢帧,理论上屏幕的刷新率是60hz,加入垂直同步机制之后,每秒渲染的画面上限就是60,因为某一个VSync信号产生时因为UI线程卡顿或者图像缓冲全部被占据等情况导致这一个VSync没有被响应,这种情况就是丢帧。而在画面没有更新的情况下没有新的帧需要渲染,这种情况并不是丢帧。

注意一下丢帧与渲染超时的区别

  • 渲染超时出现时,这一帧会被显示在屏幕上,不过会有延迟;而丢帧发生时这个周期的UI状态不会被显示到屏幕上
  • 丢帧发生时一定存在渲染超时;而因为RenderThread和三重缓冲机制的存在,发生了渲染超时也不一定就会造成丢帧

因为丢帧的计算实际是依赖于对VSync信号的响应,自然得用到Choreographer.FrameCallback

    public void postFrameCallback(View view) {
        lastTime = System.nanoTime();
        Choreographer.getInstance().postFrameCallback(this);
        disposable = Flowable.interval(200, TimeUnit.MILLISECONDS)
                .map(new Function<Long, Integer>() {
                    @Override
                    public Integer apply(Long aLong) {
                        Log.i(TAG, "apply: " + aLong);
                        int sm = (frameCount - lastFrameCount) * 1000 / 200;
                        lastFrameCount = frameCount;
                        return sm;
                    }
                })
                .subscribe(new Consumer<Integer>() {
                    @Override
                    public void accept(Integer sm) throws Exception {
                        StringBuilder builder = new StringBuilder();
                        builder.append("Smoothness :").append(sm);
                        for (int i = 0; i < skipCount.length; i++) {
                            if (i == 0) {
                                builder.append("  normal: ");
                            } else {
                                builder.append("  skip ").append(i).append(" frames: ");
                            }
                            builder.append(skipCount[i]);

                        }
                        Log.i(TAG, builder.toString());
                    }
                }, new Consumer<Throwable>() {
                    @Override
                    public void accept(Throwable throwable) throws Exception {

                    }
                });
    }

    @Override
    public void doFrame(long frameTimeNanos) {
        Choreographer.getInstance().postFrameCallback(this);
        long jitterNanos = frameTimeNanos - lastTime;
        frameCount++;
        //index表示两个VSync信号之间被忽略的信号数量,即丢帧
        //FRAME_INTERVAL_NANOS取了17ms,因为取1/60s转换成纳秒进行比较的话数值太接近了,稍有误差都会比较大的影响结果
        int index = (int) (jitterNanos / FRAME_INTERVAL_NANOS);
        if (index > 7) {
            index = 7;
        }
        skipCount[index]++;
        lastTime = frameTimeNanos;
    }

这里不仅计算了每秒响应的VSync信号数量作为流畅度(实时数据),还记录了不同连续丢帧发生的次数(可以制定多个维度的数据上报)

  • 丢帧超过50%
  • 连续丢帧2+超过30%
  • 丢帧18+表示发生了300ms以上的卡顿,说明有场景会造成严重卡顿
    ...

小结

综合兼容性,数据与实际场景的契合程度还有计算复杂度,使用Choreographer统计VSync信号是更为合理的选择

To Be Continue

本来准备一篇文章搞定的,但是写着写着发现内容越来越多,拆成两篇感觉更清晰一些,下一篇会介绍如何去优化UI流畅度。

参考资料

Getting To Know Android 4.1, Part 3: Project Butter - How It Works And What It Added
Triple Buffering: Why We Love It
理解 RenderThread
Android硬件加速(二)-RenderThread与OpenGL GPU渲染
Android GPU呈现模式原理及卡顿掉帧浅析
Test UI performance
Choreographer 解析

android性能优化
Web note ad 1