作者:闲鱼技术-夜澜

背景

目前闲鱼业务中无论是首页还是搜寻页都有大量能够落地瀑布流的场景,而在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会被布局。

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。整个瀑布流的布局逻辑围绕三个外围开展:

  1. 在滑动的过程中找到其边缘最近的child,在其后(前)进行增加child,并对child进行layout.
  2. 在child来到肯定间隔后进行GC.
  3. 保障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个局部进行设计。

  1. delegate

次要治理child生命周期并响应手势,因为咱们能够失去每个可见Child的parentData属性,所以可在滚动时进行实时的告诉。从而对每个Child的地位监听,从开始创立进入缓冲区,到从缓冲区进入可见区域。手势则来自于顶层的Scrollable。

  1. layout

次要负责布局所有的Child。将具体的布局逻辑抽离出,相似于iOS中的UICollectionViewLayout。然而在开发过程中也呈现了一些问题,起因次要来自于Flutter非凡的信息传递形式,就是咱们不能采纳native的形式一次性计算出所有child的布局。因为RenderBox须要接管一个BoxConstraints能力返回一个size。

  1. reuser

reuser则在RenderObject层面,对Child进行基于类型的复用并实现部分更新的操作。须要将SliverMultiBoxAdaptor和其Element拷贝一份进行重写,扭转其mount的逻辑,计划还在摸索和调研之中,心愿能在后续的文章中和大家见面!

性能数据

利用于主搜寻页进行自动化测试,先前在54.7帧左右,换用瀑布流后为56.2,大略晋升了1.5帧。

内存上则有稍微的升高状况。

瞻望

目前Flutter的列表视图中依然有很多问题须要解决,比方瀑布流中scrollTo(int index)的能力还无奈实现,内存的应用状况等和原生相比依然有不小的差距, 对于Flutter侧的复用的稳定性和兼容性上还存在问题,闲鱼在Flutter化上还有很多路要走。

PS0: 文中代码基于Flutter 1.12.13。

PS1: 文中譬如Viewport,既代指Widget自身, 又代指其对应的RenderObject。

PS2: 文中波及到的代码通过删改, 仅供参考。

原文链接:https://developer.aliyun.com/..._content=g_1000168249
本文为阿里云原创内容,未经容许不得转载。