Android ListView功能擴充套件,實現高效能的瀑布流佈局

發表於2015-10-17

經過前面兩篇文章的學習,我們已經對ListView進行了非常深層次的剖析,不僅瞭解了ListView的原始碼和它的工作原理,同時也將ListView中常見的一些問題進行了歸納和總結。

那麼本篇文章是我們ListView系列三部曲的最後一篇,在這篇文章當中我們將對ListView進行功能擴充套件,讓它能夠以瀑布流的樣式來顯示資料。另外,本篇文章的內容比較複雜,且知識點嚴重依賴於前兩篇文章,如果你還沒有閱讀過的話,強烈建議先去閱讀 Android ListView工作原理完全解析,帶你從原始碼的角度徹底理解 和 Android ListView非同步載入圖片亂序問題,原因分析及解決方案 這兩篇文章。

一直關注我部落格的朋友們應該知道,其實在很早之前我就釋出過一篇關於實現瀑布流佈局的文章,Android瀑布流照片牆實現,體驗不規則排列的美感。但是這篇文章中使用的實現演算法比較簡單,其實就是在外層巢狀一個ScrollView,然後按照瀑布流的規則不斷向裡面新增子View,原理如下圖所示:

20150929113837692

雖說功能是可以正常實現,但是這種實現原理背後的問題太多了,因為它只會不停向ScrollView中新增子View,而沒有一種合理的回收機制,當子View無限多的時候,整個瀑布流佈局的效率就會嚴重受影響,甚至有可能會出現OOM的情況。

而我們在前兩篇文章中對ListView進行了深層次的分析,ListView的工作原理就非常巧妙,它使用RecycleBin實現了非常出色的生產者和消費者的機制,移出螢幕的子View將會被回收,並進入到RecycleBin中進行快取,而新進入螢幕的子View則會優先從RecycleBin當中獲取快取,這樣的話不管我們有多少條資料需要顯示,實際上螢幕上的子View其實也就來來回回那麼幾個。

那麼,如果我們使用ListView工作原理來實現瀑布流佈局,效率問題、OOM問題就都不復存在了,可以說是真正意義上實現了一個高效能的瀑布流佈局。原理示意圖如下所示:

20150930211232879

OK,工作原理確認了之後,接下來的工作就是動手實現了。由於瀑布流這個擴充套件對ListView整體的改動非常大,我們沒辦法簡單地使用繼承來實現,所以只能先將ListView的原始碼抽取出來,然後對其內部的邏輯進行修改來實現功能,那麼我們第一步的工作就是要將ListView的原始碼抽取出來。但是這個工作並不是那麼簡單的,因為僅僅ListView這一個單獨的類是不能夠獨立工作的,我們如果要抽取程式碼的話還需要將AbsListView、AdapterView等也一起抽取出來,然後還會報各種錯誤都需要一一解決,我當時也是折騰了很久才搞定的。所以這裡我就不帶著大家一步步對ListView原始碼進行抽取了,而是直接將我抽取好的工程UIListViewTest上傳到了CSDN,大家只需要點選 這裡 進行下載就可以了,今天我們所有的程式碼改動都是在這個工程的基礎上進行的。

另外需要注意的是,為了簡單起見,我沒有抽取最新版本的ListView程式碼,而是選擇了Android 2.3版本ListView的原始碼,因為老版本的原始碼更為簡潔,方便於我們理解核心的工作流程。

好的,那麼現在將UIListViewTest專案匯入到開發工具當中,然後執行程式,效果如下圖所示:

20151002175533470

可以看到,這是一個非常普通的ListView,每個ListView的子View裡面有一張圖片,一段文字,還有一個按鈕。文字的長度是隨機生成的,因此每個子View的高度也各不相同。那麼我們現在就來對ListView進行擴充套件,讓它擁有瀑布流展示的能力。

首先,我們開啟AbsListView這個類,在裡面新增如下所示的幾個全域性變數:

