Android布局优化三剑客

前言

在编写Android布局时总会遇到这样或者那样的痛点,比如:

  1. 有些布局的在很多页面都用到了,而且样式都一样,每次用到都要复制粘贴一大段,有没有办法可以复用呢?
  2. 解决了1中的问题之后,发现复用的布局外面总要额外套上一层布局,要知道布局嵌套是会影响性能的呐;
  3. 有些布局只有用到时才会显示,但是必须提前写好,虽然设置了为invisiblegone,还是多多少少会占用内存的。

要解决这些痛点,我们可以请Android布局优化三剑客出码,它们分别是includemergeViewStub三个标签,现在我们就来认识认识它们吧。在此之前,我们先来看看我们本次项目的界面效果:

效果完全版

界面不复杂,我们来逐个实现吧。

1、include

include的中文意思是“包含”、“包括”,当你在一个主页面里使用include标签时,就表示当前的主布局包含标签中的布局,这样一来,就能很好地起到复用布局的效果了。在那些常用的布局比如标题栏和分割线等上面用上它可以极大地减少代码量的。它有两个主要的属性:

  1. layout:必填属性,为你需要插入当前主布局的布局名称,通过R.layout.xx的方式引用;
  2. id:当你想给通过include添加进来的布局设置一个id的时候就可以使用这个属性,它可以重写插入主布局的布局id。

下面我们就来实战一番。

1.1 常规使用

我们先创建一个ViewOptimizationActivity,然后再创建一个layout_include.xml布局文件,它的内容非常简单,就一个TextView:

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:gravity="center_vertical"
    android:textSize="14sp"
    android:background="@android:color/holo_red_light"
    android:layout_height="40dp">

</TextView>

现在我们就用include标签,将其添加到ViewOptimizationActivity的布局中:

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

    <!--include标签的使用-->
    <TextView
        android:textSize="18sp"
        android:text="1、include标签的使用"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <include
        android:id="@+id/tv_include1"
        layout="@layout/layout_include"/>

</LinearLayout>

没错,include的使用就是这么简单,只需指明要包含的布局id就行。除此之外,我们还给这个include标签设置了一个id,为了验证它就是layout_include.xml的根布局TextView的id,我们在ViewOptimizationActivity中初始化TextView,并给它设置文字:

        TextView tvInclude1 = findViewById(R.id.tv_include1);
        tvInclude1.setText("1.1 常规下的include布局");

运行之后可以可以看到如下布局:

include常规使用

说明我们设置的layout和id都是成功的。不过你可能会对id这个属性有疑问:id我可以直接在TextView中设置啊,为什么重写它呢?别忘了我们的目的是复用,当你在一个主布局中使用include标签添加两个以上的相同布局时,id相同就会冲突了,所以重写它可以让我们更好地调用它和它里面的控件。还有一种情况,假如你的主布局是RelateLayout,这时为了设置相对位置,你也需要给它们设置不同的id。

1.2 重写根布局的布局属性

除了id之外,我们还可以重写宽高、边距和可见性(visibility)这些布局属性。但是一定要注意,单单重写android:layout_height或者android:layout_width是不行,必须两个同时重写才起作用。包括边距也是这样,如果我们想给一个include进来的布局添加右边距的话的完整写法是这样的:

    <include
        android:layout_width="match_parent"
        android:layout_height="40dp"
        android:layout_marginEnd="40dp"
        android:id="@+id/tv_include2"
        layout="@layout/layout_include"/>

初始化后设置一段文字就可以看到如下的效果了:

设置了右边距的include布局

可以看到,1.2显然比1.1多了一个右边距。

1.3 控件ID相同时的处理

在1.1中我们知道了id属性可以重写include布局的根布局id,但对于根布局里面的布局和控件是无能为力的,如果这时一个布局在主布局中include了多次,那怎么区别里面的控件呢?

我们先创建一个layout_include2.xml的布局,它的根布局是FrameLayout,里面有一个TextView,它的id是tv_same:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:background="@android:color/holo_orange_light"
    android:layout_height="wrap_content">

    <TextView
        android:gravity="center_vertical"
        android:id="@+id/tv_same"
        android:layout_width="match_parent"
        android:layout_height="50dp" />

