Flutter 高效能、多功能的全場景滾動容器,一定要看!

閒魚技術發表於2021-01-15

目前閒魚的主要業務場景都已經使用 Flutter 來實現,其中流式佈局是最常見的頁面佈局場景(如搜尋、商品詳情等)。隨著業務的快速迭代和業務複雜度的不斷提升,對流式場景的能力和效能要求也越來越高;

  • 在能力方面,最常見的如卡片曝光、滾動錨點、瀑布流佈局等能力,隨著業務和需求的不斷變化,Flutter原生和一些開源解決方案,漸漸無法滿足我們需求。

  • 效能方面,流式場景下的列表滾動流暢度問題隨著業務複雜度的增加而逐漸惡化,亟需解決以提升使用者的使用體驗。

針對以上在業務中面臨的問題,我們設計了一套流式場景下通用的頁面佈局解決方案,我們將其命名為 PowerScrollView。

整體架構設計

在架構設計之前,我們充分調研了原生 Native 的滾動容器:UICollectionView(iOS) 和 RecyclerView(Android)。其中UICollectionView 的 Section(段落)理念令我們印象深刻,RecyclerView 的架構設計也啟發了我們。由於 Flutter 的獨特性,我們不能將其照搬過來,所以我們的目標是結合 Native 成熟的滾動容器,加以 Flutter 的特點,設計出更加優秀的滾動容器。
Flutter 原生有常用的 ListView、GridView,他們佈局較為單一,功能較為簡單。官方也提供了CustomScrollView的進階Widget,CustomScrollView由多個 Sliver 進行拼接,以適應更復雜的使用場景,我們將基於 CustomScrollView 進行設計。
從使用角度出發,整個列表由若干個 Section 組成,又將 Section 分為 header、content、footer 三部分,header 為段落的頭部,一般可作為 Section 的頭部裝飾,支援是否吸頂;footer 為段落的尾部,作為 Section 的尾部裝飾。列表擁有下拉重新整理與載入更多能力;content 為 Section 的正文,支援常見的佈局方式:列表、網格、瀑布流以及自定義。Section 的 content 由任意個 cell 組成,cell 即為列表最小粒度的 item。
從 Flutter 原生容器出發,CustomScrollView 支援任意多個 Sliver 的組合,Sliver 提供了 SliverList、SliverGrid、SliverBox 等,已基本符合了我們要求。我們將 Section 的 header 和 footer 各對應一個 SliverBox,content 對應 SliverList 或SliverGrid,再單獨為瀑布流佈局開發一個 SliverWaterfall;再在整個列表的頭部和尾部插入用於重新整理載入更多的 Sliver。
我們將 PowerScrollView 分成資料來源管理器、控制器、事件回撥和重新整理配置四大部分。如下圖所示。

Flutter 高效能、多功能的全場景滾動容器,一定要看!

資料來源管理器:用於資料的管理,裡面就涉及 Sections 初始化與通常的增刪改查。
控制器:主要用於控制 PowerScrollView 的重新整理、載入更多,控制滾動到某個位置等。
事件回撥:我們將事件分類,外部使用時可只監聽需要的回撥。
重新整理配置:為了提升重新整理的靈活性,我們將重新整理單獨抽出,既可以使用我們提供的標準重新整理組建,也可自定義。

功能完善

我們為 PowerScrollView 完善了業務使用的核心訴求,包括自動曝光、滾動到某個 index 、瀑布流、重新整理載入更多等能力。下面將重點介紹前兩部分。

自動曝光能力