其中mColumnCount表示瀑布流佈局一共有幾列,這裡我們先讓它分為兩列顯示,後面隨時可以對它進行修改。當然,如果想擴充套件性做的好的話,也可以使用自定義屬性的方式在XML裡面指定顯示的列數,不過這個功能就不在我們本篇文章的討論範圍之內了。mColumnViews建立了一個長度為mColumnCount的陣列,陣列中的每個元素都是一個泛型為View的ArrayList,用於快取對應列的子View。mPosIndexMap則是用於記錄每一個位置的子View應當放置在哪一列當中。

接下來讓我們回憶一下,ListView最基本的填充方式分為向下填充和向上填充兩種,分別對應的方法是fillDown()和fillUp()方法,而這兩個方法的觸發點都是在fillGap()方法當中的,fillGap()方法又是由trackMotionScroll()方法根據子元素的位置來進行呼叫的,這個方法只要手指在螢幕上滑動時就會不停進行計算,當有螢幕外的元素需要進入螢幕時,就會呼叫fillGap()方法來進行填充。那麼,trackMotionScroll()方法也許就應該是我們開始著手修改的地方了。

這裡我們最主要的就是修改對於子View進入螢幕判斷的時機,因為原生的ListView只有一列內容,而瀑布流佈局將會有多列內容,所以這個時機的判斷演算法也就需要進行改動。那麼我們先來看一下原先的判斷邏輯,如下所示:

這裡firstTop表示螢幕中第一個元素頂邊的位置,lastBottom表示螢幕中最後一個元素底邊的位置,然後spaceAbove記錄螢幕第一個元素頂邊到ListView上邊緣的距離,spaceBelow記錄螢幕最後一個元素底邊到ListView下邊緣的距離。最後使用手指在螢幕上移動的距離和spaceAbove、spaceBelow進行比較,來判斷是否需要呼叫fillGap()方法,如下所示:

瞭解了原先的工作原理之後,我們就可以來思考一下怎麼將這個邏輯改成適配瀑布流佈局的方式。比如說目前ListView中有兩列內容,那麼獲取螢幕中的第一個元素和最後一個元素其實意義是不大的,因為在有多列內容的情況下,我們需要找到的是最靠近螢幕上邊緣和最靠近螢幕下邊緣的元素,因此這裡就需要寫一個演算法來去計算firstTop和lastBottom的值,這裡我先把修改後的trackMotionScroll()方法貼出來,然後再慢慢解釋:

從第9行開始看,這裡我們使用了一個迴圈,遍歷瀑布流ListView中的所有列,每次迴圈都去獲取該列的第一個元素和最後一個元素,然後和firstTop及lastBottom做比較,以此找出所有列中最靠近螢幕上邊緣的元素位置和最靠近螢幕下邊緣的元素位置。注意這裡除了firstTop和lastBottom之外,我們還計算了一個endBottom的值,這個值記錄最底部的元素位置,用於在滑動時做邊界檢查的。

最重要的修改就是這些了,不過在其它一些地方還做了一些小的改動。觀察第75行,這裡是把被移出螢幕的子View新增到RecycleBin當中,其實也就是說明這個View已經被回收了。那麼還記得我們剛剛新增的全域性變數mColumnViews嗎?它用於快取每一列的子View,那麼當有子View被回收的時候,mColumnViews中也需要進行刪除才可以。在第76行,先呼叫getTag()方法來獲取該子View的所處於哪一列,然後呼叫remove()方法將它移出。第96行處的邏輯是完全相同的,只不過一個是向上移動,一個是向下移動,這裡就不再贅述。

另外還有一點改動,就是我們在第115行呼叫fillGap()方法的時候新增了一個引數,原來的fillGap()方法只接收一個布林型引數,用於判斷向上還是向下滑動,然後在方法的內部自己獲取第一個或最後一個元素的位置來獲取偏移值。不過在瀑布流ListView中,這個偏移值是需要通過迴圈進行計算的,而我們剛才在trackMotionScroll()方法中其實已經計算過了,因此直接將這個值通過引數進行傳遞會更加高效。

現在AbsListView中需要改動的內容已經結束了,那麼我們回到ListView當中,首先修改fillGap()方法的引數:

