Android UI卡顿原因及解决办法

  • Post author:
  • Post category:其他


渲染机制介绍

为了分析UI卡顿,我们有必要理解一下渲染机制,这套渲染机制适用于绝大部分的屏幕渲染,其中包括Android手机等众多屏幕设备。

一些参数

先来举个例子,电源胶卷时代播放的电影是24帧/秒,也就是说一秒有24张胶片进行播放,这是早期的设定,比较低,因为交卷比较贵。随着科学技术的发展,屏幕的刷新速度有了一个质的飞跃。

渲染的一些重要参数:

  1. 屏幕刷新理想的频率(硬件的角度):60Hz
  2. 理想的一秒内绘制的帧数,帧率(屏幕刷新的角度):60fps

这两个参数都是理想值,指代的都是同一个概念。实际情况中难免会比它们低。在60fps内,系统会得到发送的VSYNC(垂直刷新/绘制)信号去进行渲染,就会正常地绘制出我们需要的图形界面。Android手机进行绘制的时候,GPU帮助我们将UI组件等计算成纹理Texture和三维图形Polygons,同时会使用OpenGL—会将纹理和Polygons缓存在GPU内存里面。

其中,VSYNC:有两个概念

  1. Refresh Rate:屏幕在一秒时间内刷新屏幕的次数—-有硬件的参数决定,比如60HZ.
  2. Frame Rate:GPU在一秒内绘制操作的帧数,比如:60fps。

基本结论

要达到60fps,就要求:每一帧只能停留16ms。

UI卡顿分析

UI卡顿的根本原因

Android每个16ms就会绘制一次Activity,通过上述的结论我们知道,如果由于一些原因导致了我们的逻辑、CPU耗时、GPU耗时大于16ms,UI就无法完成一次绘制,那么就会造成卡顿。简单的一句话就是:卡主线程了。

比如说,在16ms内,发生了频繁的GC:


上图中:

  1. 在第一个16ms内,UI正常地完成了绘制,那么屏幕不会卡顿。
  2. 在第二个16ms内,由于某些原因触发了频发的GC,UI无法在16ms内完成绘制,就会卡顿。

UI卡顿外部和内部常见原因

下面总结一些常见的UI卡顿原因:

一、外部因素引起的(以View为区分)

  1. 内存抖动的问题
  2. 方法太耗时了(CPU占用)

二、View本身的卡顿

后面我们会进一步深入分析渲染机制,然后分别从CPU与GPU方面进行优化。

外部因素之–内存抖动的问题引起卡顿分析

Demo的编写

为了模拟UI卡顿,我们利用了WebView加载一张GIF图片:

WebView webView = (WebView) findViewById(R.id.webview);
webView.getSettings().setUseWideViewPort(true);
webView.getSettings().setLoadWithOverviewMode(true);
webView.loadUrl("file:///android_asset/shiver_me_timbers.gif");

然后在GIF在动的时候,执行我们的业务代码,通过GIF的卡顿情况来模拟UI卡顿。

为了模拟内存抖动,我们在GIF动的时候,在主线程执行一下代码:

/**
 * 排序后打印二维数组,一行行打印
 */
public void imPrettySureSortingIsFree() {
    int dimension = 300;
    int[][] lotsOfInts = new int[dimension][dimension];
    Random randomGenerator = new Random();
    for(int i = 0; i < lotsOfInts.length; i++) {
        for (int j = 0; j < lotsOfInts[i].length; j++) {
            lotsOfInts[i][j] = randomGenerator.nextInt();
        }
    }

    for(int i = 0; i < lotsOfInts.length; i++) {
        String rowAsStr = "";
        //排序
        int[] sorted = lotsOfInts[i].clone();
        Arrays.sort(sorted);
        //拼接打印
        for (int j = 0; j < lotsOfInts[i].length; j++) {
            rowAsStr += sorted[j];
            if(j < (lotsOfInts[i].length - 1)){
                rowAsStr += ", ";
            }
        }
        Log.i("ricky", "Row " + i + ": " + rowAsStr);
    }
}

这段代码主要是模拟大量的堆内存分配与释放String对象,频繁触发GC,导致UI卡顿。通过Memory Monitor可以看出:


内存方面是发生了抖动,但是CPU的占用几乎不动。

为了分析内存的情况,我们结合之前的文章,使用一些工具来分析,因为实际情况是,我们不知道哪里的代码导致UI卡顿。

