Android性能优化系列三:布局优化

CPU与GPU工作流程

CPU的任务繁多,做逻辑计算外,还要做内存管理、显示操作,因此在实际运算的时候性能会大打折扣,在没有GPU的时代,不能显示复杂的图形,其运算速度远更不上今天复杂三维游戏的要求。即使CPU的工作频率超过2GH或更高,对它绘制图形提高也不大,这时GPU的设计就出来了。结构图对比如下:

Android性能优化系列三:布局优化_第1张图片
黄色:Control控制器,用于协调控制整个CPU的运行,包括取出指令、控制其他模块的运行等;

绿色:ALU(Arithmetic Logic Unit),是算术逻辑单元,用于运行数学、逻辑运算;

橙色:Cache和DRAM分别为缓存和RAM,用于存储信息。

从结构图可以看出,CPU的控制器较为复杂,而ALU数量较少,因此CPU擅长各种复杂的逻辑运算,但不擅长数学尤其是浮点运算。

结合到Android的UI绘制流程可以简单的理解为如下关系:

在这里插入图片描述

CPU计算画图的方法得到矢量图,而GPU通过像素填充,将矢量图转为位图,绘制显示出来。这个过程就是栅格化。

矢量图:又叫向量图,是用一系列计算机指令来描述和记录一幅图,一幅图可以理解为一些列由点、线、面等组成的子图,它所记录的是对象的几何形状、线条粗细和色彩等

位图:位图又叫点阵图或像素图,计算机屏幕上的图像是由像素构成的,每个点用二进制数据来描述其颜色与宽度等信息,这些点是离散的,类似于点阵,多个像素的色彩组合形成了图像,称之为位图
Android性能优化系列三:布局优化_第2张图片

60Hz刷新频率的由来

  • 12fps:由于人类眼睛的特殊生理结构,如果所看画面之帧率高于每秒约10~12帧的时候,就会认为是连贯的
  • 24fps:有声电影的拍摄及播放帧率均为美秒24帧,对一般人而言已经算可接受
  • 30fps:早期的高动态电子游戏,帧率少于美秒30帧的话就会显得不连贯,这是因为没有动态模糊使流畅度降低
  • 60fps:在与手机交互的过程中,如触摸和反馈 60帧以下是能感觉出来的,60帧以上不能察觉变化
  • 当帧率低于60fps时感觉画面有卡顿迟滞现象

Android系统每隔16ms发出VSYNC信号(1000ms/60=16.66ms),触发对UI进行渲染,如果每次渲染都成功,这样就能够达到流畅的画面所需要的60fps,为了能够实现60fps,这意味着计算渲染的大多数操作都必须在16ms内完成。这里只是简单的提一下,有个初步的认识,后续会专门用一篇文章来详细分析Android的屏幕刷新机制。

Layout Inspector

  • Android studio 自带工具
  • 查看视图层级结构
  • Tools—> Layout Inspector

Choreographer 检测丢帧(卡顿)

  • 获取FPS,线上使用,具备实时性
  • API 16之后
  • Choreographer.getInstance().postFrameCallback
public class MainActivity extends AppCompatActivity {
    private long mStartFrameTime = 0;
    private int mFrameCount = 0;
    private static final long MONITOR_INTERVAL = 160L; //单次计算FPS使用160毫秒
    private static final long MONITOR_INTERVAL_NANOS = MONITOR_INTERVAL * 1000L * 1000L;
    private static final long MAX_INTERVAL = 1000L; //设置计算fps的单位时间间隔1000ms,即fps/s;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        getFPS();
    }
    
    @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
    private void getFPS() {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
            return;
        }
        Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
            @Override
            public void doFrame(long frameTimeNanos) {
                if (mStartFrameTime == 0) {
                    mStartFrameTime = frameTimeNanos;
                }
                long interval = frameTimeNanos - mStartFrameTime;
                if (interval > MONITOR_INTERVAL_NANOS) {
                    double fps = (((double) (mFrameCount * 1000L * 1000L)) / interval) * MAX_INTERVAL;
                    Log.d(TAG, "doFrame: fps = " + fps);
                    mFrameCount = 0;
                    mStartFrameTime = 0;
                } else {
                    ++mFrameCount;
                }

                Choreographer.getInstance().postFrameCallback(this);
            }
        });
    }
}