只是將原來的獲取數值改成了直接使用引數傳遞過來的值,並沒有什麼太大的改動。接下來看一下fillDown方法,原先的邏輯是在while迴圈中不斷地填充子View,當新新增的子View的下邊緣超出ListView底部的時候就跳出迴圈,現在我們進行如下修改:

可以看到,這裡在makeAndAddView之後並沒有直接使用新增的View來獲取它的bottom值,而是再次使用了一個迴圈來遍歷瀑布流ListView中的所有列,找出所有列中最靠下的那個子View的bottom值,如果這個值超出了ListView的底部,那就跳出迴圈。這樣的寫法就可以保證只要在有子View的情況下,瀑布流ListView中每一列的內容都是填滿的,介面上不會有空白的地方出現。

接下來makeAndAddView()方法並沒有任何需要改動的地方,但是makeAndAddView()方法中呼叫的setupChild()方法,我們就需要大刀闊斧地修改了。

大家應該還記得,setupChild()方法是用來具體設定子View在ListView中顯示的位置的,在這個過程中可能需要用到幾個輔助方法,這裡我們先提供好,如下所示:

這三個方法全部都非常重要,我們來逐個看一下。getColumnToAppend()方法是用於判斷當ListView向下滑動時,新進入螢幕的子View應該新增到哪一列的。而判斷的邏輯也很簡單,其實就是遍歷瀑布流ListView的每一列,取每一列的最下面一個元素,然後再從中找出最靠上的那個元素所在的列,這就是新增子View應該新增到的位置。返回值是待新增位置列的下標和該列最底部子View的bottom值。原理示意圖如下所示:

20151005224724200

然後來看一下getColumnToPrepend()方法。getColumnToPrepend()方法是用於判斷當ListView向上滑動時,新進入螢幕的子View應該新增到哪一列的。不過如果你認為這和getColumnToAppend()方法其實就是類似或者相反的過程,那你就大錯特錯了。因為向上滑動時,新進入螢幕的子View其實都是之前被移出螢幕後回收的,它們不需要關心每一列最高子View或最低子View的位置,而是隻需要遵循一個原則,就是當它們第一次被新增到螢幕時所屬於哪一列,那麼向上滑動時它們仍然還屬於哪一列,絕不能出現向上滑動導致元素換列的情況。而使用的演算法也非常簡單,就是根據當前子View的position值來從mPosIndexMap中獲取該position值對應列的下標,mPosIndexMap的值在setupChild()方法當中填充,這個我們待會就會看到。返回值是待新增位置列的下標和該列最頂部子View的top值。

最後一個clearColumnViews()方法就非常簡單了,它就是負責把mColumnViews快取的所有子View全部清除掉。

所有輔助方法都提供好了,不過在進行setupChild之前我們還缺少一個非常重要的值,那就是列的寬度。普通的ListView是不用考慮這一點的,因為列的寬度其實就是ListView的寬度。但瀑布流ListView則不一樣了,列數不同,每列的寬度也會不一樣,因此這個值我們需要提前進行計算。修改onMeasure()方法中的程式碼,如下所示:

其實很簡單,我們只不過在onMeasure()方法的最後一行新增了一句程式碼,就是使用當前ListView的寬度除以列數,得到的就是每列的寬度了,這裡將列的寬度賦值到mColumnWidth這個全域性變數上面。

現在準備工作都已經完成了,那麼我們開始來修改setupChild()方法中的程式碼,如下所示:

第一個改動的地方是在第33行,計算childWidthSpec的時候。普通ListView由於子View的寬度和ListView的寬度是一致的,因此可以在ViewGroup.getChildMeasureSpec()方法中直接傳入mWidthMeasureSpec,但是在瀑布流ListView當中則需要再經過一個MeasureSpec.makeMeasureSpec過程來計算每一列的widthMeasureSpec,傳入的引數就是我們剛才儲存的全域性變數mColumnWidth。經過這一步修改之後,呼叫child.getMeasuredWidth()方法獲取到的子View寬度就是列的寬度,而不是ListView的寬度了。

