Flutter Sliver一生之敌 (ExtendedList)

9,813 阅读17分钟

前言

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

欢迎加入Flutter CandiesQQ群:181398081

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

WidgetExtends
ListView/GridViewBoxScrollView => ScrollView

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

关键代码

/// The amount of space by which to inset the children.
  final EdgeInsetsGeometry padding;

  @override
  List<Widget> 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 <Widget>[ 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…

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

    //指示开始
    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;
    }

向后移动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;
    }

当向后滚动的时候,第一个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;
      }
    }
    // 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);
      }
    }
    // 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>((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);
    }
  }
    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

  @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<int> 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<TuChongItem>(
      collectGarbage: (List<int> 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);
  }
  ///override this method, so that you can handle raw image data,
  ///for example, compress
  Future<ui.Codec> 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 WaterfallFlowLoadingMoreList 它们都支持这个api。整个完整的解决方案我已经提交到了ExtendedImagedemo当中,方便查看整个流程。

列表曝光追踪

简单的说,就是我们怎么方便地知道在可视区域中的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个扩展方法

如果最后一个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;
  }

因为我们给每个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全家桶,真香。