根据fps大小就可以判断是否有丢帧卡顿的产生。
Android性能优化系列三:布局优化_第3张图片

布局加载造成卡顿的原因

当一帧画面绘制渲染时间超过16ms的时候,垂直同步机制会让显示器硬件等待GPU完成栅格化绘制宣传操作,这样就导致这一帧画面,多停留了16ms,甚至更多,这样就造成了用户看起来画面卡顿。

Android性能优化系列三:布局优化_第4张图片

UI绘制渲染需要尽量在16ms内完成,这段时间主要被两件事情占用:

  • 将UI对象转换为一系列多边形和纹理(即向量图)
  • CPU传递数据到GPU

所以要缩短这两部分时间,也就是说要减少对象对象转换的次数,以及传递数据的次数。具体要如何减少这两部分时间,以至于尽量在16ms内完成绘制渲染呢?

  • 减少xml转换成对象的时间

  • 减少重复绘制的时间

布局加载原理

布局加载源码流程
Android性能优化系列三:布局优化_第5张图片
上图就是布局加载的大致流程图,有兴趣的可以参考一下,自行查阅源码,通过反射生成view的核心代码如下:
Sdk/sources/android-29/android/view/LayoutInflate.java

@Nullable
public final View createView(@NonNull Context viewContext, @NonNull String name,
            @Nullable String prefix, @Nullable AttributeSet attrs)
	...
	constructor = clazz.getConstructor(mConstructorSignature);
	...
	constructor.setAccessible(true);
	...
	sConstructorMap.put(name, constructor);
	...
	final View view = constructor.newInstance(args);
}

LayoutInflate.Factory

LayoutInflate是创建view的一个Hook,我们通常对Hook的理解就是,它是一个挂钩,将自己的代码钩在上面,当执行原始方法时,也会执行添加的代码,进而达到修改相关逻辑的效果。比如你要实现一个具有特殊效果的TextView,是不是要对原生的TextView进行替换和更改,这种方案不太优雅,而且工作量也较大。就可以考虑使用LayoutInflate.Factory进行全局的替换,通过它来Hook并判断,如果加载的是TextView,替换成自定义的TextView。

首先将xml布局文件加载到内存,即xml布局文件解析的过程是一个IO过程,如果布局文件较大,读取到内存,这是耗性能的。其次,加载到内存后,创建view对象是通过反射实现的,如果xml布局里面控件较多,就会多次使用反射,这一点也是耗性能的。

Factory与Factory2

Sdk/sources/android-29/android/view/LayoutInflate.java

public interface Factory {
        /**
         * // 1 当我们填充布局的时候,可以调用这个Hook(挂钩),来自定义xml布局文件里面的标记名称
         * Hook you can supply that is called when inflating from a LayoutInflater.
         * You can use this to customize the tag names available in your XML
         * layout files.
         *
         * 

* Note that it is good practice to prefix these custom names with your * package (i.e., com.coolcompany.apps) to avoid conflicts with system * names. * * @param name Tag name to be inflated.// 2 需要填充的标记名称(控件名称) * @param context The context the view is being created in. * @param attrs Inflation attributes as specified in XML file. * * @return View Newly created view. Return null for the default * behavior. */ @Nullable View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs); } public interface Factory2 extends Factory { /** * Version of {@link #onCreateView(String, Context, AttributeSet)} * that also supplies the parent that the view created view will be * placed in. * * @param parent The parent that the created view will be placed * in; note that this may be null. * @param name Tag name to be inflated. * @param context The context the view is being created in. * @param attrs Inflation attributes as specified in XML file. * * @return View Newly created view. Return null for the default * behavior. */ @Nullable View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs); }

注释1处,的英文注释翻译过来大意就是,当我们填充布局的时候,可以调用这个Hook(挂钩),来自定义xml布局文件里面的标记名称,方法参数name表示需要填充的标记名称(控件名称)。而Factory2继承Factory,功能更强,方法多了一个参数,parent表示父容器,即创建的view,摆放的地方。所以加载布局的过程中都是优先使用的Factory2。先分析到这里,有个初步的认识,后面会分析具体的使用。

优雅获取界面布局耗时

如果代码是自己写的,我们就比较清楚页面布局的复杂情况,但是如果是你接手别人的代码,想要快速的定位哪些页面比较复杂,或是哪些页面是要进行布局优化的重点对象,一个比较简单的思路,就是通过AOP,选取每个页面的setContentView作为切面点,监测页面加载布局的耗时,根据耗时情况来优先找到耗时较长的页面进行分析处理。

