Android UI布局优化

也算是老生常谈的问题,最近正好有这方面的需求,查阅了很多官方文档和优秀的博客,加上自己的理解编写了这篇文章。

Android 渲染机制

大多数用户感知到的卡顿等性能问题的最主要根源都是因为渲染性能。从设计师的角度,他们希望App能够有更多的动画,图片等时尚元素来实现流畅的用 户体验。但是Android系统很有可能无法及时完成那些复杂的界面渲染操作。Android系统每隔16ms发出VSYNC信号,触发对UI进行渲染, 如果每次渲染都成功,这样就能够达到流畅的画面所需要的60fps,为了能够实现60fps,这意味着程序的大多数操作都必须在16ms内完成。

04080416_dgEb.png

如果你的某个操作花费时间是24ms,系统在得到VSYNC信号的时候就无法进行正常渲染,这样就发生了丢帧现象。那么用户在32ms内看到的会是同一帧画面。

04080416_cWwX.png

用户容易在UI执行动画或者滑动ListView的时候感知到卡顿不流畅,是因为这里的操作相对复杂,容易发生丢帧的现象,从而感觉卡顿。有很多原 因可以导致丢帧,也许是因为你的layout太过复杂,无法在16ms内完成渲染,有可能是因为你的UI上有层叠太多的绘制单元,还有可能是因为动画执行 的次数过多。这些都会导致CPU或者GPU负载过重。

性能优化

影响Android渲染性能的无非以下几个方面:

  1. 过度绘制OverDraw

  2. 页面布局层级太深

  3. 频繁GC导致的页面卡顿

本篇我们主要探讨前两种原因导致的UI性能问题,可以从一下几个方面进行优化:

  • 避免过度绘制OverDraw
    • 移除不必要的背景
      • 透明背景不会被绘制
      • 移除不必要的背景可以有效的优化过度绘制
        • phonewindow背景
        • activity嵌套布局背景
        • ImageView设置的无效背景src和background
    • 自定义控件
      • 使用clipRect减少叠加处的重复绘制
    • google官方还提到了一点降低透明度,可以理解为尽量减少半透明对象的使用
      • 绘制半透明像素会带来额外的成本
  • 减少布局层级,布局尽量扁平化
    • 使用merge减少布局层级
    • 尽量使用ConstraintLayout
    • 嵌套过深使用RelativeLayout而不要使用LinearLayout
  • ViewSub和incluce
    • ViewSub只在需要时加载控件
    • incluce布局复用

过度绘制OverDraw

过度绘制是单帧内同一个像素被重复绘制了多次。为了追求复杂的UI效果,界面上同一位置通常叠加了多个控件,顶部控件遮盖了底部控件时,系统仍然需要绘制被遮盖的部分,从而导致了多度绘制问题。

移除不必要的背景

移除不必要控件的背景,可以有效的优化过度绘制问题。例如某个Activity有一个背景,其上的Layout也有一个背景,Layout中的N个子View也有背景,通过移除Activity或者Layout的背景,可以减少大量红色OverDraw区域。能够显著的提升程序性能。

常见的不必要的背景的场景有:

  • 父View和子View同时设置了background

    例如以下布局:

    
    
    
        
            
        
    
    
    

    很明显content_rel的backgroud是重复的,去掉background后可以有效降低其子View过度绘制的次数。

  • ImageView设置了background

    例如:

    
    
    
        
            
            
        
    
    
    

    定义了一个ImageView并设置了背景色#ededed,乍看可能没问题,但当我们带代码中去加载显示一个图片时。

    ImageView imgIv = new ImageView(this);
    //加载resource资源
    imgIv.setImageResource(R.mipmap.ic_launcher);
    
    //通过Glide加载url
    Glide.with(this).load(imageUrl).apply(
      new RequestOptions()
          .centerCrop()
          .placeholder(R.mipmap.ic_launcher))
      .into(imgIv);
    

    ImageView就会被绘制2次,可以通过以下两种方式进行优化:

    • 通过不设置background或者设置src
     
    
    • 判断src加载之后取消background的方式都可以优化这个问题。

      Glide.with(this).load(imageUrl).apply(
              new RequestOptions()
                      .centerCrop()
                      .placeholder(R.mipmap.ic_launcher))
              .listener(new RequestListener() {
                  @Override
                  public boolean onLoadFailed(@Nullable GlideException e, Object model, 
                                              Target target, boolean isFirstResource) {
                      //加载失败
                      return false;
                  }
      
                  @Override
                  public boolean onResourceReady(Drawable resource, Object model, 
                                                 Target target, DataSource dataSource, 
                                                 boolean isFirstResource) {
                      //加载成功
                      imgIv.setBackgroundDrawable(null);
                      return false;
                  }
              })
              .into(imgIv);
      
  • PhoneWindow背景

    这种情况可能不太容易被注意到,当已经在Activity根布局设置了background时,window的背景是无效的,可以设置为null。

    getWindow().setBackgroundDrawable(null);
    

