一套Flutter混排瀑布流解決方案

作者:閒魚技術——岑彧

背景

流式佈局,這是一種當前無論是前端,還是Native都比較流行的一種頁面佈局。特別是對於商品這樣的Feeds流,無論是淘寶,京東,美團,還是閒魚。都基本上以多列瀑布流進行呈現,容器列數固定,然後每個卡片高度不一,形成參差不齊的多欄佈局。

對於Native來說,無論是iOS還是Android,CollectionView和RecyclerView都能滿足我們的絕大部分場景了。不過目前閒魚很多業務場景都是在Flutter上進行實現的,當時Flutter官方只提供了ListView和GridView的實現,沒有對瀑布流進行支持。

目前社區中有兩個開源的解決方案,分別是WaterFallFlow和FlutterStaggeredGridView。但是在閒魚的場景中都有一些無法滿足的痛點。前者無法支持RecyclerView中StaggeredGridLayoutManager中setFullSpan這樣的橫跨全屏的橫條卡片混排能力能力,後者在不提前預設置卡片高度的情況下有比較嚴重的性能問題,以及在多Sliver的場景下會有滾動錯誤的功能性問題。而在目前閒魚的業務中,無論是搜索結果還是首頁的同城頁面,都會有混排瀑布流的需求。

所以我們決定參考RecyclerView中StaggeredGridLayoutManager的佈局思路實現一套支持普通流式卡片和橫跨全屏的橫條卡片混排的流式佈局,如圖所示:

原理分析與佈局流程

其實瀑布流佈局和ListView和GridView一樣,就是按照不同的策略將多個卡片進行尺寸計算和位置計算,然後將它們排列到一起,組成一個超過一屏,可滾動的佈局。所以整個佈局策略包括兩個過程,首先是對卡片進行尺寸計算,計算結果決定了卡片在滾動佈局中的大小。然後卡片進行位置計算,計算結果決定了卡片在滾動佈局中的座標。有了大小和座標,就可以完成整個滾動容器的佈局。下面我會對網格佈局(GridView)和瀑布流佈局(FlowView)的佈局策略進行一個對比,讓大家能更清楚的瞭解佈局過程的細節。

Flutter中網格佈局整個佈局的源碼都在flutter/lib/src/rendering/sliver_grid.dart的performLayout方法中,我們下面跟着源碼來分析一下整個佈局流程。感興趣的同學也可以結合源碼食用本文,風味更佳。

網格佈局

尺寸計算過程

我們先來分析一下網格佈局的卡片尺寸計算過程。這是一個GridView的常用初始化參數,我省略了一些和尺寸計算無關的參數。

GridView.count({
  @required int crossAxisCount,
  double childAspectRatio = 1.0,
})

影響佈局的參數其實就是crossAxisCount(列數)和childAspectRatio(卡片縱橫比)。有了這兩個參數其實卡片的尺寸就很好計算了,首先先用crossAxisCount來對屏幕寬度進行等分,確定卡片的寬度,然後我們再根據這個childAspectRatio參數來計算得到卡片的高度。網格佈局的卡片尺寸就可以確定下來了。
計算過程如圖所示:

位置計算過程

在端側,因爲一個滾動容器中的卡片數量可能會非常大,所以我們不可能對所有的卡片都進行佈局,內存和運算時間都是無法接受的。我們只會佈局在屏幕中以及緩存區裏的卡片,之外的卡片我們會進行回收。等用戶向下滑動的時候,把屏幕下方的卡片創建並佈局,然後把已經劃出屏幕的卡片進行回收。向上滑動的過程也是一樣。所以我們會對從上到下和從下到上的位置計算過程進行分析。

我們先分析從上到下佈局的過程。對於網格佈局來說,每一個卡片的寬度和高度都是在位置計算流程開始之前就可以提前計算得出的。我們暫且把每個卡片的左上角叫做佈局座標點,我們來分析一下網格佈局中這個座標如何計算得出。

我們先來計算一下縱座標,我們用卡片的index對crossAxisCount進行整除,然後再用結果乘上卡片的高度,就可以得到卡片的縱座標了。

對於橫座標,我們已經根據crossAxisCount來對屏幕寬度進行了等分,那麼每個卡片的橫座標就很容易得到了,我們用卡片的index對crossAxisCount進行整除取餘,這樣就能得到卡片在某一行中的順序(即第幾列),然後再乘上卡片的寬度,這樣就可以得到卡片的橫座標了。

例如列數爲2,卡片寬度和高度都爲100的一個網格佈局,那麼第四個卡片(index爲3)的橫座標爲(3%2)×100爲1,縱座標爲 (3~/ 2)×100爲100,所以座標爲(100,100)。

計算過程如圖所示:

整個佈局關鍵源碼如下:

// 卡片尺寸計算
final double usableCrossAxisExtent = constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1);
    final double childCrossAxisExtent = usableCrossAxisExtent / crossAxisCount;
    final double childMainAxisExtent = childCrossAxisExtent / childAspectRatio;

// 卡片座標計算
SliverGridGeometry getGeometryForChildIndex(int index) {
  final double crossAxisStart = (index % crossAxisCount) * crossAxisStride; //橫座標
  return SliverGridGeometry(
    scrollOffset: (index ~/ crossAxisCount) * mainAxisStride, //縱座標
    crossAxisOffset: _getOffsetFromStartInCrossAxis(crossAxisStart),
    mainAxisExtent: childMainAxisExtent,
    crossAxisExtent: childCrossAxisExtent,
  );
} 

// 對卡片進行遍歷佈局
for (int index = indexOf(firstChild) - 1; index >= firstIndex; --index) {
      final SliverGridGeometry gridGeometry = layout.getGeometryForChildIndex(index); //獲取尺寸和位置信息
      final RenderBox child = insertAndLayoutLeadingChild(
        gridGeometry.getBoxConstraints(constraints),
      );  //使用計算好的尺寸信息來限制卡片大小
      final SliverGridParentData childParentData = child.parentData;
      childParentData.layoutOffset = gridGeometry.scrollOffset;  //卡片的縱軸座標賦值
      childParentData.crossAxisOffset = gridGeometry.crossAxisOffset; // 卡片的橫軸座標賦值
      assert(childParentData.index == index);
      trailingChildWithLayout ??= child;
      trailingScrollOffset = math.max(trailingScrollOffset, gridGeometry.trailingScrollOffset);
    }


由此可見,網格佈局中,每個卡片的位置座標跟index是有一一對應關係的。所以無論是向下滾動對後面的卡片進行佈局,還是向上滾動對前面的卡片進行佈局。都使用這個策略就可以得出所有卡片的座標。

瀑布流佈局

尺寸計算過程

然後我們對瀑布流佈局的卡片尺寸計算過程進行分析,反推出我們需要傳入的初始化參數。首先,我們需要考慮到在瀑布流佈局中一共有兩種卡片,一種是寬度由屏幕寬度被佈局列數均分的普通卡片,另一種是寬度充滿整個屏幕的特殊卡片,我們後續叫它橫條卡片。我們會分別對這兩種卡片進行尺寸計算。

普通卡片

首先對於普通卡片來說,卡片的尺寸寬度和網格佈局中的卡片一樣,是由列數和屏幕寬度決定的,所以我們同樣需要crossAxisCount這個參數。寬度確定之後,我們需要確定卡片的高度。在瀑布流佈局中,每個卡片的高度是不同的,這也是瀑布流佈局和網格佈局最大的區別。所以我們其實可以由每個卡片自己決定自己的高度,也就是我們不需要在佈局初始化的時候傳入類似childAspectRatio這樣影響卡片的參數。不過我們在實際的業務場景中,通常會對某些特殊位置的卡片進行特殊的高度設置,例如兩列流中橫條卡片上面的兩個卡片,UED會有保證這兩個卡片的底部位置一致的需求,不然就會造成卡片之間的裂隙,影響觀感。所以我們需要一個定義了一個方法參數mainAxisExtentBuilder。

typedef double IndexedMainAxisExtentBuilder(int index);

這是一個返回值爲double的方法參數,瀑布流在佈局的時候會根據index嘗試獲取開發者在這個方法中的返回值,如果這個返回值爲null,就用卡片自己內部的佈局來決定卡片高度,反之就用這個返回值來決定卡片高度。
計算過程如圖所示:

橫條卡片

橫條卡片在高度的確定流程上是和普通卡片一致的,只是橫條卡片的寬度總是和屏幕寬度一致,不受crossAxisCount限制。
計算過程如圖所示:

所以我們只需要在佈局過程中能夠區分這兩種卡片,就可以用不同的策略對它們的尺寸進行計算。類似於mainAxisExtentBuilder,我們定義了一個IndexedFullSpanBuilder參數。

typedef bool IndexedFullSpanBuilder(int index);

這是一個返回值爲bool的方法參數,瀑布流在佈局的時候會根據index嘗試獲取開發者在這個方法中的返回值,如果這個返回值爲null或者false,就使用普通卡片的寬度計算策略,反之就使用橫條卡片的寬度計算策略。

所以我們就定義好了瀑布流佈局初始化中確定佈局的三個參數。

FlowView.count({
  @required int crossAxisCount,
  IndexedFullSpanBuilder fullSpanBuilder,
  IndexedMainAxisExtentBuilder mainAxisExtentBuilder,
})