在 Flutter 中,通常不得不將曝光放在 build 函式中,這使得曝光會錯亂,不在螢幕上但是在螢幕緩衝區的部分將會被錯誤曝光,且有多次曝光問題,程式碼臃腫混亂,這都使得業務層非常頭疼。曝光能力是各種業務都必須的核心訴求,我們在 PowerScrollView 中統一進行了封裝,透過事件回撥給使用者。
前面我們知道,在 PowerScrollView 中,我們用 cell 封裝了最小粒度的 item,因為對 item 的封裝,使得我們的掌控力大大增強。正因為此,我們自定義了 cell 的 StatefulElement,在 element 的生命週期中 mount、unmount 記錄當前 element,利用 InheritedWidget ,將樹上的 element 維護在外面的列表中。
在 PowerScrollView 的滾動過程中,我們會遍歷檢查 element 陣列,篩選螢幕中的元素進行曝光回撥。其中被篩選掉的即為緩衝區的元素,同時維護個陣列避免單元素當次螢幕中多次曝光。
為了減少滾動中的多次遍歷檢查 element 陣列,我們加入了控制滾動取樣率的可配引數,透過此引數,我們可以控制滾動一定距離後才進行檢查。
在複雜場景中,會存在 cell 高度先為 0,下載模板渲染後再撐開的情況,這種情況下整個 element list 資料會非常大,且資料並不正確,我們需要過濾掉這種。但是當 cell 重新整理之後,有了真實的高度,我們需要進行正確的曝光。所以我們在 cell 中監聽了 size 的變化,當高度由 0 到非 0 的時候,通知上層進行一次曝光。

滾動到某個index

Flutter 本身提供了滾動到 position 距離的能力,但一般業務場景下,我們不知道要滾動的距離,最多知道要滾動到第幾個,這使得在 Flutter 側很多互動無法實現。這個問題我們會分幾種場景進行分析。
場景一:當要滾動的目標 index 的 cell 在檢視樹中(當前螢幕及緩衝區),由於我們已經維護了一個螢幕及緩衝區的element陣列,我們可以遍歷找到,然後將其滾動到可見區域即可。
場景二:當要滾動的目標 index 的 cell 不在檢視樹中時,首先我們根據當前螢幕的 index 與目標 index 進行比較,判斷是需要往上滾動還是往下滾動。然後,以較快的速度進行特定距離的滾動,滾動之後再遞迴,直到找到目標 index。由於滾動距離與時間的不確定性,極端情況下會沒有動畫效果,普通的動畫效果可能也會有些生硬。

效能最佳化

為什麼要做區域性重新整理

在實際的流式業務場景中,經常會因為資料來源的更新而重新整理整個列表容器:例如載入了下一頁的資料、刪除或者插入某一個 cell,甚至某個 cell 的一個按鈕狀態的變化;
重新整理範圍過大往往是造成列表容器卡頓、流暢度降低的主要原因,嚴重影響了使用者的操作體驗。所以我們需要儘量減少 Widget tree 打髒重新整理的範圍,減少 Element rebuild 的呼叫,實現區域性重新整理的能力。

Viewport 重新整理的過程

為什麼說整個列表容器打髒重新整理會帶來嚴重的耗時呢?我們來簡單看一下 Viewport 的重新整理過程。
列表容器被打髒之後,會做兩個關鍵的操作:
Viewport 所有 sliver 的 Element 都會 rebuild;
Viewport 也會重新 layout,進而所有的 sliver 也會重新 layout;
我們來先看 Viewport layout 的過程:這個方法的核心,首先找到當前的 center sliver(預設是第一個child)的位置,然後向上、向下遍歷Viewport每一個sliver;每個 child sliver 根據當前 Viewport 在 Scrollview 中的 scrollOffset,Viewport的大小以及cacheExtent大小等資訊 (SliverConstraints),計算當前需要展示的child的index範圍,layout 每一個在可顯示範圍的child;
以下圖例,SliverList可視範圍內需要layout的child index為2\~3;SliverGrid需要layout的child index為0\~3;

Flutter 高效能、多功能的全場景滾動容器,一定要看!

再來看 Viewport 所有 sliver 的 Element rebuild 的過程,這個過程才是列表容器重新整理耗時的關鍵;
我們先來看一下常見的幾種佈局 SliverList、SliverGrid 以及我們自定義的瀑布流佈局 SliverWaterfall 的實現,它們都繼承自SliverMultiBoxAdaptorWidget,一個管理多 child(Box模型)的 sliver 的基類;它對應的 Element 是 SliverMultiBoxAdaptorElement,主要負責 child 的建立、更新、移除等生命週期相關的工作,這正是區域性重新整理需要精細處理的地方。
SliverMultiBoxAdaptorElement 內部維護兩個 Map,快取 child element 以及 child widget,在 ViewPort 需要的時候(上面提到的layout過程)lazily build 自己的 child;