优化前后的对比,可以说十分明显了。

image

自定义View——clipRect

使用比较简单,直接引用官方说明了。

对于那些过于复杂的自定义的View(重写了onDraw方法),Android系统无法检测具体在onDraw里面会执行什么操作,系统无法监控并自动优化,也就无法避免Overdraw了。但是我们可以通过canvas.clipRect()来 帮助系统识别那些可见的区域。这个方法可以指定一块矩形区域,只有在这个区域内才会被绘制,其他的区域会被忽视。这个API可以很好的帮助那些有多组重叠 组件的自定义View来控制显示的区域。同时clipRect方法还可以帮助节约CPU与GPU资源,在clipRect区域之外的绘制指令都不会被执 行,那些部分内容在矩形区域内的组件,仍然会得到绘制。

for (int i = 0; i < mCards.length; i++) {
    canvas.translate(120, 0);
    canvas.save();
    if (i < mCards.length - 1) {
      //只绘制这个区域内
            canvas.clipRect(0, 0, 120, mCards[i].getHeight());
    }
    canvas.drawBitmap(mCards[i], 0, 0, null);
    canvas.restore();
}
  • 避免使用透明度

对于解决过度绘制问题,Google官方文档还提到了一种方式,减少透明元素的使用。

在屏幕上渲染透明像素,即所谓的透明度渲染,是导致过度绘制的重要因素。在普通的过度绘制中,系统会在已绘制的现有像素上绘制不透明的像素,从而将其完全遮盖,与此不同的是,透明对象需要先绘制现有的像素,以便达到正确的混合效果。诸如透明动画、淡出和阴影之类的视觉效果都会涉及某种透明度,因此有可能导致严重的过度绘制。您可以通过减少要渲染的透明对象的数量,来改善这些情况下的过度绘制。例如,如需获得灰色文本,您可以在 TextView 中绘制黑色文本,再为其设置半透明的透明度值。但是,您可以简单地通过用灰色绘制文本来获得同样的效果,而且能够大幅提升性能。

减少布局层级

  • 使用merge减少布局层级
  • 使用ViewSub只在需要时加载控件
  • 使用incluce标签实现布局复用
  • 尽量使用ConstraintLayout
  • 嵌套过深使用RelativeLayout而不要使用LinearLayout

merge

  • merge既不是View也不是ViewGroup,只是一种标记。
  • merge必须在布局的根节点。
  • 当merge所在布局被添加到容器中时,merge节点被合并不占用布局,merge下面的所有视图转移到容器中。

通过一种比较常用的场景来比较下使用merge和不使用的区别。

不使用merge

Activity布局:



    

        
        
    

    

ToolBar布局:




    

    

实际Activity布局层级:



    

        

            

            
        

    

    

使用merge进行优化:

优化后的ToolBar布局:



    

    

使用tools:parentTag属性可以指定父布局类型,方便在Android Studio中编写布局时进行预览。

实际Activity布局层级,可以通过Layout Inspector来查看具体布局层级:




    

        

        

    

    

可以看到使用merge之后布局层级减少了一层。

使用场景

上面例子可能不太合适,这么写布局容易被打。

来看一种使用频率更高的应用场景——自定义View,大家应该都实现过,比如要定义一个通用的天气控件,通常是自定义一个WeatherView 继承自RelativeLayout,然后通过inflate动态引入布局,那么布局怎么写呢?不使用merge的情况下根布局肯定是RelativeLayout,引入WeatherView之后岂不是嵌套了一层RelativeLayout。这时候就可以在布局中使用merge进行优化。

还有一种应用场景,如果Activity的根布局是FrameLayout可以使用merge进行替换,使用之后可以使Activity的布局层级减少一层。为什么会这样呢?首先我们要了解Activity页面的布局层级,最外层是PhoneWindow其下是一个DecorView下面就是TitleView和ContentView,ContentView就是我们通过SetContentView设置的Activity的布局,没错ContentView是一个FrameLayout,所以在Activity布局中使用merge可以减少层级。