@Around("execution(* android.app.Activity.setContentView(..))")
    public void getSetContentViewTime(ProceedingJoinPoint joinPoint) {
        Signature signature = joinPoint.getSignature();
        String name = signature.toShortString();
        long time = System.currentTimeMillis();
        try {
            joinPoint.proceed();
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
        Log.d("xpf", name + " 耗时:" + (System.currentTimeMillis() - time));
    }

笔者先后打开了两个Activity,监测它们的setContentVIew方法耗时情况如下:分别为225ms和23ms
在这里插入图片描述
这里为了方便演示,xml布局中就放了几个ImageView,就不具体分析了,实际监测的时候,就找到setContentView耗时较长的页面,检查布局结构。

获取每一个控件加载耗时

如果想要获取每一个具体控件的加载耗时,该从何处入手呢?如果加入过多的监测逻辑,有那么多布局和控件,侵入性太强显然是不可取的。这种场景就可以使用前面提到过的LayoutInflate.Factory了,它是加载创建view的过程中对View的Hook,加上时间戳,就能获取每一个控件加载的耗时情况了。

public class MainActivity extends AppCompatActivity {

    private static final String TAG = "MainActivity";
    @Override
    protected void onCreate(Bundle savedInstanceState) {

        LayoutInflaterCompat.setFactory2(getLayoutInflater(), new LayoutInflater.Factory2() {
            @Nullable
            @Override
            public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
                //场景1:全局替换自定义TextView
                if (TextUtils.equals("TextView", name)) {
                    //创建自定义TextView
                    //MyTextView myTextView = new MyTextView();
                    //return myTextView;
                }

                //场景2:获取每一个控件的加载时间
                long time = System.currentTimeMillis();
                //Hook要加载创建的view
                View view = getDelegate().createView(parent, name, context, attrs);
                Log.d(TAG, name + " cost " + (System.currentTimeMillis() - time));
                
                return view;
            }

            @Nullable
            @Override
            public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
                return null;
            }
        });

        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

对于场景1:只需要在基类BaseActivity中添加逻辑,其它页面继承即可实现全局替换自定义TextView的需求了。
对于场景2:Hook创建加载的view,在前后加上时间戳,就能获取每一个控件的加载的耗时情况了,记得返回hook的view。
有个小细节提一下,这里用的是LayoutInflaterCompat,通常情况下后缀Compat表示有更好的兼容效果,如果有Compat兼容类还是尽量选择兼容类。下面就是MainActivity页面的控件加载耗时情况:
Android性能优化系列三:布局优化_第6张图片
看到这里,相信你已经掌握了,布局加载耗时监测和控件加载耗时监测,这样就能有针对性的进行优化了。

异步inflate介绍

前面提到系统加载xml布局的过程有两大性能痛点:

  • 布局文件读取慢:IO过程
  • 创建View慢:反射(比new 的方式创建对象 慢3倍)

既然我们暂时想不到从根本上替代IO过程和反射的办法,那就想办法从侧面解决,如果走进setContentView里面了解过UI绘制流程,就会发现这两个过程都是在主线程执行的,我们能否让它们在子线程执行,减少主线程耗时,间接达到加快布局加载过程。接下来介绍谷歌提供的AsyncLayoutInflate,简称异步inflate:

  • workThread加载布局
  • 回调主线程
  • 节约主线程时间
    下面介绍AsyncLayoutInflate的简单使用,至于原理和它的限制和改进请参考下面链接:
    AsyncLayoutInflater实现异步加载布局
    Android AsyncLayoutInflater 限制及改进
public class MainActivity extends AppCompatActivity{
	@Override
    protected void onCreate(Bundle savedInstanceState) {
	new AsyncLayoutInflater(MainActivity.this).inflate(R.layout.activity_main, null, 
	new AsyncLayoutInflater.OnInflateFinishedListener() {
	            @Override
	            public void onInflateFinished(@NonNull View view, int i, @Nullable ViewGroup viewGroup) {
	            	//回调在主线程执行
	                setContentView(view);
	                mRecyclerView = findViewById(R.id.recycler_view);
	                mRecyclerView.setLayoutManager(new LinearLayoutManager(MainActivity.this));
	                mRecyclerView.setAdapter(mNewsAdapter);
	                mNewsAdapter.setOnFeedShowCallBack(MainActivity.this);
	            }
    });
	
	//setContentView(R.layout.activity_main);
	
}

布局加载优化

AsyncLayoutInflate通过异步加载的方式,减轻了主线程的耗时,这只是缓解了布局加载因为IO操作,反射创建view对象造成布局加载慢的问题,有没有什么方式可以避免这两个过程呢?那就是通过java代码的方式写布局,代替xml方式写布局。没有了加载xml到内存的IO过程,通过new 的方式代替反射常见view,可以说是从根本上解决了这两点造成的性能问题。但是这种方式解决了性能上的问题,也会带来新的问题,那就是不便于开发,可维护性差,xml布局方式比较简单,便于预览。后者就不一样了。所以说没有完美的方案,只有适合的场景,可以根据自身实际情况进行选择和取舍。有没有方案即保留xml方式的优点,又能避免它的性能缺陷呢?下面要介绍的就是这样一款第三方框架X2C。
X2C介绍

  • 保留XML优点,解决其性能问题
  • 开发人员写XML, 加载java代码
  • 原理:APT编译期翻译xml为java代码

X2C的使用

  • 添加依赖:annotationProcessor ‘com.zhangyue.we:x2c-apt:1.1.2’ implementation ‘com.zhangyue.we:x2c-lib:1.0.6’
  • 添加@Xml注解,然后通过X2C.setContentView(MainActivity, R.layout.activity_main)代替setContentView方法
  • rebuild project
@Xml(layouts = "activity_main")
public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
//        setContentView(R.layout.activity_main);
        X2C.setContentView(this, R.layout.activity_main);
    }

但是X2C也有小缺点,那就是部分属性Java不支持,失去了相关体统兼容类(AppCompat)

视图绘制优化

UI绘制的三大流程回顾:
测量:确定大小
布局:确定位置
绘制:绘制视图
在进行绘制的时候会先进行测量,调用measure方法,在measure方法中又会调用onMeasure方法,在onMeasure方法中则会对所有的子元素进行measure过程,这个时候measure流程就从父容器传递到了子元素中了,这样就完成了一次measure过程,接着子元素就会重复父容器的measure过程,如此反复就完成了整个View数的遍历。同理另外两个过程也是相似的,都有这样遍历的过程。有了对绘制流程的简单认识,那么那些步骤会造成性能瓶颈呢?

  • 自顶而下的遍历:如果层级太深,遍历就很耗时
  • 触发多次:特定的场景触发多次绘制

在进行布局编码的时候有一些基本准则就是减少View树层级,其次就是布局尽量宽而浅,避免窄而深。
ConstraintLayout就有一定的优势

  • 实现几乎完全扁平化布局
  • 构建复杂布局性能更高
  • 具有RelativeLayout和LinearLayout特性

其它优化的小细节

  • 不嵌套使用RelativeLayout
  • 不在嵌套LinearLayout中使用weight
  • 标签可以将一个指定的布局文件加载到当前的布局文件中,一般和搭配使用,可以降低减少布局的层级,但要注意的是,标签只能用于根View。
  • ViewStub 懒加载,提高了程序初始化的性能。

过度绘制

GPU的绘制过程,就像刷墙一样,一层层的进行,16ms刷一次,这样就会造成图层覆盖的现象,也就是无用的图层(用户看不见的区域)还被绘制在底层,造成不必要的浪费。
GPU过度绘制的常见情形

  • 自定义控件中,onDraw方法做了过多的重复绘制
  • 布局层级太深,重叠性太强,用户看不到的区域CPU也会渲染,导致耗时增加
过度绘制查看

在手机的开发者选项里,有OverDraw检测工具,调试GPU过度绘制工具。颜色代表渲染的图层情况。
蓝色:没有过度绘制
淡绿:过度绘制两次
淡红:过度绘制三次
深红:过度绘制四次

避免过度绘制方法
  • 去掉多余背景色,减少复杂shape使用
  • 避免层级叠加(如果两个控件重叠摆放,下面的控件用户看不到的地方也会被绘制,这样的过度绘制要避免)
  • 自定义View使用clipRect屏蔽被遮盖View的绘制
  • onDraw中避免创建大的对象以及进行耗时操作

你可能感兴趣的:(性能优化)