</FrameLayout>

在主布局中添加进去:

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

    <!--include标签的使用-->
    ……

    <include layout="@layout/layout_include2"/>

    <include
        android:id="@+id/view_same"
        layout="@layout/layout_include2"/>

</LinearLayout>

为了区分,这里给第二个layout_include2设置了id。也许你已经反应过来了,没错,我们就是要创建根布局的对象,然后再去初始化里面的控件:

        TextView tvSame = findViewById(R.id.tv_same);
        tvSame.setText("1.3 这里的TextView的ID是tv_same");
        FrameLayout viewSame = findViewById(R.id.view_same);
        TextView tvSame2 = viewSame.findViewById(R.id.tv_same);
        tvSame2.setText("1.3 这里的TextView的ID也是tv_same");

运行之后可以看到这样的效果:
设置了右边距的include布局

可见虽然控件的id虽然相同,但是使用起来是没有冲突的。

2、merge

include标签虽然解决了布局重用的问题,却也带来了另外一个问题:布局嵌套。因为把需要重用的布局放到一个子布局之后就必须加一个根布局,如果你的主布局的根布局和你需要include的根布局都是一样的(比如都是LinearLayout),那么就相当于在中间多加了一层多余的布局了。那么有没有办法可以在使用include时不增加布局层级呢?答案当然是有的,那就是使用merge标签。

使用merge标签要注意一点:必须是一个布局文件中的根节点,看起来跟其他布局没什么区别,但它的特别之处在于页面加载时它的不会绘制的。打个比方,它就像是布局或者控件的搬运工,把“货物”搬到主布局之后就会功成身退,不会占用任何空间,因此也就不会增加布局层级了。这正如它的名字一样,只起“合并”作用。

2.1 merge常规使用

我们来验证一下,首先创建一个layout_merge.xml,在根节点使用merge标签:

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

    <TextView
        android:id="@+id/tv_merge1"
        android:text="我是merge中的TextView1"
        android:background="@android:color/holo_green_light"
        android:gravity="center"
        android:layout_width="wrap_content"
        android:layout_height="40dp" />

    <TextView
        android:layout_toEndOf="@+id/tv_merge1"
        android:id="@+id/tv_merge2"
        android:text="我是merge中的TextView2"
        android:background="@android:color/holo_blue_light"
        android:gravity="center"
        android:layout_width="match_parent"
        android:layout_height="40dp" />
</merge>

这里我使用了一些相对布局的属性,原因后面你就知道了。我们接着在ViewOptimizationActivity的布局添加RelativeLayout,然后使用include标签将layout_merge.xml添加进去:

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <include
            android:id="@+id/view_merge"
            layout="@layout/layout_merge"/>
    </RelativeLayout>

运行出来的效果图:

merge常规使用

2.2 merge标签对布局层级的影响

在layout_merge.xml中,我们使用相对布局的属性android:layout_toEndOf将蓝色TextView设置到了绿色TextView的右边,而layout_merge.xml的父布局是RelativeLayout,所以这个属性是起了作用了,merge标签不会影响里面的控件,也不会增加布局层级。

如果你还不放心,可以用Android Studio来检查。我用的Android Studio是3.1版本的,可以通过Layout Inspector查看布局层级,不过记得要先在真机或者模拟器上把项目跑起来。依次点击Tools-Layout Inspector,然后选择你要查看的Activity,就可以看到如下的层级图:

布局层级

可以看到RelativeLayout下面直接就是两个TextView了, merge标签并没有增加布局层级。从这里也可以看出merge的局限性,即你需要明确将merge里面的布局和控件include到什么类型的布局中,才能提前设置好merge里面的布局和控件的位置。

2.3 merge的ID

在学习include标签时我们知道,它的android:id属性可以重写被include的根布局id,但如果根节点是merge呢?前面说了merge并不会作为一个布局绘制出来,所以这里给它设置id是不起作用的。我们可以在它的父布局RelativeLayout中再加一个TextView,使用android:layout_below属性把设置到layout_merge下面:

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <include
            android:id="@+id/view_merge"
            layout="@layout/layout_merge"/>

        <TextView
            android:text="我不是merge中的布局"
            android:layout_below="@+id/view_merge"
            android:background="@android:color/holo_purple"
            android:gravity="center"
            android:layout_width="match_parent"
            android:layout_height="40dp"/>
    </RelativeLayout>

