先說ListView給高的正確做法.
android:layout_height屬性:
必須將ListView的布局高度屬性設置為非“wrap_content”(可以是“match_parent / fill_parent / 400dp等絕對數值”)
廢話少說先來張bug圖填樓

前言
隨着RecyclerView的普及,ListView差不多是安卓快要淘汰的控件了,但是我們有時候還是會用到,基本上可以說是前些年最常用的Android控件之一了.拋開我們的主題,我們先來談談ListView的一些小小的細節,可能是很多開發者在開發過程中並沒有注意到的細節,這些細節設置會影響到我們的App的性能.
- android:layout_height屬性
我們在使用ListView的時候很可能隨手就會寫一個layout_height=”wrap_content”或者layout_height=”match_parent”,非常非常普通,咋一看,我寫的沒錯啊...可是實際上layout_height=”wrap_content” 是錯誤的寫法!!!會嚴重影響程序的性能 我們先來做一個實驗:
xml布局文件如下
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ListView
android:id="@+id/list_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
></ListView>
</LinearLayout>
java部分代碼

運行log

我們會發現getView總共被調用了15次!其中4次是null的,11次為重復調用,ListView的item數目只有3項!!!太可怕了
我們試着將ListView的高度屬性改為layout_height=”match_parent”,然后看看