使用merge后可以有效的减少一层布局嵌套。

ViewSub

  • ViewStub是一种没有大小,不占用布局的View。
  • 直到当调用 inflate() 方法或者可见性变为VISIBLE时,才会将指定的布局加载到父布局中。
  • ViewStub加载完指定布局之后会被移除,不再占用空间。(所以 inflate() 方法只能调用一次 )

因为这些特性ViewStub可以用来懒加载布局,优化UI性能。

使用:

布局

在布局中添加ViewStub标签并通过layout属性指定要替换的布局。

        

代码

在需要展示布局的地方调用 inflate() 方法或者将ViewStub的可见性设置为VISIBLE。

private View viewStubContentView = null;

visibleViewStub.setVisibility(View.VISIBLE);

if(viewStubContentView == null){
    viewStubContentView = inflateViewStub.inflate();
}

注意inflate() 方法只能调用一次,重复调用被抛出IllegalStateException异常。

inflate() 方法会返回替换的布局的根View而设置VISIBLE不会返回,如果需要获取替换布局的实例,如:需要为替换的布局设置监听事件,这是需要使用inflate() 方法而不是VISIBLE。

ViewStub源码分析

针对我们前面说的ViewStub的几个特点,我们来分析下源码是如何实现的。分析源码可以学习别人优秀的代码设计,也可以为我们日后类似需求的实现提供借鉴。

  • ViewSutb没有大小,不占用布局

ViewStub在构造方法中设置了控件可见性为GONE并且指定不进行绘制。

public ViewStub(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
    super(context);
    final TypedArray a = context.obtainStyledAttributes(attrs,
            R.styleable.ViewStub, defStyleAttr, defStyleRes);
    mInflatedId = a.getResourceId(R.styleable.ViewStub_inflatedId, NO_ID);
    mLayoutResource = a.getResourceId(R.styleable.ViewStub_layout, 0);
    mID = a.getResourceId(R.styleable.ViewStub_id, NO_ID);
    a.recycle();
    //设置不可见
    setVisibility(GONE);
    //指定不进行绘制
    setWillNotDraw(true);
}

并且重写了onMeasure(widthMeasureSpec, heightMeasureSpec)设置尺寸为(0,0),并且重写了draw(canvas)dispatchDraw(canvas)方法,并且没有做任何绘制操作。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    //指定尺寸为0,0
    setMeasuredDimension(0, 0);
}
@Override
public void draw(Canvas canvas) {
}
@Override
protected void dispatchDraw(Canvas canvas) {
}
  • setVisibility()inflate()方法
//定义了一个View的弱引用
private WeakReference mInflatedViewRef;

@Override
@android.view.RemotableViewMethod(asyncImpl = "setVisibilityAsync")
public void setVisibility(int visibility) {
    if (mInflatedViewRef != null) {
        //如果弱引用不为空且View不为空,调用View的setVisibility方法
        View view = mInflatedViewRef.get();
        if (view != null) {
            view.setVisibility(visibility);
        } else {
            throw new IllegalStateException("setVisibility called on un-referenced view");
        }
    } else {
        super.setVisibility(visibility);
        if (visibility == VISIBLE || visibility == INVISIBLE) {
            //弱引用为空且可见性设置为VISIBLE或者INVISIBLE,调用inflate()方法
            inflate();
        }
    }
}

到这里基本可以分析出弱引用持有的对象就是替换布局的View。继续往下看mInflatedViewRef是在哪里初始化的。

inflate()方法,核心方法执行具体的布局替换操作。

public View inflate() {
    //获取父布局
    final ViewParent viewParent = getParent();
    if (viewParent != null && viewParent instanceof ViewGroup) {
        if (mLayoutResource != 0) {
            final ViewGroup parent = (ViewGroup) viewParent;
            //获取要替换的View对象
            final View view = inflateViewNoAdd(parent);
            //执行替换操作
            replaceSelfWithView(view, parent);
            //初始化弱引用持有View对象
            mInflatedViewRef = new WeakReference<>(view);
            if (mInflateListener != null) {
                //触发监听
                mInflateListener.onInflate(this, view);
            }
            return view;
        } else {
            throw new IllegalArgumentException("ViewStub must have a valid layoutResource");
        }
    } else {
        throw new IllegalStateException("ViewStub must have a non-null ViewGroup viewParent");
    }
}