首先我们使用Android Studio自带的Allocation Tracking工具来跟踪内存分配情况。我们在UI卡顿的过程中收集内存分配的信息如下:


我主要关心自己的包,可以粗略地根据内存分配次数来断定哪些类的代码存在大量的内存分配。当然,我们也可以通过饼状图来分析,这里不再赘述。

解决办法

解决办法,这个Demo中,为了解决GC频繁的问题,我们可以利用StringBudiler代替String:

/**
 * 打印二维数组,一行行打印
 */
public void imPrettySureSortingIsFree() {
    int dimension = 300;
    int[][] lotsOfInts = new int[dimension][dimension];
    Random randomGenerator = new Random();
    for(int i = 0; i < lotsOfInts.length; i++) {
        for (int j = 0; j < lotsOfInts[i].length; j++) {
            lotsOfInts[i][j] = randomGenerator.nextInt();
        }
    }

    // 使用StringBuilder完成输出,我们只需要创建一个字符串即可,不需要浪费过多的内存
    StringBuilder sb = new StringBuilder();
    String rowAsStr = "";
    for(int i = 0; i < lotsOfInts.length; i++) {
        // 清除上一行
        sb.delete(0, rowAsStr.length());
        //排序
        int[] sorted = lotsOfInts[i].clone();
        Arrays.sort(sorted);
        //拼接打印
        for (int j = 0; j < lotsOfInts[i].length; j++) {
            sb.append(sorted[j]);
            if(j < (lotsOfInts[i].length - 1)){
                sb.append(", ");
            }
        }
        rowAsStr = sb.toString();
        Log.i("jason", "Row " + i + ": " + rowAsStr);
    }
}

注意,GC是无法避免的,我们要避免的是频繁的GC,因此这里的优化实质上是内存优化。

外部因素之–方法耗时(CPU占用)的问题引起卡顿分析

同理,我们利用斐波那契数列来模拟,我们计算到第40个:

public int computeFibonacci(int positionInFibSequence) {
    //0 1 1 2 3 5 8
    if (positionInFibSequence <= 2) {
        return 1;
    } else {
        return computeFibonacci(positionInFibSequence - 1)
                + computeFibonacci(positionInFibSequence - 2);
    }
}

我们粗略地通过Monitor进行分析:


可以看到CPU的占用突然提高了,但是内存的使用几乎不动。

我们也可以通过TraceView来进行分析方法的耗时:


可以看到,黑乎乎一篇的就是一些耗时的“重灾区”。我们点击放大重灾区:


这里可以看到调用了我自己Activity方法。

往往实际情况比较复杂,我们如果要知道是哪个类的问题,一般需要不断追溯父方法,也就是找到谁调用了这个方法,最终可以分析出是哪个类有问题。

如果我们要看哪个方法耗时,可以根据右边的一些参数来进行分析。其中,Incl的意思是该方法包括其所调用的其他方法的时间,Excl的意思是不包含其所调用的其他方法的时间(纯粹是本身调用的时间)。Recursive是递归调用的意思。CPU Time就是占用CPU的时间,Real Time的意思就是实际时间,包括内存分配、回收等其他的时间,Real Time比CPU Time大。后面还是一些平均调用时间,一个方法可能本身耗时很少,但是可能会被频繁(递归)调用,这时候就需要分析平均调用时间。

分析耗时的时候,我们要不断追溯子方法的耗时情况:

一路跟踪下来,发现到了第11层以后,耗时百分比就变成0.2%了,那么我们可以暂时确定耗时的根源就是第10层的相关方法。如果你发现,Incl百分比很大,但是该方法本身的Excl百分比很小,那么改方法就不是耗时的根源,如下图所示,读者可以自行分析:


最终我们确定是我们自己的Activity的斐波那契计算的那个方法的耗时导致UI卡顿的。

解决办法
  1. 修改方法(算法),使得方法不耗时。
  2. 放到子线程中,例如网络访问、大文件操作等,防止ANR。

例如上述例子中,我们可以使用循环代替递归实现斐波那契数列的计算:

//优化后的斐波那契数列的非递归算法 caching缓存+批处理思想
public int computeFibonacci(int positionInFibSequence) {
    int prev = 0;
    int current = 1;
    int newValue;
    for (int i=1; i<positionInFibSequence; i++) {
        newValue = current + prev;
        prev = current;
        current = newValue;
    }
    return current;
}

