Android性能优化之布局优化

Posted 宿罪

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android性能优化之布局优化相关的知识,希望对你有一定的参考价值。

前言

在开始布局优化前我们先需要知道从哪些方面入手以及优化的思路和原理,本篇文章除了讲解布局优化的常用方法外,还会阅读相关的源码,从源码角度让我们可以理解的更加深刻,本篇文章将围绕以下几点展开:

  1. 减少布局树的层级
  2. 减少布局树中View的数量
  3. 减少View的绘制时间(如移除无用的属性避免过度绘制,将在下一篇文章中归到绘制优化部分)
  4. 提高布局的复用性

减少布局树的层级

我们知道在android中View布局是一个树的数据结构,这个树里面的元素是我们编写或定义的View或ViewGroup,而每个ViewGroup元素又可以有View或ViewGroup子元素,这样ViewGroup嵌套子View,子ViewGroup又嵌套子View形成一个树形结构,以下简称为布局树。而View的解析过程是一个深度优先遍历的过程,如果布局树的层级(深度)过大那么会影响到View的显示性能并且内存占用会更大,用户看到的是界面显示不够流畅,所以我们可以通过减少布局树的层级来优化性能。

在Android中我们可以使用XML或者也可以直接使用代码编写布局,两者各有优缺点。使用XML定义布局可以使得我们布局编写更加简单方便,在XML中定义各种View的便签,嵌套定义也比较直观,而直接使用代码编写布局的化就没那么方便了,在代码中View的层级关系不明显及属性参数的设置也麻烦,不直观,但是可以省去XML定义布局带来的XML解析过程的时间。

使用XML定义布局,系统会通过解析XML在代码中构建View的层级树

我们可以从如下几个方面优化View布局树的层级

  1. 选择合适的布局容器,原则是能实现功能的前提下减少布局嵌套
  2. 使用Merge便签,思想是复用上层ViewGroup达到减少一层ViewGroup的目的
  3. 使用ViewStub标签,思想是按需延迟加载,减少首次加载布局时的布局树的复杂度
1. 选择合适的布局容器

我们常用的布局容器有LinearLayout,FrameLayout,RelativeLayout,以及在2016年Google I/O大会上发布的新的布局容器ConstraintLayout(约束布局)。在选择它们实现界面UI的时候我们可以基于以下几个原则。

  1. 使用的布局容器尽可能少
  2. 使用相同数量的布局容器都可以实现的前提下选择布局容器越简单的越好

比如我们在实现下面展示的新闻资讯App的每条新闻Item布局的时候


我们选择ConstraintLayout布局只需要一层嵌套就可以实现,如果使用LinearLayout的话就需要水平或竖直嵌套多层,当然也可以使用RelativeLayout,但是ConstraintLayout比RelativeLayout更加灵活,如果在Item布局比较复杂的化使用RelativeLayout就比较棘手或者需要嵌套。

再如我们实现下图App设置界面


我们可以使用LinearLayout或者RelativeLayout作为我们布局最外层的ViewGroup,但是考虑到RelativeLayout的布局容器会对子View做两次测量与LinearLayout相比性能较低(后面会通过源码角度去比较RelativeLayout和LinearLayout),这里我们会选择LinearLayout作为最外层的布局容器,当然现实中这个例子你首先想到的也会是使用竖直方向的LinearLayout来实现,这里有个原则是LinearLayout和RelativeLayout使用相同布局层级都可以实现都情况下优先使用LinearLayout,这里将布局容器选择按优先级归结为FrameLayout > LinearLayout > RelativeLayout > ConstraintLayout > 布局嵌套

2. 使用Merge标签优化减少View的层级

Merge标签可以将当前Layout顶层元素ViewGroup优化合并到包含这个Layout布局的父布局中,但它也有如下使用限制。

  1. 只能用在布局XML文件的根布局
  2. 使用Merge标签必须指定父ViewGroup并且attachToRoot必须为true

如下XML使用Merge,Android Studio会提示Element merge is not allowed here

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical" android:layout_width="match_parent"
    android:layout_height="match_parent">

    <merge
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"/>
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"/>
    </merge>

    <Button
        android:layout_width="match_parent"
        android:text="button"
        android:layout_height="wrap_content"/>
</LinearLayout>

同时在Android 29的布局解析器LayoutInflater#rInflate源码中有如下代码

 else if (TAG_MERGE.equals(name)) 
    throw new InflateException("<merge /> must be the root element");