我們可以看到getView()只被調用了3次!這應該是我們期望的結果!
原因分析:
了解原因前,我們應該先了解View的繪制流程,之前我的博客沒有關於View繪制流程的介紹,那么在這邊說一下,是一個很重要的知識點.
View的繪制流程是通過 onMeasure()->onLayout()->onDraw()
onMeasure() :主要工作是測量視圖的大小.從頂層的父View到子View遞歸調用measure方法,measure方法又回調onMeasure().
onLayout: 主要工作是確定View的位置,進行頁面布局.從頂層的父View向子View的遞歸調用view.layout方法的過程,即父View根據上一步measure子view所得到的布局大小和布局參數,將子view放在合適的位置上
onDraw() 主要工作是繪制視圖.ViewRoot創建一個Canvas對象,然后調用onDraw()方法.總共6個步驟.1.繪制視圖背景,2.保存當前畫布的圖層(Layer),3.繪制View內容,4.繪制View的子View視圖,沒有的話就不繪制,5.還原圖層,6.繪制滾動條.
了解了View的繪制流程,那么我們回到這個問題上.設置ListView的屬性layout_height=”wrap_content”,就意味着Listview的高度由子View決定,當在onMeasure()的時候,需要測量子View的高度,那我們來看看Listview的onMeasure()方法.
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// Sets up mListPadding
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int childWidth = 0;
int childHeight = 0;
int childState = 0;
mItemCount = mAdapter == null ? 0 : mAdapter.getCount();
if (mItemCount > 0 && (widthMode == MeasureSpec.UNSPECIFIED ||
heightMode == MeasureSpec.UNSPECIFIED)) {
final View child = obtainView(0, mIsScrap);
measureScrapChild(child, 0, widthMeasureSpec);
childWidth = child.getMeasuredWidth();
childHeight = child.getMeasuredHeight();
childState = combineMeasuredStates(childState, child.getMeasuredState());
if (recycleOnMeasure() && mRecycler.shouldRecycleViewType(
((LayoutParams) child.getLayoutParams()).viewType)) {
mRecycler.addScrapView(child, 0);
}
}
if (widthMode == MeasureSpec.UNSPECIFIED) {
widthSize = mListPadding.left + mListPadding.right + childWidth +
getVerticalScrollbarWidth();
} else {
widthSize |= (childState&MEASURED_STATE_MASK);
}
if (heightMode == MeasureSpec.UNSPECIFIED) {
heightSize = mListPadding.top + mListPadding.bottom + childHeight +
getVerticalFadingEdgeLength() * 2;
}
if (heightMode == MeasureSpec.AT_MOST) {
// TODO: after first layout we should maybe start at the first visible position, not 0
heightSize = measureHeightOfChildren(widthMeasureSpec, 0, NO_POSITION, heightSize, -1);
}
setMeasuredDimension(widthSize , heightSize);
mWidthMeasureSpec = widthMeasureSpec;
}
其中
if (heightMode == MeasureSpec.AT_MOST) {
// TODO: after first layout we should maybe start at the first visible position, not 0
heightSize = measureHeightOfChildren(widthMeasureSpec, 0, NO_POSITION, heightSize, -1);
}
比較重要
再看measureHeightOfChildren()
final int measureHeightOfChildren(int widthMeasureSpec, int startPosition, int endPosition,
final int maxHeight, int disallowPartialChildPosition) {
...
for (i = startPosition; i <= endPosition; ++i) {
child = obtainView(i, isScrap);
measureScrapChild(child, i, widthMeasureSpec);
...
// Recycle the view before we possibly return from the method
if (recyle && recycleBin.shouldRecycleViewType(
((LayoutParams) child.getLayoutParams()).viewType)) {
recycleBin.addScrapView(child, -1);
}
returnedHeight += child.getMeasuredHeight();
if (returnedHeight >= maxHeight) {
...
}
if ((disallowPartialChildPosition >= 0) && (i >= disallowPartialChildPosition)) {
...
}
}
return returnedHeight;
}
obtainView(i, isScrap)是子View的實例
measureScrapChild(child, i, widthMeasureSpec); 測量子View
recycleBin.addScrapView(child, -1);將子View加入緩存,可以用來復用
if (returnedHeight >= maxHeight) {return ...;}如果已經測量的子View的高度大於maxHeight的話就直接return出循環,這樣的做法也很好理解,其實是ListView很聰明的一種做法,你可以想想比如說這個屏幕只能畫10個Item高度,你有20個Item,那么畫出10個就行了,剩下的十個就沒必要畫了~
我們現在看下obtainView()方法
View obtainView(int position, boolean[] isScrap) {
Trace.traceBegin(Trace.TRACE_TAG_VIEW, "obtainView");
isScrap[0] = false;
// Check whether we have a transient state view. Attempt to re-bind the
// data and discard the view if we fail.
final View transientView = mRecycler.getTransientStateView(position);
if (transientView != null) {
final LayoutParams params = (LayoutParams) transientView.getLayoutParams();
// If the view type hasn't changed, attempt to re-bind the data.
if (params.viewType == mAdapter.getItemViewType(position)) {
final View updatedView = mAdapter.getView(position, transientView, this);
// If we failed to re-bind the data, scrap the obtained view.
if (updatedView != transientView) {
setItemViewLayoutParams(updatedView, position);
mRecycler.addScrapView(updatedView, position);
}
}
// Scrap view implies temporary detachment.
isScrap[0] = true;
return transientView;
}
final View scrapView = mRecycler.getScrapView(position);
final View child = mAdapter.getView(position, scrapView, this);
if (scrapView != null) {
if (child != scrapView) {
// Failed to re-bind the data, return scrap to the heap.
mRecycler.addScrapView(scrapView, position);
} else {
isScrap[0] = true;
child.dispatchFinishTemporaryDetach();
}
}
if (mCacheColorHint != 0) {
child.setDrawingCacheBackgroundColor(mCacheColorHint);
}
if (child.getImportantForAccessibility() == IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
child.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
}
setItemViewLayoutParams(child, position);
if (AccessibilityManager.getInstance(mContext).isEnabled()) {
if (mAccessibilityDelegate == null) {
mAccessibilityDelegate = new ListItemAccessibilityDelegate();
}
if (child.getAccessibilityDelegate() == null) {
child.setAccessibilityDelegate(mAccessibilityDelegate);
}
}
Trace.traceEnd(Trace.TRACE_TAG_VIEW);
return child;
}
得到一個視圖,它顯示的數據與指定的位置。這叫做當我們已經發現的觀點不是可供重用的回收站。剩下的唯一的選擇是將一個古老的視圖或制作一個新的.(這是方法注釋的翻譯,大致可以理解他的意思)
我們應該關注下以下兩行代碼:
...
final View scrapView = mRecycler.getScrapView(position);
final View child = mAdapter.getView(position, scrapView, this);
...
這兩行代碼的意思就是說先從緩存里面取出來一個廢棄的view,然后將當前的位置跟view作為參數傳入到getView()方法中.這個廢棄的,然后又作為參數的view就是convertView.
然后我們總結下剛剛的步驟:
A、測量第0項的時候,convertView肯定是null的 View scrapView = mRecycler.getScrapView(position)也是空的,所以我們在log上可以看到.