這樣我們就能夠計算出佈局中每一個卡片的尺寸了,接下來我們只需要再確定卡片左上角的座標,這樣就可以完成卡片的佈局了。

位置計算過程

對於瀑布流來說,位置計算過程會比網格佈局複雜得多,我們先來分析一下從上到下佈局的過程。之前我們說過,在混排瀑布流佈局中會有兩種卡片,橫條卡片和普通卡片。我們希望卡片的佈局中儘量沒有間隙。

所以對於普通卡片來說,卡片的縱座標計算過程是這樣的。我們需要在已經完成佈局的卡片中進行查找,找到其中縱座標+卡片高度(即卡片bottom縱座標)值最小的卡片,我們把這張卡片叫做最低卡片。然後把下一張卡片佈局在最低卡片的正下方,所以下一張卡片的縱座標就是最低卡片的縱座標+卡片高度。因爲需要佈局在最低卡片的正下方,所以橫座標就直接和最低卡片的橫座標保持一致即可。

對於橫條卡片來說,因爲他的寬度總是和屏幕寬度一致,所以我們只需要計算它的縱座標。它的橫座標永遠是0,他的縱座標和普通卡片剛好相反,需要在已經完成佈局的卡片中進行查找,找到其中縱座標+卡片高度(即卡片bottom縱座標)值最大的卡片,我們把這張卡片叫做最高卡片。然後把橫條卡片佈局在這張最高卡片下面,否則這張橫條卡片會遮住其他卡片。在這裏我們根據列數生成一個初始值都爲0的縱座標列表,每佈局一個卡片就把該列的offset加上卡片的高度。

計算過程如圖所示:

而從下到上的佈局過程,瀑布流和GridView和ListView都不太一樣,ListView,上一個卡片的位置可以由下一個卡片佈局位置來確定,往上滾動的時候,我們只用把卡片佈局在最上面的卡片上面就可以了,GridView直接根據index就可以完成計算了,瀑布流比較特殊,因爲卡片的佈局依賴於它上面的卡片的佈局信息,無法通過後一個卡片的佈局信息推斷出前一個卡片的佈局。在這裏,一般有兩種處理方式。

  1. 維護一個index和crossAxisIndex一一對應的Map關係表

目前RecyclerView和WaterFallFlow是採用這種方式的,在用戶向下滑動時,正常佈局,然後記錄下沒張卡片屬於哪一列。然後在用戶向上滑動時,對即將進行佈局的卡片,先通過這個關係表得到它屬於哪一列,然後將它佈局在這一列最上面卡片的上方,這樣就可以保證卡片的佈局對於用戶來說始終是一致的。但是這樣的方式在混排瀑布流中,需要對橫條卡片做特殊處理,因爲橫條卡片的上一張卡片不一定和橫條卡片在佈局上是緊貼着的,可能會有間隙。所以我們還需要記錄橫條卡片跟上一張卡片的間隙,佈局的時候再加上這個間隙再佈局,這樣才能保證正確佈局。

  1. 使用分頁思想,始終從上到下進行佈局。

FlutterStaggeredGridView採用的就是這種方式,而我們實現的混排瀑布流也使用了這樣的思路。我們設定一個高度PageSize,按照這個高度給整個瀑布流佈局進行分頁,然後維護一個pageIndex和pageInfo的對應表,每一頁裏記錄着自己的mainAxisOffsets,以及的firstChildIndex。

第一頁的mainAxisOffsets很顯然是一個長度爲crossAxisCount,值爲0的列表。然後從上到下佈局時,不斷更新這個mainAxisOffsets,例如第一頁在第一列布局了第一個高度爲100的普通卡片,則mainAxisOffsets更新爲{100,0}。然後在第二列布局了第二個高度爲150的普通卡片,則mainAxisOffsets更新爲{100,150}。後續我們佈局了一個高度爲200的橫條卡片,則mainAxisOffsets更新爲{350,350}。然後橫條卡片和第一張卡片之間會有一個50的間隙,這個mainAxisOffsets就是下一張卡片佈局的起始點。然後當有mainAxisOffsets都超過PageSize時,我們就開始分下一頁。下一頁的initialOffsets就是上一頁的mainAxisOffsets,然後再開始第二頁的卡片佈局。

這樣當我們向上滾動時,當我們需要對上一個卡片進行佈局時,我們就會從這個卡片所屬的頁面的第一個卡片開始佈局,這樣就瀑布流就始終是從上到下佈局的。就能保證佈局的正確性。

然後我們按照RenderSliverGrid的思路實現了一個RenderSliverFlow。整個佈局的關鍵的源碼如下:

// 卡片座標計算

