背景
目前閒魚業務中無論是首頁還是搜索頁都有大量可以落地瀑布流的場景,而在Flutter原生中只提供了ListView, GridView,無法提供自定義佈局的能力。
而在社區中,一般瀑布流的解決方案都是基於SliverMultiBoxAdaptor對其performLayout進行定製,主要存在的問題是缺乏複用機制,並且在很多情形下容易出現重複佈局,在線上業務的複雜場景下容易出現幀數偏低的問題, 閃屏的問題。同時對於Child生命週期,打點曝光等一系列基礎功能的支持還是一片空白的狀態。
所以,我們迫切需要一個更為通用的可以解決複雜佈局過程同時能夠對基礎能力進行擴充的列表視圖解決方案。
Flutter中的列表視圖簡介
1. Scrollable
Scrollable是一個StatefulWidget, 職責是監聽用户的手勢輸入。其State的build方法會返回一個含有Listener和RawGestureDetector的ViewportScrollPosition用於描述其位置信息,並在其內部定義了 onStart, onUpdate, onEnd等回調。Scrollable中的每一次滑動的開始到結束都對應於一個Darg對象,並且會發送滑動的通知。而Viewport則負責對通知進行監聽。
2. Sliver
Flutter有兩種佈局體系 Box, Sliver。在layout的過程中,每個Sliver 都接收 SliverConstraints 計算返回一個 SliverGeometry,可以類比於RenderBox 接收 BoxConstraints 返回一個 Size。Sliver由Viewport統一來負責進行管理。
3. Viewport
A widget that is bigger on the inside.
Viewport持有一個或多個Sliver。Scrollable將offset傳遞給Viewport, 由Viewport決定哪些Sliver應該是Visible。Viewport本質上是一個MultiChildRenderObjectWidget,也就是整個滾動視圖的主要渲染邏輯都在Viewport中完成。
而在performLayout中,_attemptLayout會以center為中心,先佈局leading方向的child,再佈局trailing方向的child。其中只有dirty的child會被佈局。
如果_attemptLayout返回了一個非0的correction, 就會打斷當前佈局的過程,需要對offset進行調整後重新開始佈局,最多隻能連續打斷10次。
correction用於調整,舉個?,比如targetScrollOffset很遠,而在scroll的過程中child用完了,就需要讓Sliver通知Viewport, 同時進行修正。但是Flutter並不是通過不斷對child進行layout來改變child位置實現的滑動效果,這樣的重繪過程顯然效率太低,顯然RenderObject不需要被改變,是可以複用的。但是佈局一般只發生在添加新child的過程中,而滑動效果則發生在paint過程中。
Viewport通過PaintingContext間接持有Canvas進行繪製。Offset指笛卡爾座標系下的座標,與Axis方向無關。繪製時只需改變對應RenderObject的Offset即可實現滾動的效果, 這樣就不必重新創建RenderObject。所以我們如果想實現性能較高的列表視圖,就要嘗試去減少重新佈局Child。在對Flutter的列表佈局有了基本瞭解後,我們再來看瀑布流的實現過程。
瀑布流的實現邏輯
WatetfallFlow的佈局過程中需要指定Child的Offset,然後對其進行佈局。所以需要繼承SliverMultiBoxAtaptor,依賴於其將SliverConstraints轉換為BoxConstraints的能力。我們也可以使用其SliverBoxChildManager, 方便控制Child的懶加載過程。
核心邏輯
在瀑布流中由於同一行的child具有先後關係,需要按照順序來進行佈局,所以瀑布流相比於GridView更類似於ListView,而瀑布流的佈局過程也借鑑了ListView。整個瀑布流的佈局邏輯圍繞三個核心展開:
在滑動的過程中找到其邊緣最近的child,在其後進行添加child,並對child進行layout.
在child離開一定距離後進行GC.
保證layout方法被儘可能少的調用. 上文有提過layout會調用performLayout而不能直接進行paint.
其中核心的數據結構是ParentData.
ParentData位於Child中,Child將其傳遞給Sliver,Sliver又將其傳遞至上層,其中儲存了全部的佈局信息。在performLayout中,child在調用layout時所使用的佈局信息就來自ParentData。在Child的添加過程中,用一個Manager存儲前後邊緣所有Child的ParentData,在添加時尋找邊緣最靠近可見區域的Child,對其ParentData進行設置並替換當前Child.
佈局的核心邏輯是對從最開始的Child到最末的Child進行佈局。如果_layoutedChilds中已經有記錄,則跳過其佈局過程。
對離開視圖的child進行GC,同時記得將數組中的child清除.
在開發過程中出現了幀數偏低的問題,發現是Child在performLayout的過程中會出現重複佈局。解決方法是我們不僅記錄leading, trailing邊緣的child。而且用對已經layout過的child進行記錄,粗暴直接但是有效,這樣做也可以提供單獨update單個child的Layout能力。在更新Child的佈局時也只需從記錄中將對應child移除。
相比於原生視圖,我們可以通過獲取所有Child的ParentData信息,可以為上層接口提供實時並且有效的回調.。這樣就可以根據每個Child的實時位置來提供生命週期,曝光打點的能力。所以可以對每個child的座標進行監聽,從而獲得精準的曝光信息。
從瀑布流到容器
在瀑布流的開發過程中也暴露出了一些設計上的問題。
比如瀑布流的具體渲染邏輯都在RenderObject中進行,太過底層顯然是不利於業務方根據業務進行定製。
又比如由於沒有複用的機制,在視圖層級較為複雜時幀數會由於重複渲染而不可避免的降低。
借鑑native思路重新設計後將整體容器分為3個部分進行設計。
1、delegate
主要管理child生命週期並響應手勢,由於我們可以得到每個可見Child的parentData屬性,所以可在滾動時進行實時的通知。從而對每個Child的位置監聽,從開始創建進入緩衝區,到從緩衝區進入可見區域。手勢則來自於頂層的Scrollable。
2、layout
主要負責佈局所有的Child。將具體的佈局邏輯抽離出,類似於iOS中的UICollectionViewLayout。但是在開發過程中也出現了一些問題,原因主要來自於Flutter特殊的信息傳遞方式,就是我們不能採用native的方式一次性計算出所有child的佈局。因為RenderBox需要接收一個BoxConstraints才能返回一個size。
3、reuser
reuser則在RenderObject層面,對Child進行基於類型的複用並實現局部更新的操作。需要將SliverMultiBoxAdaptor和其Element拷貝一份進行重寫,改變其mount的邏輯,方案還在探索和調研之中,希望能在後續的文章中和大家見面!
性能數據
應用於主搜索頁進行自動化測試,先前在54.7幀左右,換用瀑布流後為56.2,大概提升了1.5幀。
內存上則有略微的升高情況。
展望
目前Flutter的列表視圖中仍然有很多問題需要處理,比如瀑布流中scrollTo的能力還無法實現,內存的使用情況等和原生相比仍然有不小的差距, 對於Flutter側的複用的穩定性和兼容性上還存在問題,閒魚在Flutter化上還有很多路要走。
PS0: 文中代碼基於Flutter 1.12.13。
PS1: 文中譬如Viewport,既代指Widget本身, 又代指其對應的RenderObject。
PS2: 文中涉及到的代碼經過刪改, 僅供參考。