在編寫Android佈局時總會遇到這樣或者那樣的痛點,比如:
-
有些佈局的在很多頁面都用到了,而且樣式都一樣,每次用到都要複製粘貼一大段,有沒有辦法可以複用呢?
-
解決了1中的問題之後,發現複用的佈局外面總要額外套上一層佈局,要知道佈局嵌套是會影響性能的吶;
-
有些佈局只有用到時纔會顯示,但是必須提前寫好,雖然設置了爲invisible或gone,還是多多少少會佔用內存的。
要解決這些痛點,我們可以請Android佈局優化三劍客出碼,它們分別是include、merge和ViewStub三個標籤,現在我們就來認識認識它們吧。在此之前,我們先來看看我們本次項目的界面效果:
界面不復雜,我們來逐個實現吧。
include
include的中文意思是“包含”、“包括”,當你在一個主頁面裏使用include標籤時,就表示當前的主佈局包含標籤中的佈局,這樣一來,就能很好地起到複用佈局的效果了。在那些常用的佈局比如標題欄和分割線等上面用上它可以極大地減少代碼量的。它有兩個主要的屬性:
-
layout:必填屬性,爲你需要插入當前主佈局的佈局名稱,通過R.layout.xx的方式引用;
-
id:當你想給通過include添加進來的佈局設置一個id的時候就可以使用這個屬性,它可以重寫插入主佈局的佈局id。
下面我們就來實戰一番。
我們先創建一個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佈局");
運行之後可以可以看到如下佈局:
說明我們設置的layout和id都是成功的。不過你可能會對id這個屬性有疑問:id我可以直接在TextView中設置啊,爲什麼重寫它呢?別忘了我們的目的是複用,當你在一個主佈局中使用include標籤添加兩個以上的相同佈局時,id相同就會衝突了,所以重寫它可以讓我們更好地調用它和它裏面的控件。還有一種情況,假如你的主佈局是RelateLayout,這時爲了設置相對位置,你也需要給它們設置不同的id。
重寫根佈局的佈局屬性
除了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"/>
初始化後設置一段文字就可以看到如下的效果了:
可以看到,1.2顯然比1.1多了一個右邊距。
控件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");
運行之後可以看到這樣的效果:
可見雖然控件的id雖然相同,但是使用起來是沒有衝突的。
merge
include標籤雖然解決了佈局重用的問題,卻也帶來了另外一個問題:佈局嵌套。因爲把需要重用的佈局放到一個子佈局之後就必須加一個根佈局,如果你的主佈局的根佈局和你需要include的根佈局都是一樣的(比如都是LinearLayout),那麼就相當於在中間多加了一層多餘的佈局了。那麼有沒有辦法可以在使用include時不增加布局層級呢?答案當然是有的,那就是使用merge標籤。
使用merge標籤要注意一點:必須是一個佈局文件中的根節點,看起來跟其他佈局沒什麼區別,但它的特別之處在於頁面加載時它的不會繪製的。打個比方,它就像是佈局或者控件的搬運工,把“貨物”搬到主佈局之後就會功成身退,不會佔用任何空間,因此也就不會增加布局層級了。這正如它的名字一樣,只起“合併”作用。
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標籤對佈局層級的影響
在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裏面的佈局和控件的位置。
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
你一定遇到這樣的情況:頁面中有些佈局在初始化時沒必要顯示,但是又不得不事先在佈局文件中寫好,雖然設置成了invisible或gone,但是在初始化時還是會加載,這無疑會影響頁面加載速度。針對這一情況,Android爲我們提供了一個利器————ViewStub。這是一個不可見的,大小爲0的視圖,具有懶加載的功能,它存在於視圖層級中,但只會在setVisibility()和inflate()方法調用只會纔會填充視圖,所以不會影響初始化加載速度。它有以下三個重要屬性:
-
android:layout:ViewStub需要填充的視圖名稱,爲“R.layout.xx”的形式;
-
android:inflateId:重寫被填充的視圖的父佈局id。
與include標籤不同,ViewStub的android:id屬性是設置ViewStub本身id的,而不是重寫佈局id,這一點可不要搞錯了。另外,ViewStub還提供了OnInflateListener接口,用於監聽佈局是否已經加載了。
我們先創建一個layout_view_stub.xml,裏面放置一個Switch開關:
<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>
然後在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()方法多次調用,我們可以採用如下三種方式:
捕獲異常
我們可以捕獲異常,同時調用setVisibility()方法顯示佈局。
try {
viewStub.inflate();
} catch (IllegalStateException e) {
Log.e("Tag",e.toString());
view.setVisibility(View.VISIBLE);
}
通過監聽ViewStub的填充事件
聲明一個布爾值變量isViewStubShow,默認值爲false,佈局填充成功之後,在監聽事件onInflate方法中將其置爲true。
if (isViewStubShow){
viewStub.setVisibility(View.VISIBLE);
}else {
viewStub.inflate();
}
直接調用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()了。
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的特性,即填充佈局之後就自我銷燬了,給其設置可見性是沒有意義的。
操作佈局控件
仔細比較一下,其實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的知識就講這麼多了。