Android布局优化三剑客

前言

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

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

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

效果彻底版

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

一、include

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

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

下面咱们就来实战一番。github

1.1 常规使用

咱们先建立一个ViewOptimizationActivity,而后再建立一个layout_include.xml布局文件,它的内容很是简单,就一个TextView:segmentfault

<?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的布局中:app

<?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="一、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,并给它设置文字:ide

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虽然相同,可是使用起来是没有冲突的。

二、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了。

三、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="三、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中的布局中再初始化控件:

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

若是主布局中控件的id没有冲突,能够直接初始化控件使用:

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

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

后记

本来觉得知识点不难,应该能够写得快一点的,没想到仍是断断续续写了四五天,写得本身都以为有点累了。但愿仍是能对你们有点帮助,不足之处还望指正。下面使用思惟导图总计一下,并给出GitHub上的源码吧。

思惟导图

GitHub

参考文章

API文档
ViewStub
Android--UI之ViewStub

相关文章
相关标签/搜索