接下來在第48行判斷needToMeasure,如果是普通情況下的填充或者ListView滾動,needToMeasure都是為true的,但如果是點選ListView觸發onItemClick事件這種場景,needToMeasure就會是false。針對這兩種不同的場景處理的邏輯也是不一樣的,我們先來看一下needToMeasure為true的情況。

在第49行判斷,如果是向下滑動,則呼叫getColumnToAppend()方法來獲取新增子View要新增到哪一列,並計算出子View左上右下的位置,最後呼叫child.layout()方法完成佈局。如果是向上滑動,則呼叫getColumnToPrepend()方法來獲取新增子View要新增到哪一列,同樣計算出子View左上右下的位置,並呼叫child.layout()方法完成佈局。另外,在設定完子View佈局之後,我們還進行了幾個額外的操作。child.setTag()是給當前的子View打一個標籤,記錄這個子View是屬於哪一列的,這樣我們在trackMotionScroll()的時候就可以呼叫getTag()來獲取到該值,mColumnViews和mPosIndexMap中的值也都是在這裡填充的。

接著看一下needToMeasure為false的情況,首先在第72行呼叫mPosIndexMap的get()方法獲取該View所屬於哪一列,接著判斷是向下滑動還是向上滑動,如果是向下滑動,則將該View新增到mColumnViews中所屬列的末尾,如果是向上滑動,則向該View新增到mColumnViews中所屬列的頂部。這麼做的原因是因為當needToMeasure為false的時候,所有ListView中子元素的位置都不會變化,因而不需要呼叫child.layout()方法,但是ListView仍然還會走一遍layoutChildren的過程,而layoutChildren算是一個完整佈局的過程,所有的快取值在這裡都應該被清空,所以我們需要對mColumnViews重新進行賦值。

那麼說到layoutChildren過程中所有的快取值應該清空,很明顯我們還沒有進行這一步,那麼現在修改layoutChildren()方法中的程式碼,如下所示:

很簡單,由於剛才我們已經提供好輔助方法了,這裡只需要在開始layoutChildren過程之前呼叫一下clearColumnViews()方法就可以了。

最後還有一個細節需要注意,之前在定義mColumnViews的時候,其實只是定義了一個長度為mColumnCount的ArrayList陣列而已,但陣列中的每個元素目前還都是空的,因此我們還需要在ListView開始工作之前對陣列中的每個元素進行初始化才行。那麼修改ListView建構函式中的程式碼,如下所示:

這樣基本上就算是把所有的工作都完成了。現在重新執行一下UIListViewTest專案,效果如下圖所示:

20151006220949075

恩,效果還是相當不錯的,說明我們對ListView的功能擴充套件已經成功實現了。值得一題的是,這個功能擴充套件對於呼叫方而言是完全不透明的,也就是說在使用瀑布流ListView的時候其實仍然在使用標準的ListView用法,但是自動就變成了這種瀑布流的顯示模式,而不用做任何特殊的程式碼適配,這種設計體驗對於呼叫方來說是非常友好的。

另外我們這個瀑布流ListView並不僅僅支援兩列內容顯示而已,而是可以輕鬆指定任意列數顯示,比如將mColumnCount的值改成3,就可以變成三列顯示了。不過三列顯示有點擠,這裡我把螢幕設定成橫屏再來看一下效果:

20151006222043791

測試結果還是比較讓人滿意的。

最後還需要提醒大家一點,本篇文章中的例子僅供參考學習,是用於幫助大家理解原始碼和提升水平的,切誤將本篇文章中的程式碼直接使用在正式專案當中,不管在功能性還是穩定性方面,例子中的程式碼都還達不到商用產品的標準。如果確實需要在專案實現瀑布流佈局的效果,可以使用開源專案 PinterestLikeAdapterView 的程式碼,或者使用Android新推出的RecyclerView控制元件,RecyclerView中的StaggeredGridLayoutManager也是可以輕鬆實現瀑布流佈局效果的。

好的,那麼今天就到這裡了,ListView系列的內容也到此結束,相信大家通過這三篇文章的學習,對ListView一定都有了更深一層的理解,使用ListView時碰到了什麼問題也可以更多從原始碼和工作原理的層次去考慮如何解決。感謝大家可以看到最後。

相關文章