Flutter瀑布流及通用列表解决方案
背景
目前闲鱼业务中无论是首页还是搜索页都有大量可以落地瀑布流的场景,而在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。
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);
}elseif(child == null) {
// Child已经用尽.
break;
}
} else{
if(!_layoutedChilds.contains(index)) {
_layoutChildIfNeeded(child, parentUsesSize: true);
_layoutedChilds.add(index);
}
}
trailingChildWithLayout = child;
}
if(firstChild != null) {
// 上一次的最先最末Child.
final int oldFirstIndex = indexOf(firstChild);
final int oldLastIndex = indexOf(lastChild);
// 前后需要GC的child数量
final int trailingGarbage = targetLastIndex == null
? 0: (oldLastIndex - targetLastIndex).clamp(0, childCount);
final int leadingGarbage =
(firstIndex - oldFirstIndex).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);
}