![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLicGcq5COjF2NyMjY3MTZ3kTO0MmYwkDO0gjN1ADOycTZzY2N28CX5d2bs92Yl1iclB3bsVmdlR2LcNWaw9CXt92Yu4GZjlGbh5yYjV3Lc9CX6MHc0RHaiojIsJye.jpg)
作者|董家建(夜瀾)
編輯|橙子君
出品|阿裡巴巴新零售淘系技術
目前閑魚業務中無論是首頁還是搜尋頁都有大量可以落地瀑布流的場景,而在Flutter原生中隻提供了ListView, GridView,無法提供自定義布局的能力。
而在社群中,一般瀑布流的解決方案都是基于SliverMultiBoxAdaptor對其performLayout進行定制,主要存在的問題是缺乏複用機制,并且在很多情形下容易出現重複布局,線上上業務的複雜場景下容易出現幀數偏低的問題, 閃屏的問題。同時對于Child生命周期,打點曝光等一系列基礎功能的支援還是一片空白的狀态。
是以,我們迫切需要一個更為通用的可以解決複雜布局過程同時能夠對基礎能力進行擴充的清單視圖解決方案。
Flutter中的清單視圖介紹
▐ Scrollable
Scrollable是一個StatefulWidget, 職責是監聽使用者的手勢輸入。其State的build方法會傳回一個含有Listener和RawGestureDetector的Viewport。ScrollPosition用于描述其位置資訊,并在其内部定義了 onStart, onUpdate, onEnd等回調。Scrollable中的每一次滑動的開始到結束都對應于一個Darg對象,并且會發送滑動的通知。而Viewport則負責對通知進行監聽。
▐ Sliver
Flutter有兩種布局體系 Box, Sliver。在layout的過程中,每個Sliver 都接收 SliverConstraints 計算傳回一個 SliverGeometry,可以類比于RenderBox 接收 BoxConstraints 傳回一個 Size。Sliver由Viewport統一來負責進行管理。
▐ 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會被布局。
do {
correction = _attemptLayout(mainAxisExtent, crossAxisExtent, offset.pixels + centerOffsetAdjustment);
if (correction != 0.0) {
offset.correctBy(correction);
} else {
if (offset.applyContentDimensions(
math.min(0.0, _minScrollExtent + mainAxisExtent * anchor),
math.max(0.0, _maxScrollExtent - mainAxisExtent * (1.0 - anchor)),
))
break;
}
count += 1;
} while (count < _maxLayoutCycles);
如果attemptLayout傳回了一個非0的correction, 就會打斷目前布局的過程,需要對offset進行調整後重新開始布局,最多隻能連續打斷10次(maxLayoutCycles)。
correction用于調整,舉個🌰,比如targetScrollOffset很遠,而在scroll的過程中child用完了,就需要讓Sliver通知Viewport, 同時進行修正。但是Flutter并不是通過不斷對child進行layout來改變child位置實作的滑動效果,這樣的重繪過程顯然效率太低,顯然RenderObject不需要被改變,是可以複用的。
但是布局一般隻發生在添加新child的過程中,而滑動效果則發生在paint過程中。
void _paintWithContext(PaintingContext context, Offset offset) {
// 重新布局就不需要調整offset了.
if (_needsLayout)
return;
_needsPaint = false;
paint(context, offset);
}
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(對應firstIndex)到最末的Child(對應targetLastIndex)進行布局。如果_layoutedChilds中已經有記錄,則跳過其布局過程。
for (int index = firstIndex; index <= targetLastIndex; ++index) {
final SliverGeometry gridGeometry = layout.getGeometryForChildIndex(index);
final BoxConstraints childConstraints = gridGeometry.getBoxConstraints(constraints);
RenderBox child = childAfter(trailingChildWithLayout);
if (child == null || indexOf(child) != index) {
// 重新擷取Child.
child = _createAndLayoutChildIfNeeded(childConstraints, after: trailingChildWithLayout);
if (child != null && indexOf(child) == index) {
_layoutedChilds.add(index);
}else if (child == null) {
// Child已經用盡.
break;
}
} else {
if (!_layoutedChilds.contains(index)) {
_layoutChildIfNeeded(child, parentUsesSize: true);
_layoutedChilds.add(index);
}
}
trailingChildWithLayout = child;
}
對離開視圖的child進行GC,同時記得将數組中的child清除。
if (firstChild != null) {
// 上一次的最先最末Child.
final int oldFirstIndex = indexOf(firstChild);
final int oldLastIndex = indexOf(lastChild);
// 前後需要GC的child數量
final int leadingGarbage = (firstIndex - oldFirstIndex).clamp(0, childCount);
final int trailingGarbage = targetLastIndex == null
? 0 : (oldLastIndex - targetLastIndex).clamp(0, childCount);
// GC
collectGarbage(leadingGarbage, trailingGarbage);
_layoutedChilds.sort();
_layoutedChilds.removeRange(0, leadingGarbage);
_layoutedChilds.removeRange(layoutedChilds.length - 1 - trailingGarbage,
layoutedChilds.length - 1);
} else {
collectGarbage(0, 0);
}
在開發過程中出現了幀數偏低的問題,發現是Child在performLayout的過程中會出現重複布局。解決方法是我們不僅記錄leading, trailing邊緣的child。而且用對已經layout過的child進行記錄,粗暴直接但是有效,這樣做也可以提供單獨update單個child的Layout能力。在更新Child的布局時也隻需從記錄中将對應child移除。
相比于原生視圖,我們可以通過擷取所有Child的ParentData資訊,可以為上層接口提供實時并且有效的回調.。這樣就可以根據每個Child的實時位置來提供生命周期,曝光打點的能力。是以可以對每個child的坐标進行監聽,進而獲得精準的曝光資訊。
從瀑布流到容器
在瀑布流的開發過程中也暴露出了一些設計上的問題。比如瀑布流的具體渲染邏輯都在RenderObject中進行,太過底層顯然是不利于業務方根據業務進行定制。又比如由于沒有複用的機制,在視圖層級較為複雜時幀數會由于重複渲染而不可避免的降低。
借鑒native思路重新設計後将整體容器分為3個部分進行設計。
▐ delegate
主要管理child生命周期并響應手勢,由于我們可以得到每個可見Child的parentData屬性,是以可在滾動時進行實時的通知。進而對每個Child的位置監聽,從開始建立到進入緩沖區,到從緩沖區進入可見區域。手勢則來自于頂層的Scrollable。
▐ layout
主要負責布局所有的Child。将具體的布局邏輯抽離出,類似于iOS中的UICollectionViewLayout。但是在開發過程中也出現了一些問題,原因主要來自于Flutter特殊的資訊傳遞方式,就是我們不能采用native的方式一次性計算出所有child的布局。因為RenderBox需要接收一個BoxConstraints才能傳回一個size。
▐ reuser
reuser則在RenderObject層面,對Child進行基于類型的複用并實作局部更新的操作。需要将SliverMultiBoxAdaptor和其Element拷貝一份進行重寫,改變其mount的邏輯,方案還在探索和調研之中,希望能在後續的文章中和大家見面!
性能資料
應用于主搜尋頁進行自動化測試,先前在54.7幀左右,換用瀑布流後為56.2,大概提升了1.5幀。
記憶體上則有略微的升高情況。
後續計劃
目前Flutter的清單視圖中仍然有很多問題需要處理,比如瀑布流中scrollTo(int index)的能力還無法實作,記憶體的使用情況等和原生相比仍然有不小的差距, 對于Flutter側的複用的穩定性和相容性上還存在問題,閑魚在Flutter化上還有很多路要走。
關注「淘系技術」微信公衆号,一個有溫度有内容的技術社群~