Android佈局優化三劍客

我與bug不得不說的故事發表於2019-03-29

前言

在編寫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常規使用
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佈局
設定了右邊距的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佈局
設定了右邊距的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常規使用
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),執行之後就可以看到如下效果:

image

這也符合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之前,如果設定visibility為VISIBLE的話是會呼叫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為空時,則判斷visibility為VISIBLE或INVISIBLE時呼叫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上的原始碼吧。

思維導圖
思維導圖

相關文章