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

作者:閒魚技術——新宿,光酒

目前閒魚的主要業務場景都已經使用 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 分成數據源管理器、控制器、事件回調和刷新配置四大部分。如下圖所示。

數據源管理器:用於數據的管理,裏面就涉及 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;

再來看 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;

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獲取方法。

局部刷新之 LoadMore

LoadMore的實現相對會比較簡單,需要做的主要有兩點:

  1. 清理widgets緩存,防止不算加載的過程中內存佔用過大;保存與 _childElements 中 index 相同的 widget;這裏有一個需要特別注意的點:要過濾爲 null 的 widget,否則這個位置的 widget 無法正常展示;(_childWidgets 最後一個 index 會是一個爲 null 的值,具體爲什麼插入一個爲 null 的 widget 大家可以閱讀源碼尋找答案)
  1. 最後打髒sliver,重新layout children:

使用 Dart DevTools 的 TimeLine 數據對比兩種 LoadMore 方式的耗時情況如下圖:

SetState 的 timeline:

LoadMore 的 timeline:

局部刷新之 Delete

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

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

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

這個過程同時會維護好 child 的 RenderObject 中 ParentData 的 previousSibling 和 nextSibling 的關係;

接下來調整 _childElements 中 Element 與 index 的對應關係;

最後更新每一個 child 的 slot:

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

局部刷新之 Insert

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

Element 複用能力

不管是 iOS 的 UITableView、UICollectionView 還是 Android 的 RecyclerView,都支持 cell 的複用能力;在 Flutter 的列表容器中,在不修改 framework 層的情況下,是否能夠實現 element 的複用呢?

首先我們來分析 element 被回收的過程,SliverMultiBoxAdaptorElement 通過 _childElements 來緩存 elements,當滾動超出 viewport 的顯示以及預加載範圍或者數據源發生變化,會通過調用 collectGarbage 方法回收不需要的 elements;

我們可以通過重寫 collectGarbage 的方式,在不使用 keepAlive 的情況下,截獲本該 deactive 的 child element,放入緩衝池中;在需要創建 element 的時候,優先從緩衝池獲取;

雖然原理比較簡單,也會遇到一些需要注意的點:需要緩存的 element 需要通過 remove 方法,將它從 childList 中移除,而不是真正的銷燬 element, 如果將它被置爲 defunct 狀態,這樣就無法複用了。

因爲業務中卡片佈局基本相同,這裏面複用的邏輯做的相對簡單,事實上針對卡片類型複用才能發揮出最好的效果。

分幀渲染

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

使用方可以爲每個 item 定製較爲簡單的 Widget,這樣在一幀任務較多時,通過一定的策略,先 build placeholder 進行渲染,延遲到之後幾幀再進行實際 cell 的 build。由於 viewport 上下都有緩衝區,在延後的幀設置較少時,用戶並沒有機會看到 placeholder,所以業務上並不會有影響。placeholder 最明顯的作用是削峯,較長的一幀耗時會被下幾幀瓜分。

下面數據是使用複雜商品 card 在瀑布流中的場景,使用機型爲 Pixel XL。從數據上看,分幀使平均耗時有所增加,但是90、99、最長幀耗時,都有明顯的降低,丟幀數也有所減少。

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

實際應用場景

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

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

總結和展望

經過對列表容器能力的不斷完善、流暢度方面不斷優化,目前 PowerScrollView 已經能夠更好的支撐閒魚流式佈局下的業務,給用戶提供更好的使用體驗。

但在一些低端機型上,長列表的表現仍然不能讓人滿意;瀑布流等一些需要複雜佈局計算的場景,如何更好的優化佈局計算過程,這些都是需要我們繼續探索的方向。

目前複用實現還比較粗糙,未來也會深入到Flutter引擎,尋找提升複用能力的方法,讓 PowerScrollView 真正成爲一個高效流式佈局的解決方案。

另外在端到端研發方面,我們在探索將列表容器與動態模板相結合,實現端雲一體的頁面搭建解決方案。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章