SliverFlowGeometry getGeometryForChildIndex(int index,List<double> startOffsets) {
  bool isFullSpan = _getIsFullSpan(index); //是否是橫條卡片

  double maxOffset = startOffsets.reduce(math.max); //最高卡片底部縱座標
  double minOffset = startOffsets.reduce(math.min); //最低卡片底部縱座標

  var scrollOffset = minOffset;
  var crossAxisIndex = startOffsets.indexOf(minOffset); //屬於哪一列
  int needCrossAxisCount = isFullSpan ? crossAxisCount : 1;

  if(isFullSpan){
    scrollOffset = maxOffset;
    crossAxisIndex = 0;
  }

  if (reverseCrossAxis) {
    crossAxisIndex = crossAxisCount - needCrossAxisCount - crossAxisIndex;
  }
  var crossAxisOffset = crossAxisIndex * crossAxisStride; 
  var mainAxisExtent = _getChildMainAxisExtent(index);
  return SliverFlowGeometry(
    scrollOffset: scrollOffset, //縱座標
    crossAxisOffset: crossAxisOffset, //橫座標
    mainAxisExtent: mainAxisExtent,
    crossAxisExtent: crossAxisStride * needCrossAxisCount - crossAxisSpacing,
    isFullSpan: isFullSpan,
    crossAxisIndex: crossAxisIndex,
  );
}

內存回收和性能優化

回收機制

前文中我們提到過,在端側,因爲一個滾動容器中的卡片數量可能會非常大,所以我們不可能一次性對所有的卡片都進行佈局和繪製,內存和運算時間都是無法接受的。

我們總是希望只佈局儘可能少的卡片,我們先來分析一下最晚可以從哪個卡片開始佈局。從上文我們知道,我們將整個瀑布流進行了分頁,每一頁包含着多個卡片,我們記錄着每一頁的起始offsets,所以我們需要找可見區域最上方的卡片,把這個卡片的位置標記爲firstIndex,然後從這個卡片所屬的頁面的第一個卡片開始佈局。然後我們再分析一下佈局在什麼時候結束,因爲我們前面的卡片無需依賴後面的卡片,所以我們佈局到可視區域之外就可以停止佈局了,然後把最後一張卡片的位置標記爲lastIndex。每一次佈局都會產生一個firstIndex和lastIndex。

當我們往下滑動的時候,我們會判斷firstIndex屬於哪一頁,這就表明這一頁此時在最上方,那對這一頁之前的Page裏的卡片我們就可以進行內存回收了。往上滑動的時候,我們把lastIndex之後的卡片全部進行回收就好了。

性能優化

這樣的分頁機制雖然是能夠保證佈局的正確性,但是其實很多情況下,我們都需要佈局緩存區以外的卡片,舉個極端情況的例子,可見區域的第一張卡片是屬於某一個分頁的最後一張卡片,這個時候我們就不得不把這個分頁裏的全部卡片都進行佈局。這其實會對滑動性能造成一些影響,一開始的設計PageSize固定爲一個屏幕的高度,每一屏分一頁。後來進行了性能優化,我們會根據大部分瀑布流的卡片高度得到一個分頁值,儘量保證每一次分頁所包含的卡片儘可能就是一行的卡片數。這樣可見區域的第一張卡片往往就是這個分頁的第一張卡片,這樣一來就可以減少不必要的佈局。

然後我們對GridView和FlowView進行了性能測試,使用腳本對兩個滾動容器分別往下滾動五次,再滾動五次。最後得出性能數據,然後我們主要關注兩個數據,分別是最大丟幀數和最差幀耗時,這往往就是最影響體感的兩個數據。通過根據平均卡片尺寸高度動態調整分頁,最後的性能數據達到了儘可能和GridView一致。
使用同一機型,性能測試數據如下:

佈局類型 GridView FlowView 瀑布流
丟幀數 24 25
最差幀耗時 53 52

效果與落地

這是目前使用FlowView完成的一個Demo工程,支持了Flutter滾動體系裏的各種功能。scrollController(滾動到offset),reverse(逆序排列),scrollDirection(滾動方向垂直或水平滾動)等。

在閒魚工程中,主要在首頁、搜索結果頁等進行落地。不過目前Flutter首頁在線上只是進行了少量的灰度。

總結與展望

整個瀑布流目前結合PowerScrollView進行了初步落地,在整個佈局的過程中,在功能上可擴展和優化的地方依然存在。

在可擴展的功能方面,未來希望可以在一個佈局中完成不同列數的混排,例如一個Sliver中可以有一列、兩列、三列、甚至六列的混排,類似於RecyclerView中的GridLayoutManager。

然後在性能方面,希望之後能夠在佈局邏輯中進行優化,儘可能減少不必要的計算和佈局。能夠在滑動中提供更好的體感。

希望官方之後會對這樣比較常用的佈局進行支持,這樣也可以給後面的佈局優化帶來思路。

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