inflate()方法中获取要替换的View对象并执行了替换操作,mInflatedViewRef持有的确实是替换View对象的实例。

  • ViewStub加载完指定布局之后会被移除,不再占用空间

我们继续来看inflateViewNoAdd() 方法和replaceSelfWithView()方法。

private View inflateViewNoAdd(ViewGroup parent) {
    final LayoutInflater factory;
    if (mInflater != null) {
        factory = mInflater;
    } else {
        factory = LayoutInflater.from(mContext);
    }
    //动态加载View
    final View view = factory.inflate(mLayoutResource, parent, false);
    if (mInflatedId != NO_ID) {
        view.setId(mInflatedId);
    }
    return view;
}

inflateViewNoAdd() 方法比较简单,没什么好解释的。

private void replaceSelfWithView(View view, ViewGroup parent) {
    final int index = parent.indexOfChild(this);
    //从父布局中移除自己
    parent.removeViewInLayout(this);
    final ViewGroup.LayoutParams layoutParams = getLayoutParams();
    if (layoutParams != null) {
        //添加替换布局
        parent.addView(view, index, layoutParams);
    } else {
        //添加替换布局
        parent.addView(view, index);
    }
}

replaceSelfWithView()执行了移除和替换两步操作。这也解释了为什么inflate()方法只能执行一次,因为执行replaceSelfWithView()自身已经被移除,再次执行inflate()方法获取getParent()会为空,从而抛出IllegalStateException异常。

使用场景

app页面中总会有一些布局是不常显示的,如一些特殊提示和页面loading等,这时可以使用ViewStub来实现懒加载的功能,优化UI性能。

篇幅有限,其他几种方式相对简单,在此不详细展开了。

减少ContentView嵌套层级

每一个Activity都对应一个Window也就是PhoneWindow的实例,PhoneWindow对应布局是DecorView,也就是所有Activity的根布局都是DecorView,DecorView是一个FrameLayout,DecorView之下是一个竖向的LinearLayout,包含一个ActionBar和content,content也就是承载我们编写的Activity布局的控件是一个FrameLayout,也是我们调用setContentView所设置布局的父控件,整个结构如下:

image

通过Android Studio自带的Layout Inspector工具可以更清楚的看到整个Activity布局层级。

image

可以看到在加载自定义的Activity布局之前,DecorView中已经嵌套了三层布局了,而且action_bar在国内开发中几乎用不到了,那么我们直接把自定义的布局添加到DecorView中,可以至少减少2层嵌套,按照这个思路我们来实现一个基类。

public abstract class BaseDecorActivity extends FragmentActivity {
    protected final String TAG = getClass().getSimpleName();

    private Unbinder unbinder;

    private View rootView;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        initLayoutView();
        unbinder = ButterKnife.bind(this);

        create();
    }

    /**
     * 添加布局
     */
    private void initLayoutView(){
        if(initLayout() != 0){
            if(getWindow().getDecorView() instanceof FrameLayout){
                //获取DecorView
                FrameLayout decorView = ((FrameLayout)getWindow().getDecorView());
                //移除DecorView的所有子View
                decorView.removeAllViews();
                //初始化子View,并attach到DecorView中
                rootView = LayoutInflater.from(this).inflate(initLayout(),decorView, true);
            } else{
                setContentView(initLayout());
            }
        }
    }

    /**
     * get rootView
     * @return
     */
    protected View getRootView(){
        return rootView;
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();

        if(unbinder != null){
            unbinder.unbind();
        }
    }

    /**
     * 初始化布局
     * @return
     */
    protected abstract int initLayout();
    protected abstract void create();
}

通过Layout Inspector来看一下修改后的效果:

image

可以看到效果很明显,优化后减少了两层布局嵌套。

根据上面的代码我们也知道DecorView是一个FrameLayout,既然..那么,如果我们使用merge对布局再次优化呢?

Activity布局如下:




    

    


优化后的结果:

[图片上传失败...(image-8c2570-1634107619063)]

可以看到布局层级已经很少了,基本达到了最优状态。但是此种方案,未做过多验证,在实际项目中谨慎使用。

写在最后

在进行UI布局优化时,注意配置检测工具使用,文中对这部分未做过多介绍,但是网上有很多关于功能的使用说明,也可以参考Google官方的文档。

检查 GPU 渲染速度和过度绘制

相关文档:

Google官方说明:

检查GPU过度绘制

减少过度绘制

Android性能优化典范(强烈推荐):

Android性能优化典范

其他博客:

Android UI性能实战

你可能感兴趣的:(Android UI布局优化)