也可以说明在XML中使用Merge标签必须是XML的根布局,所以通常会和include标签结合使用,同时在Android 29的布局解析器LayoutInflater#inflate源码中有如下代码可以反应上面的使用限制2 。

if (TAG_MERGE.equals(name)) 
     if (root == null || !attachToRoot) 
         throw new InflateException("<merge /> can be used only with a valid "
                 + "ViewGroup root and attachToRoot=true");
     

     rInflate(parser, root, inflaterContext, attrs, false);
 

继续查看rInflate方法源码

void rInflate(XmlPullParser parser, View parent, Context context,
            AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException 

    final int depth = parser.getDepth();
    int type;
    boolean pendingRequestFocus = false;

    while (((type = parser.next()) != XmlPullParser.END_TAG ||
            parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) 

        if (type != XmlPullParser.START_TAG) 
            continue;
        

        final String name = parser.getName();

        if (TAG_REQUEST_FOCUS.equals(name)) 
            pendingRequestFocus = true;
            consumeChildElements(parser);
         else if (TAG_TAG.equals(name)) 
            parseViewTag(parser, parent, attrs);
         else if (TAG_INCLUDE.equals(name)) 
            if (parser.getDepth() == 0) 
                throw new InflateException("<include /> cannot be the root element");
            
            parseInclude(parser, context, parent, attrs);
         else if (TAG_MERGE.equals(name)) 
            throw new InflateException("<merge /> must be the root element");
         else 
        	// 1. 根据标签生成对应的View
            final View view = createViewFromTag(parent, name, context, attrs);
            final ViewGroup viewGroup = (ViewGroup) parent;
            final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
            // 2. 递归调用rInflate方法解析该标签包含的子View
            rInflateChildren(parser, view, attrs, true);
           	// 3. 将根据标签生成的View直接添加到ViewGroup中,这里的ViewGroup是包含Merge标签的父ViewGroup
            viewGroup.addView(view, params);
        
    

    if (pendingRequestFocus) 
        parent.restoreDefaultFocus();
    

    if (finishInflate) 
        parent.onFinishInflate();
    

上面的注释3处的代码说明来Merge标签的工作原理是直接将其包含的子View解析添加到包含Merge标签的父ViewGroup中,所以Merge标签用来表示其包含的布局将会直接合并到父ViewGroup中,所以使用Merge标签
可以减少一层布局树的嵌套。在Merge标签包含的子View最终的父ViewGroup则是包含Merge标签的ViewGroup,其子View的布局属性要考虑合并后的ViewGroup,简单来说就是Merge标签代替的布局容器要和包含Merge标签的那个父ViewGroup匹配,如果不匹配的话可能和我们要实现的布局效果不一样。

3. 使用ViewStub标签延迟加载布局提高应用程序首次显示速度

考虑到我们在实际的功能开发中有些子View或者ViewGroup的内容不需要在首次显示或者同时出来,那么我们可以使用ViewStub标签自己控制子布局的加载显示时机,通过将ViewStub标签设置为可见或者调用ViewStub的inflate方法和合适的有需要的时候将子布局加载到布局树中显示出来。当然也可以通过visibility属性来控制显示和隐藏,但是这种实现方式虽然可以控制是否显示,但是无法达到延迟加载的目的,换句话说通过设置visibility属性为invisiblegone虽然View不显示,但是还是会加载到布局树里面去影响程序性能。

下面我们从ViewStub的源码角度去看看ViewStub的原理及一些使用限制

ViewStub原理解析
public final class ViewStub extends View 
/**
     * Creates a new ViewStub with the specified layout resource.
     *
     * @param context The application's environment.
     * @param layoutResource The reference to a layout resource that will be inflated.
     */
    public ViewStub(Context context, @LayoutRes int layoutResource) 
        this(context, null);

        mLayoutResource = layoutResource;
    

    public ViewStub(Context context, AttributeSet attrs) 
        this(context, attrs, 0);
    

    public ViewStub(Context context, AttributeSet attrs, int defStyleAttr) 
        this(context, attrs, defStyleAttr, 0);
    
    
	public ViewStub(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) 
	        super(context);
	
	    final TypedArray a = context.obtainStyledAttributes(attrs,
	            R.styleable.ViewStub, defStyleAttr, defStyleRes);
	    saveAttributeDataForStyleable(context, R.styleable.ViewStub, attrs, a, 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();
		// 设置默认不可见,在ViewGroup的实现类中的onLayout对子View的visibility属性判断可知为GONE的子View不会对它进行布局
	    setVisibility(GONE);
	    setWillNotDraw(true);
	
 ...

从构造方法中我们可以看到ViewStub标签的View默认是不可见的,同时我们查看它的onMeasure和draw方法

	@Override
	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) 
	    setMeasuredDimension(0, 0);
	
	
	@Override
	public void draw(Canvas canvas) 
	

发现ViewStub不用测量将测量参数直接设置为0并且draw(onDraw()方法由draw方法触发 ) 方法无任何实现,可以看出ViewStub是一个很轻量的View。

继续看它的setVisibility方法

public void setVisibility(int visibility) 
		// 当还没有触发inflate方法前mInflatedViewRef为null
        if (mInflatedViewRef != null) 
            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) 
                inflate();
            
        
    

当我们通过该方法设置的visibility不是GONE的时候会触发inflate方法加载布局,我们继续看inflate方法


/**
 * Inflates the layout resource identified by @link #getLayoutResource()
 * and replaces this StubbedView in its parent by the inflated layout resource.
 *
 * @return The inflated layout resource.
 *
 */
public View inflate() 
    final ViewParent viewParent = getParent();

    if (viewParent != null && viewParent instanceof ViewGroup) 
        if (mLayoutResource != 0) 
            final ViewGroup parent = (ViewGroup) viewParent;
            // 1. 解析ViewStub的layout属性设置的布局但并不添加布局树中						
            final View view = inflateViewNoAdd(parent);
            // 2. 使用layout属性解析出来的View布局替代当前ViewStub添加到ViewStub的父容器中
            replaceSelfWithView(view, parent);
			// 3. 将layout属性解析的View通过弱引用保存起来,后面调用ViewStub的setVisibility方法的时候将是设置解析出来的View的属性
            mInflatedViewRef = new WeakReference<>(view);
            if (mInflateListener != null) 
                mInflateListener.onInflate(this, view);
            

            return view;
         else 
        	// 4. ViewStub使用限制一,必须需要一个可用的layout(layout设置一个有效的layout布局)
            throw new IllegalArgumentException("ViewStub must have a valid layoutResource");
        
     else 
      	// 5. ViewStub使用限制二,必须需要将ViewStub包含在父容器中
        throw new IllegalStateException("ViewStub must have a non-null ViewGroup viewParent");
    


private View inflateViewNoAdd(ViewGroup parent) 
   final LayoutInflater factory;
   if (mInflater != null) 
       factory = mInflater;
    else 
       factory = LayoutInflater.from(mContext);
   
   final View view = factory.inflate(mLayoutResource, parent, false);

   if (mInflatedId != NO_ID) 
   		// 6. 通过ViewStub标签的inflatedId设置的ID替换layout布局root的ID
       view.setId(mInflatedId);
   
   return view;


private void replaceSelfWithView(View view, ViewGroup parent) 
   final int index = parent.indexOfChild(this);
   // 7. 从布局树中移除当前的ViewStub
   parent.removeViewInLayout(this);

   final ViewGroup.LayoutParams layoutParams = getLayoutParams();
   if (layoutParams != null) 
   		// 8. 使用ViewStub设置的LayoutParams作为ViewStub包含的View的布局参数将ViewStub layout属性知道的布局View添加到ViewStub的父容器中
       parent.addView(view, index, layoutParams);
    else 
   		// 9. 使用默认生成的LayoutParams作为ViewStub包含的View的布局参数将ViewStub layout属性知道的布局View添加到ViewStub的父容器中
       parent.addView(view, index);
   

上面的代码关键部分都有注释,从中我们可以看到ViewStub的工作原理是在ViewStub设置为VISIBLE或者INVISIBLE或者调用inflate方法后才会解析ViewStub layout属性包含的布局,从布局树中移除ViewStub并将解析出来的View添加到ViewStub的父容器中。此时ViewStub已脱离布局树只包含解析出来的View的弱引用,所以ViewStub在首次解析layout属性指定的布局后仅可用来控制其指定的View的显示与隐藏,同时看里面还有一个setVisibilityAsync方法,该方法将解析出来的View替换ViewStub添加到布父布局容器的过程封装成了一个Runnable,使得ViewStub布局替换的时机更加灵活。

/** @hide **/
public Runnable setVisibilityAsync(int visibility) 
    if (visibility == VISIBLE || visibility == INVISIBLE) 
        ViewGroup parent = (ViewGroup) getParent();
        return new ViewReplaceRunnable(inflateViewNoAdd(parent));
     else 
        return null;
    

小结:

  1. ViewStub本身不需要测量不需要绘制也不占用布局位置(ViewStub构造方法中设置为GONE,从ViewGroup的实现类中的onLayout对子View的visibility属性判断可知)的一个轻量级的View;
  2. 通过ViewStub#setVisibility的设置visibility为VISIBLE或者INVISIBLE或者调用inflate方法加载其layout属性指定的View;
  3. ViewStub在加载View之后会从布局树中移除,将其加载的View根据ViewStub的LayoutParams添加到ViewStub所在的父容器中,此后setVisibility操作的是其加载的View;
  4. ViewStub的inflatedId属性将会替代其包含子View根布局的ID;

减少布局树中View的数量

在实际开发中我们应该移除布局中的无用控件,同时我们可以善于利用View的一些属性实现一个View代替多个View,如下图所示常见的App的底部导航按钮,我们可以使用Button或者TextView + drawableTop + drawablePadding 而不需要通过上面一个ImageView + 下面一个TextView来实现(也可以是继承TextView的Button或是RadioButton等)

代码类似:

<TextView
  android:layout_width="wrap_content"
  android:drawableTop="@drawable/ic_launcher_background"
  android:text="首页"
  android:drawablePadding="6dp"
  android:gravity="center_horizontal"
  android:layout_height="wrap_content"/>

再如我们在LinearLayout里面定义了多个View的时候通常会使用一个View来实现分割线,代码类似

<View
    android:layout_width="match_parent"
    android:background="@android:color/darker_gray"
    android:layout_height="1dp"/>

其实我们可以使用LinearLayout自带的divider,showDividers,dividerPadding来实现Item的分割线,需要注意的是不能只写divider属性,还需要配合showDividers属性才能显示分割线,同时,如果需要定义分割线的高度,可以利用shape里面的size属性。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical" android:layout_width="match_parent"
    android:divider="@drawable/divider"
    android:showDividers="middle"
    android:dividerPadding="10dp"
    android:layout_height="match_parent">

    <TextView
        android:layout_width="match_parent"
        android:text="text1"
        android:gravity="center"
        android:layout_height="40dp"/>

    <TextView
        android:layout_width="match_parent"
        android:text="text2"
        android:gravity="center"
        android:layout_height="40dp"/>

    <TextView
        android:layout_width="match_parent"
        android:text="text3"
        android:gravity="center"
        android:layout_height="40dp"/>

    <TextView
        android:layout_width="match_parent"
        android:text="text4"
        android:gravity="center"
        android:layout_height="40dp"/>
</LinearLayout>

除了上面提到的两点可以优化减少View控件的使用,还有一些其他有用的技巧,大家如果有比较好的技巧可以在留言评论中告诉我哈。

使用Include标签实现布局的复用

在实际的界面功能开发中,我们的多个界面之间经常会有相同的布局,比如Activity,Fragment的toolbar或者TitleBar等,我们可以将多个界面之间相同的布局提取出来放到一个单独的layout文件中,在需要使用的布局中通过include标签包含相同的布局即可,这样做可以减少XML的代码量降低开发成本,同时也可以降低相同布局的维护成本,因为修改公共布局后所有包含公共布局部分的界面也跟着改变,这样便于公共布局的管理,避免有些相同布局的改变漏掉的情况。

下面将从源码角度解析include标签的原理

LayoutInflater#parseInclude

// 如果include标签包含的根布局标签是Merge标签那么会执行解析Merge标签的操作
if (TAG_MERGE.equals(childName)) 
    // The <merge> tag doesn't support android:theme, so
    // nothing special to do here.
    rInflate(childParser, parent, context, childAttrs, false);
 else 
    final View view = createViewFromTag(parent, childName,
        context, childAttrs, hasThemeOverride);
    final ViewGroup group = (ViewGroup) parent;

    final TypedArray a = context.obtainStyledAttributes(
        attrs, R.styleable.Include);
    final int以上是关于Android性能优化之布局优化的主要内容,如果未能解决你的问题,请参考以下文章

Android性能优化之启动耗时测量

Android性能优化之启动耗时测量

Android性能优化之启动耗时测量

Android性能优化之布局优化

Android性能优化之布局优化

Android性能优化总提纲