Flutter 高效能、多功能的全場景滾動容器,一定要看!

rebuild 過程之所以耗時是因為要清空所有 child widget 快取,重新 build child widget,update child Element;如果遇到資料的變化,例如 insert、delete,很有可能導致 element 無法複用,這樣 rebuild 的成本會更高。

區域性重新整理的實現原理

摸清了基本原理之後,我們就在思考,當列表容器內容發生變化的時候(比如 insert、delete、LoadMore),是否可以做出一些最佳化,只讓發生變化的部分去 build、layout 呢?
首先我們認為 sliver 的 Element 全部 rebuild 的做法過於簡單粗暴,我們可以透過更精準的控制 sliver element 中,childWidgets 與 childElements,來實現區域性重新整理的目的;
下面我們來看看針對與具體的場景,如何實現精準的 childWidgets 與 childElements 控制,實現區域性重新整理的能力的。

可變的 child count

在常見的需要區域性重新整理的場景,容器元素的數量往往會發生變化。在常見的 CustomScrollview 使用中,childCount 都是建立時指定的,當 childCount 方式變化,就需要重新 build 列表容器;
第一步就是避免因為 sliver 內部元素數量變化,必須重新build整個容器的問題;
雖然也可以使用childCount為空,根據builder返回null來決定是否為最後一個child的方式實現可變childCount的目的,但這種方式並不太符合常用的習慣,對使用方也會增加額外成本,所以並未採用這種方式。
做法比較簡單,透過繼承自SliverChildBuilderDelegate,修改childCount獲取方法。

Flutter 高效能、多功能的全場景滾動容器,一定要看!

區域性重新整理之 LoadMore

LoadMore的實現相對會比較簡單,需要做的主要有兩點:
1.清理widgets快取,防止不算載入的過程中記憶體佔用過大;儲存與 _childElements 中 index 相同的 widget;這裡有一個需要特別注意的點:要過濾為 null 的 widget,否則這個位置的 widget 無法正常展示;(_childWidgets 最後一個 index 會是一個為 null 的值,具體為什麼插入一個為 null 的 widget 大家可以閱讀原始碼尋找答案)

Flutter 高效能、多功能的全場景滾動容器,一定要看!

2.最後打髒sliver,重新layout children:

Flutter 高效能、多功能的全場景滾動容器,一定要看!

Flutter 高效能、多功能的全場景滾動容器,一定要看!

使用 Dart DevTools 的 TimeLine 資料對比兩種 LoadMore 方式的耗時情況如下圖:
SetState 的 timeline:

Flutter 高效能、多功能的全場景滾動容器,一定要看!

LoadMore 的 timeline:

Flutter 高效能、多功能的全場景滾動容器,一定要看!

區域性重新整理之 Delete

首先整理 childWidgets 的內容,根據 delete 的 index,重新調整 childWidgets 中 widget 與 index 的對應關係;

Flutter 高效能、多功能的全場景滾動容器,一定要看!

接下來是 _childElements 的處理,如果需要刪除的 index 還未建立,只需要把當前 sliver 的 RenderObject 的 layout 資訊標髒,重新 layout 自己即可。注意這個過程是不會重新 layout 當前 viewport 已經展示的 child 的

Flutter 高效能、多功能的全場景滾動容器,一定要看!

否則要找到要刪除的 child element,deactivate 對應的 element,其對應的 RenderObject 從 Render tree 上移除:

Flutter 高效能、多功能的全場景滾動容器,一定要看!

這個過程同時會維護好 child 的 RenderObject 中 ParentData 的 previousSibling 和 nextSibling 的關係;
接下來調整 _childElements 中 Element 與 index 的對應關係;
最後更新每一個 child 的 slot:

Flutter 高效能、多功能的全場景滾動容器,一定要看!