B、第0項測量結束,這個第0項的View就被加入到復用緩存當中了;
C、開始測量第1項,這時因為是有第0項的View緩存的,所以getView的參數convertView就是這個第0項的View緩存,然后重復B步驟添加到緩存,只不過這個View緩存還是第0項的View;
D、繼續測量第2項,重復C。
所以前面說到onMeasure方法會導致getView調用,而一個View的onMeasure方法調用時機並不是由自身決定,而是由其父視圖來決定。ListView放在FrameLayout和RelativeLayout中其onMeasure方法的調用次數是完全不同的。在RelativeLayout中oMeasure()方法調用會翻倍.
由於onMeasure方法會多次被調用,上述問題中是兩次,其實完整的調用順序是onMeasure - onLayout - onMeasure - onLayout - onDraw。
所以根據上面的結論我們可以得出,如果LitsView的android:layout_height屬性設置為wrap_content將會引起getView的多次測量
現象
如上bug圖...
產生的原因
-
ListView的高度設置成了android:layout_height屬性設置為wrap_content
-
ListView的父類是RelativeLayout,RelativiLayout布局會使子布局View的Measure周期翻倍,有興趣可以看下三大基礎布局性能比較
解決辦法
根據每個Item的高度,然后再根據Adapter的count來動態算高.
代碼如下:
public class SetHeight {
public void setListViewHeightBasedOnChildren(ListView listView, android.widget.BaseAdapter adapter) {
if (adapter==null){
return;
}
int totalHeight = 0;
for (int i = 0; i < adapter.getCount(); i++) { // listAdapter.getCount()返回數據項的數目
View listItem = adapter.getView(i, null, listView);
listItem.measure(0, 0); // 計算子項View 的寬高
totalHeight += listItem.getMeasuredHeight(); // 統計所有子項的總高度
}
ViewGroup.LayoutParams params = listView.getLayoutParams();
params.height = totalHeight
+ (listView.getDividerHeight() * (adapter.getCount() - 1));
// listView.getDividerHeight()獲取子項間分隔符占用的高度
// params.height最后得到整個ListView完整顯示需要的高度
listView.setLayoutParams(params);
}
}
xml布局,注意要將ListView的父類設置為LinearLayout
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_above="@+id/txt_cancel"
android:orientation="vertical">
<View
android:layout_width="fill_parent"
android:layout_height="@dimen/y2"
android:background="#cccccc" />
<ListView
android:id="@+id/lv_remain_item"
android:layout_width="fill_parent"
android:layout_height="0dp"
android:cacheColorHint="#00000000"
></ListView>
<View
android:layout_width="fill_parent"
android:layout_height="@dimen/y2"
android:background="#cccccc" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal"
>
<TextView
android:id="@+id/txt_cancel"
android:layout_width="fill_parent"
android:layout_height="@dimen/y120"
android:layout_alignParentBottom="true"
android:gravity="center"
android:text="cancel"
android:textSize="@dimen/x32" />
</LinearLayout>
</LinearLayout>
然后在Listview使用處,調用該方法.
userListDialog.getmListView().setAdapter(scaleUserAdapter);
SetHeight.setListViewHeightBasedOnChildren(userListDialog.getmListView(),scaleUserAdapter);
運行結果

getView()調用情況

GitHub代碼地址:ListViewDialog,喜歡的話歡迎Start
