关于javascript:Flutter瀑布流及通用列表解决方案

7次阅读

共计 5289 个字符,预计需要花费 14 分钟才能阅读完成。

作者:闲鱼技术 - 夜澜

背景

目前闲鱼业务中无论是首页还是搜寻页都有大量能够落地瀑布流的场景,而在 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
本文为阿里云原创内容,未经容许不得转载。

正文完
 0