最後將sliver的RenderObject標髒,下一幀重新layout重新整理。

Flutter 高效能、多功能的全場景滾動容器,一定要看!

區域性重新整理之 Insert

Insert的實現過程與上面的類似,可以根據上面的過程自行實現,這裡就不做贅述;

Element 複用能力

不管是 iOS 的 UITableView、UICollectionView 還是 Android 的 RecyclerView,都支援 cell 的複用能力;在 Flutter 的列表容器中,在不修改 framework 層的情況下,是否能夠實現 element 的複用呢?
首先我們來分析 element 被回收的過程,SliverMultiBoxAdaptorElement 透過 _childElements 來快取 elements,當滾動超出 viewport 的顯示以及預載入範圍或者資料來源發生變化,會透過呼叫 collectGarbage 方法回收不需要的 elements;

Flutter 高效能、多功能的全場景滾動容器,一定要看!

我們可以透過重寫 collectGarbage 的方式,在不使用 keepAlive 的情況下,截獲本該 deactive 的 child element,放入緩衝池中;在需要建立 element 的時候,優先從緩衝池獲取;
雖然原理比較簡單,也會遇到一些需要注意的點:需要快取的 element 需要透過 remove 方法,將它從 childList 中移除,而不是真正的銷燬 element, 如果將它被置為 defunct 狀態,這樣就無法複用了。
因為業務中卡片佈局基本相同,這裡面複用的邏輯做的相對簡單,事實上針對卡片型別複用才能發揮出最好的效果。

分幀渲染

在實際的滑動過程中,如果一幀的時間內需要 build 過多的 cell ,很容易引起掉幀的情況,使用者會感覺到卡頓。為了減少這種情況,我們在 cell 層面引入了 placeholder 的機制:

Flutter 高效能、多功能的全場景滾動容器,一定要看!

使用方可以為每個 item 定製較為簡單的 Widget,這樣在一幀任務較多時,透過一定的策略,先 build placeholder 進行渲染,延遲到之後幾幀再進行實際 cell 的 build。由於 viewport 上下都有緩衝區,在延後的幀設定較少時,使用者並沒有機會看到 placeholder,所以業務上並不會有影響。placeholder 最明顯的作用是削峰,較長的一幀耗時會被下幾幀瓜分。
下面資料是使用複雜商品 card 在瀑布流中的場景,使用機型為 Pixel XL。從資料上看,分幀使平均耗時有所增加,但是90、99、最長幀耗時,都有明顯的降低,丟幀數也有所減少。

Flutter 高效能、多功能的全場景滾動容器,一定要看!

值得注意的是,對於 cell 過於複雜的場景,即使一幀 build 一個都會超時,那麼以 cell 為最小粒度的分幀就沒有最佳化效果了,類比到在效能非常差的手機上,普通複雜的 cell 的分幀可能會使流暢度降低。這個時候需要降低 cell 複雜度或者縮小分幀的粒度。

實際應用場景

PowerScrollView 已經在閒魚多個核心頁面線上全量使用,如下圖:

Flutter 高效能、多功能的全場景滾動容器,一定要看!

完善的能力、優良的效能、較低的接入成本,都使得使用方受益頗多。

總結和展望

經過對列表容器能力的不斷完善、流暢度方面不斷最佳化,目前 PowerScrollView 已經能夠更好的支撐閒魚流式佈局下的業務,給使用者提供更好的使用體驗。
但在一些低端機型上,長列表的表現仍然不能讓人滿意;瀑布流等一些需要複雜佈局計算的場景,如何更好的最佳化佈局計算過程,這些都是需要我們繼續探索的方向。
目前複用實現還比較粗糙,未來也會深入到Flutter引擎,尋找提升複用能力的方法,讓 PowerScrollView 真正成為一個高效流式佈局的解決方案。
另外在端到端研發方面,我們在探索將列表容器與動態模板相結合,實現端雲一體的頁面搭建解決方案。

閒不住?來閒魚!

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69900359/viewspace-2750154/,如需轉載,請註明出處,否則將追究法律責任。

相關文章