Flutter Sliver一生之敌 (ExtendedList) 您所在的位置:网站首页 一生之敌 Flutter Sliver一生之敌 (ExtendedList)

Flutter Sliver一生之敌 (ExtendedList)

2023-07-21 02:54| 来源: 网络整理| 查看: 265

前言

接着上一章Flutter Sliver一生之敌 (ScrollView),我们这章将沿着ListView/GridView => SliverList/SliverGrid => RenderSliverList/RenderSliverGrid的线路,梳理列表计算的最终一公里代码,举一反N。

欢迎加入Flutter CandiesQQ群:181398081

Flutter Sliver一生之敌 (ScrollView) Flutter Sliver一生之敌 (ExtendedList) Flutter Sliver 瀑布流 Flutter Sliver 锁住你的美 Sliver的布局输入和输出

在讲解布局代码之前,先要了解下Sliver布局的输入和输出

SliverConstraints

Sliver布局的输入,就是Viewport告诉我们的约束。

class SliverConstraints extends Constraints { /// Creates sliver constraints with the given information. /// /// All of the argument must not be null. const SliverConstraints({ //滚动的方向 @required this.axisDirection, //这个是给center使用的,center之前的sliver是颠倒的 @required this.growthDirection, //用户手势的方向 @required this.userScrollDirection, //滚动的偏移量,注意这里是针对这个Sliver的,而且非整个Slivers的总滚动偏移量 @required this.scrollOffset, //前面Slivers的总的大小 @required this.precedingScrollExtent, //为pinned和floating设计的,如果前一个Sliver绘制大小为100,但是布局大小只有50,那么这个Sliver的overlap为50. @required this.overlap, //还有多少内容可以绘制,参考viewport以及cache。比如多Slivers的时候,前一个占了100,那么后面能绘制的区域就要减掉前面绘制的区域大小,得到剩余的绘制区域大小 @required this.remainingPaintExtent, //纵轴的大小 @required this.crossAxisExtent, //纵轴的方向,这里会影响GridView同一行元素的摆放顺序,是0~x,还是x~0 @required this.crossAxisDirection, //viewport中还有多少内容可以绘制 @required this.viewportMainAxisExtent, //剩余的缓存区域大小 @required this.remainingCacheExtent, //相对于scrollOffset缓存区域大小 @required this.cacheOrigin, }) SliverGeometry

Sliver布局的输出,将会反馈给Viewport。

@immutable class SliverGeometry extends Diagnosticable { /// Creates an object that describes the amount of space occupied by a sliver. /// /// If the [layoutExtent] argument is null, [layoutExtent] defaults to the /// [paintExtent]. If the [hitTestExtent] argument is null, [hitTestExtent] /// defaults to the [paintExtent]. If [visible] is null, [visible] defaults to /// whether [paintExtent] is greater than zero. /// /// The other arguments must not be null. const SliverGeometry({ //预估的Sliver能够滚动大小 this.scrollExtent = 0.0, //对后一个的overlap属性有影响,它小于[SliverConstraints.remainingPaintExtent],为Sliver在viewport范围(包含cache)内第一个元素到最后一个元素的大小 this.paintExtent = 0.0, //相对Sliver位置的绘制起点 this.paintOrigin = 0.0, //这个sliver在viewport的第一个显示位置到下一个sliver的第一个显示位置的大小 double layoutExtent, //最大能绘制的总大小,这个参数是用于[SliverConstraints.remainingPaintExtent] 是无穷大的,就是使用在shrink-wrapping viewport中 this.maxPaintExtent = 0.0, //如果sliver被pinned在边界的时候,这个大小为Sliver的自身的高度。其他情况为0 this.maxScrollObstructionExtent = 0.0, //点击有效区域的大小,默认为paintExtent double hitTestExtent, //可见,paintExtent为0不可见。 bool visible, //是否需要做clip,免得chidren溢出 this.hasVisualOverflow = false, //viewport layout sliver的时候,如果sliver出现了一些问题,那么这个值将不等于0,通过这个值来修正整个滚动的ScrollOffset this.scrollOffsetCorrection, //该Sliver使用了多少[SliverConstraints.remainingCacheExtent],针对多Slivers的情况 double cacheExtent, })

大概讲解了这些参数的意义,可能还是不太明白,在后面的源码中使用中还会根据场景进行讲解。

BoxScrollView WidgetExtendsListView/GridViewBoxScrollView => ScrollView

ListView 和 GirdView 都继承与BoxScrollView,我们先看看BoxScrollView跟ScrollView有什么区别。

关键代码

/// The amount of space by which to inset the children. final EdgeInsetsGeometry padding; @override List buildSlivers(BuildContext context) { /// 这个方法被ListView/GirdView 实现 Widget sliver = buildChildLayout(context); EdgeInsetsGeometry effectivePadding = padding; if (padding == null) { final MediaQueryData mediaQuery = MediaQuery.of(context, nullOk: true); if (mediaQuery != null) { // Automatically pad sliver with padding from MediaQuery. final EdgeInsets mediaQueryHorizontalPadding = mediaQuery.padding.copyWith(top: 0.0, bottom: 0.0); final EdgeInsets mediaQueryVerticalPadding = mediaQuery.padding.copyWith(left: 0.0, right: 0.0); // Consume the main axis padding with SliverPadding. effectivePadding = scrollDirection == Axis.vertical ? mediaQueryVerticalPadding : mediaQueryHorizontalPadding; // Leave behind the cross axis padding. sliver = MediaQuery( data: mediaQuery.copyWith( padding: scrollDirection == Axis.vertical ? mediaQueryHorizontalPadding : mediaQueryVerticalPadding, ), child: sliver, ); } } if (effectivePadding != null) sliver = SliverPadding(padding: effectivePadding, sliver: sliver); return [ sliver ]; } /// Subclasses should override this method to build the layout model. @protected /// 这个方法被ListView/GirdView 实现 Widget buildChildLayout(BuildContext context);

可以看出来,只是多包了一层SliverPadding,最后返回的[ sliver ]也说明,其实ListView和GridView 跟CustomScrollView相比,前者是单个Sliver,后者可为多个Slivers.

ListView

关键代码

在BoxScrollView的buildSlivers方法中调用了buildChildLayout,下面是在ListView中的实现。可以看到根据itemExtent来分别返回了SliverList和SliverFixedExtentList 2种Sliver。

@override Widget buildChildLayout(BuildContext context) { if (itemExtent != null) { return SliverFixedExtentList( delegate: childrenDelegate, itemExtent: itemExtent, ); } return SliverList(delegate: childrenDelegate); } SliverList class SliverList extends SliverMultiBoxAdaptorWidget { /// Creates a sliver that places box children in a linear array. const SliverList({ Key key, @required SliverChildDelegate delegate, }) : super(key: key, delegate: delegate); @override RenderSliverList createRenderObject(BuildContext context) { final SliverMultiBoxAdaptorElement element = context; return RenderSliverList(childManager: element); } } RenderSliverList Sliver布局

RenderSliverList中的performLayout (github.com/flutter/flu…

图中绿色的为我们能看到的部分,黄色是缓存区域,灰色为应该回收掉的部分。

layout 准备开始 //指示开始 childManager.didStartLayout(); //指示是否可以添加新的child childManager.setDidUnderflow(false); //constraints就是viewport给我们的布局限制,也就是布局输入 //滚动位置包含cache,布局区域开始位置 final double scrollOffset = constraints.scrollOffset + constraints.cacheOrigin; assert(scrollOffset >= 0.0); //绘制整个区域大小包含缓存区域,就是图中黄色和绿色部分 final double remainingExtent = constraints.remainingCacheExtent; assert(remainingExtent >= 0.0); //布局区域结束位置 final double targetEndScrollOffset = scrollOffset + remainingExtent; //获取到child的限制,如果是垂直滚动的列表,高度应该是无限大double.infinity final BoxConstraints childConstraints = constraints.asBoxConstraints(); //从第一个child开始向后需要回收的孩子个数,图中灰色部分 int leadingGarbage = 0; //从最后一个child开始向前需要回收的孩子个数,图中灰色部分 int trailingGarbage = 0; //是否滚动到最后 bool reachedEnd = false; //如果列表里面没有一个child,我们将尝试加入一个,如果加入失败,那么整个Sliver无内容 if (firstChild == null) { if (!addInitialChild()) { // There are no children. geometry = SliverGeometry.zero; childManager.didFinishLayout(); return; } } 向前计算的情况,(垂直滚动的列表)是列表想前滚动。由于灰色部分的child会被移除,所以当我们向前滚动的时候,我们需要根据现在的滚动位置来查看是否需要在前面插入child。 // Find the last child that is at or before the scrollOffset. RenderBox earliestUsefulChild = firstChild; //当第一个child的layoutOffset小于我们的滚动位置的时候,说明前面是空的,如果在第一个child的签名插入一个新的child来填充 for (double earliestScrollOffset = childScrollOffset(earliestUsefulChild); earliestScrollOffset > scrollOffset; earliestScrollOffset = childScrollOffset(earliestUsefulChild)) { // We have to add children before the earliestUsefulChild. // 这里就是在插入新的child earliestUsefulChild = insertAndLayoutLeadingChild(childConstraints, parentUsesSize: true); //处理当前面已经没有child的时候 if (earliestUsefulChild == null) { final SliverMultiBoxAdaptorParentData childParentData = firstChild.parentData as SliverMultiBoxAdaptorParentData; childParentData.layoutOffset = 0.0; //已经到0.0的位置了,所以不需要再向前找了,break if (scrollOffset == 0.0) { // insertAndLayoutLeadingChild only lays out the children before // firstChild. In this case, nothing has been laid out. We have // to lay out firstChild manually. firstChild.layout(childConstraints, parentUsesSize: true); earliestUsefulChild = firstChild; leadingChildWithLayout = earliestUsefulChild; trailingChildWithLayout ??= earliestUsefulChild; break; } else { // We ran out of children before reaching the scroll offset. // We must inform our parent that this sliver cannot fulfill // its contract and that we need a scroll offset correction. // 这里就是我们上一章讲的,出现出错了。将scrollOffsetCorrection设置为不为0,传递给viewport,这样它会整体重新移除掉这个差值,重新进行layout布局。 geometry = SliverGeometry( scrollOffsetCorrection: -scrollOffset, ); return; } } /// 滚动的位置减掉firstChild的大小,用来继续计算是否还需要插入更多child来补足前面。 final double firstChildScrollOffset = earliestScrollOffset - paintExtentOf(firstChild); // firstChildScrollOffset may contain double precision error // 同样的道理,如果发现最终减掉之后,数值小于0.0(precisionErrorTolerance这是一个接近0.0的极小数)的话,肯定是不对的,所以又告诉viewport移除掉差值,重新布局 if (firstChildScrollOffset < -precisionErrorTolerance) { // The first child doesn't fit within the viewport (underflow) and // there may be additional children above it. Find the real first child // and then correct the scroll position so that there's room for all and // so that the trailing edge of the original firstChild appears where it // was before the scroll offset correction. // TODO(hansmuller): do this work incrementally, instead of all at once, // i.e. find a way to avoid visiting ALL of the children whose offset // is < 0 before returning for the scroll correction. double correction = 0.0; while (earliestUsefulChild != null) { assert(firstChild == earliestUsefulChild); correction += paintExtentOf(firstChild); earliestUsefulChild = insertAndLayoutLeadingChild(childConstraints, parentUsesSize: true); } geometry = SliverGeometry( scrollOffsetCorrection: correction - earliestScrollOffset, ); final SliverMultiBoxAdaptorParentData childParentData = firstChild.parentData as SliverMultiBoxAdaptorParentData; childParentData.layoutOffset = 0.0; return; } // ok,这里就是正常的情况 final SliverMultiBoxAdaptorParentData childParentData = earliestUsefulChild.parentData as SliverMultiBoxAdaptorParentData; // 设置child绘制的开始点 childParentData.layoutOffset = firstChildScrollOffset; assert(earliestUsefulChild == firstChild); leadingChildWithLayout = earliestUsefulChild; trailingChildWithLayout ??= earliestUsefulChild; } advance 方法(github.com/flutter/flu…)

向后移动child,如果没有了返回false

bool inLayoutRange = true; RenderBox child = earliestUsefulChild; int index = indexOf(child); double endScrollOffset = childScrollOffset(child) + paintExtentOf(child); bool advance() { // returns true if we advanced, false if we have no more children // This function is used in two different places below, to avoid code duplication. assert(child != null); if (child == trailingChildWithLayout) inLayoutRange = false; child = childAfter(child); ///不在render tree里面 if (child == null) inLayoutRange = false; index += 1; if (!inLayoutRange) { if (child == null || indexOf(child) != index) { // We are missing a child. Insert it (and lay it out) if possible. //不在树里面,尝试新增进去 child = insertAndLayoutChild(childConstraints, after: trailingChildWithLayout, parentUsesSize: true, ); if (child == null) { // We have run out of children. return false; } } else { // Lay out the child. child.layout(childConstraints, parentUsesSize: true); } trailingChildWithLayout = child; } assert(child != null); final SliverMultiBoxAdaptorParentData childParentData = child.parentData as SliverMultiBoxAdaptorParentData; //设置绘制位置 childParentData.layoutOffset = endScrollOffset; assert(childParentData.index == index); //设置endScrollOffset为child的绘制结束位置 endScrollOffset = childScrollOffset(child) + paintExtentOf(child); return true; } 找到离scrollOffset置最近的一个child

当向后滚动的时候,第一个child也许不是离scrollOffset最近的,所以我们需要向后找,找到这个最近的。

// Find the first child that ends after the scroll offset. while (endScrollOffset < scrollOffset) { //如果是小于,说明需要被回收,这里+1记录一下。 leadingGarbage += 1; if (!advance()) { assert(leadingGarbage == childCount); assert(child == null); //找到最后都没有满足的话,将以最后一个child为准 // we want to make sure we keep the last child around so we know the end scroll offset collectGarbage(leadingGarbage - 1, 0); assert(firstChild == lastChild); final double extent = childScrollOffset(lastChild) + paintExtentOf(lastChild); geometry = SliverGeometry( scrollExtent: extent, paintExtent: 0.0, maxPaintExtent: extent, ); return; } } 向后处理child直到布局区域的结束位置。 // Now find the first child that ends after our end. // 直到布局区域的结束位置 while (endScrollOffset < targetEndScrollOffset) { if (!advance()) { reachedEnd = true; break; } } // Finally count up all the remaining children and label them as garbage. //到上面位置是需要布局的最后一个child,所以在它之后的child就是需要被回收的 if (child != null) { child = childAfter(child); while (child != null) { trailingGarbage += 1; child = childAfter(child); } } 回收children // At this point everything should be good to go, we just have to clean up // the garbage and report the geometry. // 使用之前计算出来的回收参数 collectGarbage(leadingGarbage, trailingGarbage); @protected void collectGarbage(int leadingGarbage, int trailingGarbage) { assert(_debugAssertChildListLocked()); assert(childCount >= leadingGarbage + trailingGarbage); invokeLayoutCallback((SliverConstraints constraints) { //从第一个向后删除 while (leadingGarbage > 0) { _destroyOrCacheChild(firstChild); leadingGarbage -= 1; } //从最后一个向前删除 while (trailingGarbage > 0) { _destroyOrCacheChild(lastChild); trailingGarbage -= 1; } // Ask the child manager to remove the children that are no longer being // kept alive. (This should cause _keepAliveBucket to change, so we have // to prepare our list ahead of time.) _keepAliveBucket.values.where((RenderBox child) { final SliverMultiBoxAdaptorParentData childParentData = child.parentData as SliverMultiBoxAdaptorParentData; return !childParentData.keepAlive; }).toList().forEach(_childManager.removeChild); assert(_keepAliveBucket.values.where((RenderBox child) { final SliverMultiBoxAdaptorParentData childParentData = child.parentData as SliverMultiBoxAdaptorParentData; return !childParentData.keepAlive; }).isEmpty); }); } void _destroyOrCacheChild(RenderBox child) { final SliverMultiBoxAdaptorParentData childParentData = child.parentData as SliverMultiBoxAdaptorParentData; //如果child被标记为缓存的话,从tree中移除并且放入缓存中 if (childParentData.keepAlive) { assert(!childParentData._keptAlive); remove(child); _keepAliveBucket[childParentData.index] = child; child.parentData = childParentData; super.adoptChild(child); childParentData._keptAlive = true; } else { assert(child.parent == this); //直接移除 _childManager.removeChild(child); assert(child.parent == null); } } 计算sliver的输出 assert(debugAssertChildListIsNonEmptyAndContiguous()); double estimatedMaxScrollOffset; //以及到底了,直接使用最后一个child的绘制结束位置 if (reachedEnd) { estimatedMaxScrollOffset = endScrollOffset; } else { // 计算出估计最大值 estimatedMaxScrollOffset = childManager.estimateMaxScrollOffset( constraints, firstIndex: indexOf(firstChild), lastIndex: indexOf(lastChild), leadingScrollOffset: childScrollOffset(firstChild), trailingScrollOffset: endScrollOffset, ); assert(estimatedMaxScrollOffset >= endScrollOffset - childScrollOffset(firstChild)); } //根据remainingPaintExtent算出当前消耗了的绘制区域大小 final double paintExtent = calculatePaintOffset( constraints, from: childScrollOffset(firstChild), to: endScrollOffset, ); //根据remainingCacheExtent算出当前消耗了的缓存绘制区域大小 final double cacheExtent = calculateCacheOffset( constraints, from: childScrollOffset(firstChild), to: endScrollOffset, ); //布局区域结束位置 final double targetEndScrollOffsetForPaint = constraints.scrollOffset + constraints.remainingPaintExtent; //将输出反馈给Viewport,viewport根据sliver的输出,如果这个sliver已经没有内容了,再布局下一个 geometry = SliverGeometry( scrollExtent: estimatedMaxScrollOffset, paintExtent: paintExtent, cacheExtent: cacheExtent, maxPaintExtent: estimatedMaxScrollOffset, // Conservative to avoid flickering away the clip during scroll. //是否需要clip hasVisualOverflow: endScrollOffset > targetEndScrollOffsetForPaint || constraints.scrollOffset > 0.0, ); // We may have started the layout while scrolled to the end, which would not // expose a new child. // 2者相等说明已经这个sliver的底部了 if (estimatedMaxScrollOffset == endScrollOffset) childManager.setDidUnderflow(true); //通知完成layout //这里会通过[SliverChildDelegate.didFinishLayout] 将第一个index和最后一个index传递出去,可以用追踪 childManager.didFinishLayout();

估计最大值默认实现

static double _extrapolateMaxScrollOffset( int firstIndex, int lastIndex, double leadingScrollOffset, double trailingScrollOffset, int childCount, ) { if (lastIndex == childCount - 1) return trailingScrollOffset; final int reifiedCount = lastIndex - firstIndex + 1; //算出平均值 final double averageExtent = (trailingScrollOffset - leadingScrollOffset) / reifiedCount; //加上剩余估计值 final int remainingCount = childCount - lastIndex - 1; return trailingScrollOffset + averageExtent * remainingCount; } Sliver绘制

RenderSliverMultiBoxAdaptor

paint方法 @override void paint(PaintingContext context, Offset offset) { if (firstChild == null) return; // offset is to the top-left corner, regardless of our axis direction. // originOffset gives us the delta from the real origin to the origin in the axis direction. Offset mainAxisUnit, crossAxisUnit, originOffset; bool addExtent; // 根据滚动的方向,来获取主轴和横轴的系数 switch (applyGrowthDirectionToAxisDirection(constraints.axisDirection, constraints.growthDirection)) { case AxisDirection.up: mainAxisUnit = const Offset(0.0, -1.0); crossAxisUnit = const Offset(1.0, 0.0); originOffset = offset + Offset(0.0, geometry.paintExtent); addExtent = true; break; case AxisDirection.right: mainAxisUnit = const Offset(1.0, 0.0); crossAxisUnit = const Offset(0.0, 1.0); originOffset = offset; addExtent = false; break; case AxisDirection.down: mainAxisUnit = const Offset(0.0, 1.0); crossAxisUnit = const Offset(1.0, 0.0); originOffset = offset; addExtent = false; break; case AxisDirection.left: mainAxisUnit = const Offset(-1.0, 0.0); crossAxisUnit = const Offset(0.0, 1.0); originOffset = offset + Offset(geometry.paintExtent, 0.0); addExtent = true; break; } assert(mainAxisUnit != null); assert(addExtent != null); RenderBox child = firstChild; while (child != null) { //获取child主轴的位置,为child的layoutOffset减去滚动位移scrollOffset final double mainAxisDelta = childMainAxisPosition(child); //获取child横轴的位置,ListView为0.0, GridView为计算出来的crossAxisOffset final double crossAxisDelta = childCrossAxisPosition(child); Offset childOffset = Offset( originOffset.dx + mainAxisUnit.dx * mainAxisDelta + crossAxisUnit.dx * crossAxisDelta, originOffset.dy + mainAxisUnit.dy * mainAxisDelta + crossAxisUnit.dy * crossAxisDelta, ); if (addExtent) childOffset += mainAxisUnit * paintExtentOf(child); // If the child's visible interval (mainAxisDelta, mainAxisDelta + paintExtentOf(child)) // does not intersect the paint extent interval (0, constraints.remainingPaintExtent), it's hidden. // 这里可以看到因为有cache的原因,有一些child其实是不需要绘制在我们可以看到的可视区域的 if (mainAxisDelta < constraints.remainingPaintExtent && mainAxisDelta + paintExtentOf(child) > 0) context.paintChild(child, childOffset); child = childAfter(child); } } RenderSliverFixedExtentList

当ListView的itemExtent不为null的时候,使用的是RenderSliverFixedExtentList。这个我们也只简单讲一下,由于知道了child主轴的高度,再各种计算当中就更加简单。我们可以根据scrollOffset和viewport直接算出来第一个child和最后一个child。

GridView RenderSliverGrid

最后是我们的GridView,因为GridView的设计为child的主轴大小和横轴大小/横轴child个数相等(当然还跟childAspectRatio(默认为1.0)宽高比例有关系),所以说其实child主轴的大小也是已知的,而横轴的绘制位置也很好定.基本上的计算原理也跟ListView差不多了。

举一反三

讲了一堆源码,不知道有多少人能看到这里。我们通过对源码分析,知道了sliver列表的一些计算绘制知识。接下来我们将对官方的Sliver 列表做一些扩展,来满足羞羞的效果。

图片列表内存优化

经常听到有小伙伴说图片列表滚动几下就闪退,这种情况在ios上面特别明显,而在安卓上面内存增长的很快,其原因是Flutter默认为图片做了内存缓存。就是说你如果滚动列表加载了300张图片,那么内存里面就会有300张图片的内存缓存,官方缓存上限为1000.

列表内存测试

首先,我们来看看不做任何处理的情况下,图片列表的内存。我在这里做了一个图片列表,常见的9宫格的图片列表,增量加载child的总个数为300个,也就是说加载完毕之后可能有(19)*300=(3002700)个图片内存缓存,当然因为官方缓存为1000,最终图片内存缓存应该在300到1000之间(如果总的图片大小没有超过官方的限制)。

内存检测工具 首先,执行 flutter packages pub global activate devtools 激活 dart devtools 激活成功之后,执行 flutter --no-color packages pub global run devtools --machine --port=0 将上图中的 127.0.0.1:9540 地址输入到浏览器中。

接下来我们需要执行 flutter run --profile 运行起来我们的测试应用 执行完毕之后,会有一个地址,我们将这个地址copy到devtools中的Connect 点击Connect之后,在上部切换到Memory,我们就可以看到应用的实时内存变化监控了 不做任何处理的测试 安卓,我打开列表,一直向下拉,直到加载完毕300条,内存变化为下图,可以看到内存起飞爆炸

ios,我做了同样的步骤,可惜,它最终没有坚持到最后,600m左右闪退(跟ios应用内存限制有关)

上面例子很明显看到多图片列表对内存的巨大消耗,我们前面了解了Flutter中列表绘制整个流程,那么我们有没有办法来改进一下内存呢? 答案是我们可以尝试在列表children回收的时候,我们主动去清除掉那个child中包含图片的内存缓存。这样内存中只有我们列表中少量的图片内存,另一方面由于我们图片做了硬盘缓存,即使我们清除了内存缓存,图片重新加载的时候也不会再次下载,对于用户来说无感知的。

图片内存优化

最新更新,你可以通过 直接设置,来移除掉更多的图片内存

ExtendedImage( clearMemoryCacheWhenDispose: true, )

我们前面提到过官方的collectGarbage方法,这个方法调用的时候将去清除掉不需要的children。那么我们可以在这个时刻将被清除children的indexes获取到并且通知用户。

关键代码如下。由于我不想重写更多的Sliver底层的类,所以我这里是通过ExtendedListDelegate中的回调将indexes传递出来。

void callCollectGarbage({ CollectGarbage collectGarbage, int leadingGarbage, int trailingGarbage, int firstIndex, int targetLastIndex, }) { if (collectGarbage == null) return; List garbages = []; firstIndex ??= indexOf(firstChild); targetLastIndex ??= indexOf(lastChild); for (var i = leadingGarbage; i > 0; i--) { garbages.add(firstIndex - i); } for (var i = 0; i < trailingGarbage; i++) { garbages.add(targetLastIndex + i); } if (garbages.length != 0) { //call collectGarbage collectGarbage.call(garbages); } }

当通知chilren被清除的时候,通过ImageProvider.evict方法将图片缓存从内存中移除掉。

SliverListConfig( collectGarbage: (List indexes) { ///collectGarbage indexes.forEach((index) { final item = listSourceRepository[index]; if (item.hasImage) { item.images.forEach((image) { final provider = ExtendedNetworkImageProvider( image.imageUrl, ); provider.evict(); }); } }); },

经过优化之后执行同样的步骤,安卓内存变化为下

ios也差不多,表现为下

不够极限?

从上面测试中,我们可以看到经过优化,图片列表的内存得到了大大的优化,基本满足我们的需求。但是我们做的还不够极限,因为对于列表图片来说,通常我们对它的图片质量其实不是那么高的(我又想起来了列表图片一张8m的那个大哥)

使用官方的ResizeImage,它是官方最近新加的,用于减少图片内存缓存。你可以通过设置width/height来减少图片,其实就是官方给你做了压缩。用法如下

当然这种用法的前提是你已经提前知道了图片的大小,这样你可以对图片进行等比压缩。比如下面代码我对宽高进行了5倍缩小。注意的是,这样做了之后,图片的质量将会下降,如果太小了,就会糊掉。请根据自己的情况进行设置。另外一个问题是,列表图片和点击图片进行预览的图片,因为不是同一个ImageProvider了(预览图片一般都希望是高清的),所以会重复下载,请根据自己的情况进行取舍。

代码地址

ImageProvider createResizeImage() { return ResizeImage(ExtendedNetworkImageProvider(imageUrl), width: width ~/ 5, height: height ~/ 5); } 在继承ExtendedNetworkImageProvider(当然extended的其他provider也通过这样方法来压缩图片), override instantiateImageCodec方法,这里对图片进行压缩。 代码位置 ///override this method, so that you can handle raw image data, ///for example, compress Future instantiateImageCodec( Uint8List data, DecoderCallback decode) async { _rawImageData = data; return await decode(data); } 在做了这些优化之后,我们再次进行测试,下面试内存变化情况,内存消耗再次被降低。 支持我的PR

如果方案对你有用,请支持一下我对collectGarbage的PR.

add collectGarbage method for SliverChildDelegate to track which children can be garbage collected

这样可以让更多人解决掉图片列表内存的问题。当然你也可以直接使用 ExtendedList WaterfallFlow 和 LoadingMoreList 它们都支持这个api。整个完整的解决方案我已经提交到了ExtendedImage的demo当中,方便查看整个流程。

列表曝光追踪

简单的说,就是我们怎么方便地知道在可视区域中的children呢?从列表的计算绘制过程中,其实我们是能够轻易获取到可视区域中children的indexes的。我这里提供了ViewportBuilder回调来获取可视区域中第一个index和最后一个index。 代码位置

同样是通过ExtendedListDelegate,在viewportBuilder中回调。

使用演示

ExtendedListView.builder( extendedListDelegate: ExtendedListDelegate( viewportBuilder: (int firstIndex, int lastIndex) { print("viewport : [$firstIndex,$lastIndex]"); }), 特殊化最后一个child的布局

我们在入门Flutter的时候,做增量加载列表的时候,看到的例子就是把最后一个child作为loadmore/no more。ListView如果满屏幕的时候没有什么问题,但是下面情况需要解决。

ListView未满屏的时候,最后一个child展示 ‘没有更多’。 通常是希望‘没有更多’ 是放在最下面进行显示,但是因为它是最后一个child,它会紧挨着倒数第2个。 GridView 最后一个child作为loadmore/no more的时候。产品不希望它们当作普通的GridView元素来进行布局

为了解决这个问题,我设计了lastChildLayoutTypeBuilder。通过用户告诉的最后一个child的类型,来布局最后一个child,下面以RenderSliverList为例子。

if (reachedEnd) { ///zmt final layoutType = extendedListDelegate?.lastChildLayoutTypeBuilder ?.call(indexOf(lastChild)) ?? LastChildLayoutType.none; // 最后一个child的大小 final size = paintExtentOf(lastChild); // 最后一个child 绘制的结束位置 final trailingLayoutOffset = childScrollOffset(lastChild) + size; //如果最后一个child绘制的结束位置小于了剩余绘制大小,那么我们将最后一个child的位置改为constraints.remainingPaintExtent - size if (layoutType == LastChildLayoutType.foot && trailingLayoutOffset < constraints.remainingPaintExtent) { final SliverMultiBoxAdaptorParentData childParentData = lastChild.parentData; childParentData.layoutOffset = constraints.remainingPaintExtent - size; endScrollOffset = constraints.remainingPaintExtent; } estimatedMaxScrollOffset = endScrollOffset; }

最后我们看看怎么使用。

enum LastChildLayoutType { /// 普通的 none, /// 将最后一个元素绘制在最大主轴Item之后,并且使用横轴大小最为layout size /// 主要使用在[ExtendedGridView] and [WaterfallFlow]中,最后一个元素作为loadmore/no more元素的时候。 fullCrossAxisExtend, /// 将最后一个child绘制在trailing of viewport,并且使用横轴大小最为layout size /// 这种常用于最后一个元素作为loadmore/no more元素,并且列表元素没有充满整个viewport的时候 /// 如果列表元素充满viewport,那么效果跟fullCrossAxisExtend一样 foot, } ExtendedListView.builder( extendedListDelegate: ExtendedListDelegate( // 列表的总长度应该是 length + 1 lastChildLayoutTypeBuilder: (index) => index == length ? LastChildLayoutType.foot : LastChildLayoutType.none, ), 简单的聊天列表

我们在做一个聊天列表的时候,因为布局是从上向下的,我们第一反应肯定是将 ListView的reverse设置为true,当有新的会话会被插入0的位置,这样设置是最简单,但是当会话没有充满viewport的时候,因为布局被翻转,所以布局会像下面这样。

trailing ----------------- | | | | | item2 | | item1 | | item0 | ----------------- leading

为了解决这个问题,你可以设置 closeToTrailing 为true, 布局将变成如下 该属性同时支持[ExtendedGridView],[ExtendedList],[WaterfallFlow]。 当然如果reverse如果不为ture,你设置这个属性依然会生效,没满viewport的时候布局会紧靠trailing。

trailing ----------------- | item2 | | item1 | | item0 | | | | | ----------------- leading

那是如何是现实的呢?为此我增加了2个扩展方法

handleCloseToTrailingEnd

如果最后一个child的绘制结束位置没有剩余绘制区域大(也就是children未填充满viewport),那么我们给每一个child的绘制起点增加constraints.remainingPaintExtent - endScrollOffset的距离,那么现象就会是全部children是紧靠trailing布局的。这个方法为整体计算布局之后调用。

/// handle closeToTrailing at end double handleCloseToTrailingEnd( bool closeToTrailing, double endScrollOffset) { if (closeToTrailing && endScrollOffset < constraints.remainingPaintExtent) { RenderBox child = firstChild; final distance = constraints.remainingPaintExtent - endScrollOffset; while (child != null) { final SliverMultiBoxAdaptorParentData childParentData = child.parentData; childParentData.layoutOffset += distance; child = childAfter(child); } return constraints.remainingPaintExtent; } return endScrollOffset; } handleCloseToTrailingBegin

因为我们给每个child的绘制起点增加了constraints.remainingPaintExtent - endScrollOffset的距离。再下一次performLayout的时候,我们应该先移除掉这部分的距离。当第一个child的index为0 并且layoutOffset不为0,我们需要将全部的children的layoutOffset做移除。

/// handle closeToTrailing at begin void handleCloseToTrailingBegin(bool closeToTrailing) { if (closeToTrailing) { RenderBox child = firstChild; SliverMultiBoxAdaptorParentData childParentData = child.parentData; // 全部移除掉前一次performLayout增加的距离 if (childParentData.index == 0 && childParentData.layoutOffset != 0) { var distance = childParentData.layoutOffset; while (child != null) { childParentData = child.parentData; childParentData.layoutOffset -= distance; child = childAfter(child); } } } }

最后我们看看怎么使用。

ExtendedListView.builder( reverse: true, extendedListDelegate: ExtendedListDelegate(closeToTrailing: true), 结语

这一章我们通过对sliver 列表的源码进行分析,举一反四,解决了实际开发中的一些问题。下一章我们将创造自己的瀑布流布局,你也能有创建任意sliver布局列表的能力。

欢迎加入Flutter Candies,一起生产可爱的Flutter小糖果QQ群:181398081

最最后放上Flutter Candies全家桶,真香。



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有