运行之后你会发现新加的TextView会把merge布局盖住,没有像预期那样在其下方。如果把android:layout_below中的id改为layout_merge.xml中任一TextView的id(比如tv_merge1),运行之后就可以看到如下效果:

这也符合2.2中的情况,即父布局RelativeLayout下级布局就是include进去的TextView了。

3、ViewStub

你一定遇到这样的情况:页面中有些布局在初始化时没必要显示,但是又不得不事先在布局文件中写好,虽然设置成了invisiblegone,但是在初始化时还是会加载,这无疑会影响页面加载速度。针对这一情况,Android为我们提供了一个利器————ViewStub。这是一个不可见的,大小为0的视图,具有懒加载的功能,它存在于视图层级中,但只会在setVisibility()inflate()方法调用只会才会填充视图,所以不会影响初始化加载速度。它有以下三个重要属性:

  • android:layout:ViewStub需要填充的视图名称,为“R.layout.xx”的形式;
  • android:inflateId:重写被填充的视图的父布局id。

include标签不同,ViewStubandroid:id属性是设置ViewStub本身id的,而不是重写布局id,这一点可不要搞错了。另外,ViewStub还提供了OnInflateListener接口,用于监听布局是否已经加载了。

3.1 填充布局的正确方式

我们先创建一个layout_view_stub.xml,里面放置一个Switch开关:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:background="@android:color/holo_blue_dark"
    android:layout_height="100dp">
    <Switch
        android:id="@+id/sw"
        android:layout_gravity="center"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />
</FrameLayout>

然后在Activity的布局中修改如下:

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

    <!--ViewStub标签的使用-->
    <TextView
        android:textSize="18sp"
        android:text="3、ViewStub标签的使用"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <ViewStub
        android:id="@+id/view_stub"
        android:inflatedId="@+id/view_inflate"
        android:layout="@layout/layout_view_stub"
        android:layout_width="match_parent"
        android:layout_height="100dp" />
    <LinearLayout
        android:orientation="horizontal"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <Button
            android:text="显示"
            android:id="@+id/btn_show"
            android:layout_weight="1"
            android:layout_width="0dp"
            android:layout_height="wrap_content" />

        <Button
            android:text="隐藏"
            android:id="@+id/btn_hide"
            android:layout_weight="1"
            android:layout_width="0dp"
            android:layout_height="wrap_content" />

        <Button
            android:text="操作父布局控件"
            android:id="@+id/btn_control"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />
    </LinearLayout>
</LinearLayout>

ViewOptimizationActivity中监听ViewStub的填充事件:

        viewStub.setOnInflateListener(new ViewStub.OnInflateListener() {
            @Override
            public void onInflate(ViewStub viewStub, View view) {
                Toast.makeText(ViewOptimizationActivity.this, "ViewStub加载了", Toast.LENGTH_SHORT).show();
            }
        });

然后通过按钮事件来填充和显示layout_view_stub:

    @Override
    public void onClick(View view) {
        switch (view.getId()) {
            case R.id.btn_show:
                viewStub.inflate();
                break;
            case R.id.btn_hide:
                viewStub.setVisibility(View.GONE);
                break;
            default:
                break;
        }
    }

运行之后,点击“显示”按钮,layout_view_stub显示了,并弹出"ViewStub加载了"的Toast;点击“隐藏”按钮,布局又隐藏掉了,但是再点击一下“显示”按钮,页面居然却闪退了,查看日志,发现抛出了一个异常:

java.lang.IllegalStateException: ViewStub must have a non-null ViewGroup viewParent

我们打开ViewStub的源码,看看是哪里抛出这个异常的。很快我们就可以定位到是在inflate()方法中

    public View inflate() {
        final ViewParent viewParent = getParent();

        if (viewParent != null && viewParent instanceof ViewGroup) {
            if (mLayoutResource != 0) {
                final ViewGroup parent = (ViewGroup) viewParent;
                final View view = inflateViewNoAdd(parent);
                replaceSelfWithView(view, parent);

                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");
        }
    }

注意到if语句中有一个replaceSelfWithView()方法,听这名字就让人有一种不祥的预感了,点进去一看:

    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);
        }
    }