View本身的卡顿之–渲染过程分析

如下图所示,这就是一次完整的渲染过程。首先,我们的View通过CPU进行测量、布局、记录、执行等指令操作以后,生成Polygons三维图形和Texture纹理,然后交给CPU进行栅格化,最后,硬件会发出VSYNC信号进行屏幕的绘制与刷新。


其中,栅格化就是将例如字符串、按钮、路径或者形状的一些高级对象,拆分到不同的像素上在屏幕上进行显示,格栅化是一个非常费时的操作,例如:


View本身的卡顿之–耗时原因分析

其中,比较耗时的步骤有:

  1. CPU计算时间,CPU的测量、布局时间
  2. CPU将计算好的Polygons和Texture传递到GPU的时候也需要时间。OpenGL ES API允许数据上传到GPU后可以对数据进行保存,缓存到display list。因此,我们平移等操作一个view是几乎不怎么耗时的。
  3. GPU进行格栅化

View本身的卡顿之–优化建议

一些优化的建议,这里我们参考谷歌官方的一张图片:


CPU优化建议

针对CPU的优化,从减轻加工View对象成Polygons和Texture来下手:

View Hierarchy中包涵了太多的没有用的view,这些view根本就不会显示在屏幕上面,一旦触发测量和布局操作,就会拖累应用的性能表现。那么我们就需要利用工具进行分析。

如何找出里面没用的view呢?或者减少不必要的view嵌套。

我们利用工具:Hierarchy Viewer进行检测,优化思想是:查看自己的布局,层次是否很深以及渲染比较耗时,然后想办法能否减少层级以及优化每一个View的渲染时间。

我们打开APP,然后打开Android Device Monitor,然后切换到Hierarchy Viewer面板。除了看层次结构之外,还可以看到一些耗时的信息:


三个圆点分别代表:测量、布局、绘制三个阶段的性能表现。
1)绿色:渲染的管道阶段,这个视图的渲染速度快于至少一半的其他的视图。
2)黄色:渲染速度比较慢的50%。
3)红色:渲染速度非常慢。

优化建议:

  1. 当我们的布局是用的FrameLayout的时候,我们可以把它改成merge,可以避免自己的帧布局和系统的ContentFrameLayout帧布局重叠造成重复计算(measure和layout)。
  2. 使用ViewStub:当加载的时候才会占用。不加载的时候就是隐藏的,仅仅占用位置。
GPU优化建议

GPU优化建议就是一句话:尽量避免过度绘制(overdraw)

一、背景经常容易造成过度绘制。

手机开发者选项里面找到工具:Debug GPU overdraw,其中,不同颜色代表了绘制了几次:


例子:

由于我们布局设置了背景,同时用到的MaterialDesign的主题会默认给一个背景。解决的办法:将主题添加的背景去掉:

//将主题的背景去掉
getWindow().setBackgroundDrawable(null);

又例如我们的根布局经常会设置重复的背景,那么这时候就应该去掉一些不必要的背景。

还有的就是,我们在写列表控件的时候,如果Item在没有图片的时候需要一个背景色的时候,那么我们这时候就需要灵活地利用透明色来防止过度绘制:

if (chat.getAuthor().getAvatarId() == 0) {
    //没有头像的时候,需要把Drawable设置为透明,防止过度绘制(每次都要设置,因为Item会复用)
    Picasso.with(getContext()).load(android.R.color.transparent).into(chat_author_avatar);
    //没有头像的时候,需要设置默认的背景色
    chat_author_avatar.setBackgroundColor(chat.getAuthor().getColor());
} else {
    //有头像的时候,直接设置头像,并且把背景色设置为透明,同样也是防止过度绘制
    Picasso.with(getContext()).load(chat.getAuthor().getAvatarId()).into(
            chat_author_avatar);
    chat_author_avatar.setBackgroundColor(Color.TRANSPARENT);
}

二、自定义控件处理过度绘制。

如果我们的自定义控件存在一些被遮挡的不需要显示的区域,可以通过画布的裁剪来处理。例如下面的伪代码:

private void drawSomething(Canvas canvas , ...) {
    //画布的保存
    canvas.save();
    //裁剪画布
    canvas.clipRect(...);
    //绘制
    canvas.draw(...);
    //画布还原,下次继续使用
    canvas.restore();
}



版权声明:本文为LF_HYCZ原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。