Android效能優化-ListView自適應效能問題
作者:京東物流 張振勇
ListView是Android中最常用的檢視之一,使用的頻率僅僅次於幾大基礎佈局,雖然由於使用性和擴充套件性等原因備受爭議,且儘管後來出現了RecyclerView的替代方案,但是ListView仍然廣泛地使用在我們的專案中。
自從ListView出道至今,已經不知道衍生出了多少問題,然而很多人只關心功能功能的實現,卻極少關注ListView過度呼叫導致的效能問題。在實際專案中,即使你正確使用了ViewHolder機制來優化ListView效能,但是在某些場景下依然會感覺卡頓嚴重,到底是什麼原因導致的呢,我們來分析下
1 問題演示
很多時候,我們在使用ListView的時候,都是隨手寫上一個layout_height=”wrap_content”或者layout_height=”match_parent”,非常常規的寫法,乍一看,並沒有什麼問題,尤其是功能實現上也是無可挑剔。
然而,就是layout_height=”wrap_content”這個屬性是導致嚴重的效能問題的根源,下面以一個簡單的例子說明一下:
佈局如上,接下來,假設ListView一共有5項,那麼顯示邏輯程式碼如下:
下面,我們來看看log列印的情況:
數一數,一個是15次getView呼叫,其中6次convertView為null,剩餘9次convertView為複用,而ListView的資料來源真正只有5項!
當然,為了場景的簡單化,我們先不考慮ListView內容超過一螢幕的情況(也就是不考慮其複用機制),所以我們期待的情況應該是getView呼叫5次且convertView全部為null,而事實上getView多呼叫了10次且有一次convertView為null。
同樣的,我們測試一下當layout_height=”match_parent”的情況:
另外,ListView內容超過一螢幕的情況下(考慮複用機制),測試結果一樣,這裡就不再演示了。
在實際專案中,Adapter的getView方法承載著大量的業務邏輯,在效能方面,除去建立檢視的損耗,不正確的ListView使用方式導致的效能損耗大約是正常的3倍左右!那麼到底是什麼原因導致的呢?我們下面來簡單分析下ListView原始碼。
2 ListView程式碼分析
在演示了layout_height=”wrap_content”導致效能問題的現象之後,我們來從原始碼的角度分析下,出現這種過度呼叫問題的根本原因。(原始碼以API 29為例)
2.1 onMeasure
首先,layout_height=”wrap_content”屬性意味著ListView的高度需要由子View決定,即在onMeasure的時候,需要一一測量子View的高度,所以我們先從其onMeasure方法入手。
wrap_content對應的mode為MeasureSpec.AT_MOST,所以很容易就能找測量子檢視高度的程式碼measureHeightOfChildren,當然方法名也體現出來了,所以具體來看這個方法
核心程式碼如上,很明顯,所有的子View例項都是由obtainView方法返回的,然後再呼叫具體measureScrapChild來具體測量子View的高度,正常情況下這裡for迴圈的次數就等於所有子項的個數,不過特殊的是已測量的子View高度之和大於maxHeight就直接return出迴圈了。這種做法其實很好理解,ListView能顯示的最大高度就是螢幕的高度,如果有1000個子項,前面10項已經佔滿了一螢幕了,那後面的990項就沒必要繼續測量高度了,這樣可以大大提高效能。
另外,當一個子View測量完了之後,會通過recycleBin加到複用快取之中,畢竟這個View只是測量了,還沒有加到檢視樹之中,完全是可以繼續複用的。
繼續來看obtainView方法的實現,原始碼在AbsListView中。
obtainView方法裡面核心的程式碼其實就兩行,首先從複用快取中取出一個可以複用的View,然後作為參傳入getView中,也就是convertView。
這時我們梳理一下measure過程中呼叫getView的全過程:
A、測量第0項的時候,convertView肯定是null的,通常需要我們Inflate一個View返回;
B、第0項測量結束,這個第0項的View就被加入到複用快取當中了;
C、開始測量第1項,這時因為是有第0項的View快取的,所以getView的引數convertView就是這個第0項的View快取,然後重複B步驟新增到快取,只不過這個View快取還是第0項的View;
D、繼續測量3、4、5…項,重複C。
所以,我們log中的情況是position=0,convertView=null,而position 1,2 … convertView都是同一個物件例項,即被複用第0項。
2.2 Layout
當Measure過程結束了,下面就要開始Layout過程了,由於onLayout方法程式碼較多,我們直接pass,來看makeAndAddView方法,也就是真真建立View的程式碼。
同樣的,子View例項都是由obtainView方法返回的。這時候就有個小細節了,由於前面Measure的時候,第0項的View已經建立了並且加入到了複用快取當中,這一次就可以直接拿出來繼續用了。接著建立第1,2 … 後面項的時候就沒複用快取了,只能一次次地Inflate。
所以,我們log中的情況是position=0,convertView複用第0項,而position 1,2 … convertView=null。
按理說,Layout之後,應該就不會在呼叫getView方法了,但是我們明顯能看到log仍然多了5次呼叫,那麼這又是怎麼回事呢?
前面說到onMeasure方法會導致getView呼叫,而一個View的onMeasure方法呼叫時機並不是由自身決定,而是由其父檢視來決定。
ListView放在FrameLayout和RelativeLayout中其onMeasure方法的呼叫次數是完全不同的。
2.3 小結
由於onMeasure方法會多次被呼叫,例子中是兩次,其實完整的呼叫順序是onMeasure - onLayout - onMeasure - onLayout - onDraw。所以我們又會看到5次呼叫,和最前面5次是一模一樣的。
那麼,肯定有童鞋又要問,既然onLayout也被執行兩次,那為何不是呼叫5x2+5x2=20次呢?
在第2次onLayout的時候,由於資料並沒有變化,即mDataChanged=false,這時候可以直接用當前項已經存在的View了,不要再通過getView方法重新繫結資料,所以getView是不需要被呼叫的。
從上面的分析中,我們可以得到wrap_content情況下getView被呼叫的時機和次數,假設onMeasure(heightMeasureSpec為AT_MOST)次數為n,onLayout次數為m,ListView控制元件內同時顯示的子項數為i,那麼getView次數=(n + 1)_i,正常情況match_parent時,getView次數= i,多餘的getView呼叫次數應該是 (n + 1)_i - i = n * i;
由公式可以看出getView多餘呼叫次數與onMeasure次數n以及顯示子項數i成正比關係。
3 三大基礎佈局效能比較
1層巢狀:
A = FrameLayout
View onMeasure 2次 onLayout 2次 onDraw 1次
A = LinearLayout
View onMeasure 2次 onLayout 2次 onDraw 1次
A = RelativeLayout
View onMeasure 4次 onLayout 2次 onDraw 1次
2層巢狀:
A = FrameLayout
View onMeasure 2次 onLayout 2次 onDraw 1次
A = LinearLayout
View onMeasure 2次 onLayout 2次 onDraw 1次
A = RelativeLayout
View onMeasure 8次 onLayout 2次 onDraw 1次
3層巢狀:
A = FrameLayout
View onMeasure 2次 onLayout 2次 onDraw 1次
A = LinearLayout
View onMeasure 2次 onLayout 2次 onDraw 1次
A = RelativeLayout
View onMeasure 16次 onLayout 2次 onDraw 1次
4層巢狀:
A = FrameLayout
View onMeasure 2次 onLayout 2次 onDraw 1次
A = LinearLayout
View onMeasure 2次 onLayout 2次 onDraw 1次
A = RelativeLayout
View onMeasure 32次 onLayout 2次 onDraw 1次
從上面邏輯可以看出,RelativeLayout會導致子View的onMeasure重複呼叫,假設巢狀層數為n,子View的onMeasure次數為2^(n+1),如果onMeasure中做了複雜邏輯,將會容易導致卡頓。
另外,如果上面的子View是ListView,且如果高度設定為wrap_content,恰好一螢幕的item個數是m,那麼其adapter的getView方法呼叫次數=(2^n+1)* m。假設n=4,m=10,getView=170次!170次!170次!(為何會這樣,下回合分解,有時間的可以先去玩下,^-^)
所以,三大布局對子View的影響排名應該是:
LinearLayout = FrameLayout >> RelativeLayout
4 常見錯誤
4.1 常見錯誤1
比如4層巢狀的RelativeLayout會使得子View的onMeasure次數達到32,其中heightMeasureSpec為AT_MOST的次數為16,所以如果ListView同時顯示的項數為10,那麼getView的次數達到(16+1)_10=170次,雖然只有10項,但是卻相當於一次性載入了170項,效能損耗之大可想而知。
可以總結出一個公式:如果RelativeLayout巢狀層數為n,ListView顯示項數為m,getView呼叫次數為(2^n+1)_m
4.2 常見錯誤2
從官方的設計來看,ListView其實是禁止防止在ScrollView等垂直滾動檢視中的,但無奈各種各樣的業務和設計導致我們不得不這麼做,然後就衍生出了可謂ListView歷史上最大的坑:NoScrollListView。
NoScrollListview 出現的主要目的是為了支援ListView放在ScrollView等垂直滾動檢視中,原理很簡單,利用前面ListView測量原理分析到的機制,強行設定AT_MOST來測量子View高度,也就是強制ListView自適應,即使你在xml中正確地使用layout_height=”match_parent”,在Java程式碼裡面也會強行設定成wrap_content,導致的結果就是每一次onMeasure都會不停呼叫getView。
如果,結合上前面說的RelativeLayout巢狀,ListView的效能損耗還要再翻倍!
假設ScrollView中存在RelativeLayout裡面巢狀NoScrollListview,RelativeLayout巢狀層數為n,那麼onMeasure的次數為2^n+2^(n+1)次,ListView顯示項數為m,getView呼叫次數為(2^n + 2^(n+1) +1)* m次。如果n=4,m=10,getView次數為490次
相信看到這裡,終於知道為什麼ScrollView中嵌有列表的頁面會卡出翔了吧!
當然,事情還遠遠不止這麼簡單,尤其在某些特殊的場景下,容易導致onMeasure頻繁呼叫,以實際專案中遇到的問題場景舉兩個例子。
- 有些ScrollView具有下拉彈性功能,當手指下拉時會導致子View不停onMeasure,如果子View包含NoScrollListview,頁面肯定一頓一頓的。
- 如果你在getView中的某些不恰當的操作導致ListView重新onMeasure,比如setVisibility為Gone等,就會造成onMeasure和getView的相互迴圈呼叫,這時候效能消耗非常嚴重(一般不會ANR)。
- 同樣的,某些時候我們需要監聽ListView的滾動狀態,會使用setOnScrollListener,由於在onMeasure的時候會觸發OnScrollListener的回撥,如果回撥裡面某些不恰當的操作導致ListView再次觸發onMeasure就會導致OnScrollChangeListener和onMeasure兩者的死迴圈。
5 心得建議
對於以上幾點問題,有如下一些建議:
- 使用ListView的時候注意儘量使用layout_height=”match_parent”。
- 如果第1點無法避免,需要注意ListView的父佈局,父佈局以上絕對不要使用RelativeLayout,即使使用FrameLayout或LinearLayout會增加布局層級。
- 如果第1點無法避免,需要注意不要在getView中使用setVisibility這種會觸發ListView重新onMeasure的操作。
- 如果ListView存在位移,比如下來重新整理等,絕對要遵循第1點來設定layout_height=”match_parent”,不然頻繁觸發onMeasure會導致互動卡頓。
- 關於NoScrollListView,這種佈局是嚴禁使用的,無論是哪種場景,如果ScrollView中必須要使用ListView,可以使用SimulateListView控制元件代替ListView
- 應用健康度隱患刨析解決系列之資料庫時區設定
- 對於Vue3和Ts的心得和思考
- 一文詳解擴散模型:DDPM
- zookeeper的Leader選舉原始碼解析
- 一文帶你搞懂如何優化慢SQL
- 京東金融Android瘦身探索與實踐
- 微前端框架single-spa子應用載入解析
- cookie時效無限延長方案
- 聊聊前端效能指標那些事兒
- Spring竟然可以建立“重複”名稱的bean?—一次專案中存在多個bean名稱重複問題的排查
- 京東金融Android瘦身探索與實踐
- Spring原始碼核心剖析
- 深入淺出RPC服務 | 不同層的網路協議
- 安全測試之探索windows遊戲掃雷
- 關於資料庫分庫分表的一點想法
- 對於Vue3和Ts的心得和思考
- Bitmap、RoaringBitmap原理分析
- 京東小程式CI工具實踐
- 測試用例設計指南
- 當你對 redis 說你中意的女孩是 Mia