果然,ViewStub在这里调用了removeViewInLayout()方法把自己从布局移除了。到这里我们就明白了,ViewStub在填充布局成功之后就会自我销毁,再次调用inflate()方法就会抛出IllegalStateException异常了。此时如果想要再次显示布局,可以调用setVisibility()方法。

为了避免inflate()方法多次调用,我们可以采用如下三种方式:

3.1.1 捕获异常

我们可以捕获异常,同时调用setVisibility()方法显示布局。

                try {
                    viewStub.inflate();
                } catch (IllegalStateException e) {
                    Log.e("Tag",e.toString());
                    view.setVisibility(View.VISIBLE);
                }

3.1.2 通过监听ViewStub的填充事件

声明一个布尔值变量isViewStubShow,默认值为false,布局填充成功之后,在监听事件onInflate方法中将其置为true。

                if (isViewStubShow){
                    viewStub.setVisibility(View.VISIBLE);
                }else {
                    viewStub.inflate();
                }

3.1.3 直接调用setVisibility()方法

我先来看看ViewStub中的setVisibility()源码:

    public void setVisibility(int visibility) {
        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();
            }
        }
    }

可以看到,在inflate()初始化mInflatedViewRef之前,如果设置visibilityVISIBLE的话是会调用inflate()方法的,在mInflatedViewRef不为null之后就不会再去调用inflate()了。

3.2 viewStub.getVisibility()为何总是等于0?

在显示ViewStub中的布局时,你可能会采取如下的写法:

                if (viewStub.getVisibility() == View.GONE){
                    viewStub.setVisibility(View.VISIBLE);
                }else {
                    viewStub.setVisibility(View.GONE);
                }

恭喜你,踩到一个大坑了。这样写你会发现点击“显示”按钮后ViewStub里面的布局不会再显示出来,也就是说if语句里面的代码没有执行。如果你将viewStub.getVisibility()的值打印出来,就会看到它始终为0,这恰恰是View.VISIBLE的值。奇怪,我们明明写了viewStub.setVisibility(View.GONE),layout_view_stub也隐藏了,为什么ViewStub的状态还是可见呢?

重新回到3.1.3,看看ViewStub中的setVisibility()源码,首先判断弱引用对象mInflatedViewRef是否为空,不为空则取出存放进去的对象,也就是我们ViewStub中的View,然后调用了view的setVisibility()方法,mInflatedViewRef为空时,则判断visibilityVISIBLEINVISIBLE时调用inflate()方法填充布局,如果为GONE的话则不予处理。这样一来,在mInflatedViewRef不为空,也就是已经填充了布局的情况下,ViewStub中的setVisibility()方法实际上是在设置内部视图的可见性,而不是ViewStub本身。这样的设计其实也符合ViewStub的特性,即填充布局之后就自我销毁了,给其设置可见性是没有意义的。

3.3 操作布局控件

仔细比较一下,其实ViewStub就像是一个懒惰的include,我们需要它加载时才加载。要操作布局里面的控件也跟include一样,你可以先初始化ViewStub中的布局中再初始化控件:

                //1、初始化被inflate的布局后再初始化其中的控件,
                FrameLayout frameLayout = findViewById(R.id.view_inflate);//android:inflatedId设置的id
                Switch sw = frameLayout.findViewById(R.id.sw);
                sw.toggle();

如果主布局中控件的id没有冲突,可以直接初始化控件使用:

                //2、直接初始化控件
                Switch sw = findViewById(R.id.sw);
                sw.toggle();

好了,关于ViewStub的知识就讲这么多了。

后记

原本以为知识点不难,应该可以写得快一点的,没想到还是断断续续写了四五天,写得自己都觉得有点累了。希望还是能对大家有点帮助,不足之处还望指正。下面使用思维导图总计一下,并给出GitHub上的源码吧。

思维导图

GitHub

参考文章

API文档
ViewStub
Android--UI之ViewStub

阅读 1.3k

推荐阅读