Flutter Sliver一輩子之敵 (ExtendedList)

前言

接着上一章Flutter Sliver一輩子之敵 (ScrollView),咱們這章將沿着ListView/GridView => SliverList/SliverGrid => RenderSliverList/RenderSliverGrid的感情線,梳理列表計算的最終一千米代碼,舉一反N。ios

歡迎加入Flutter Candies flutter-candies QQ羣: 181398081。git

Sliver的佈局輸入和輸出

在講解佈局代碼以前,先要了解下Sliver佈局的輸入和輸出github

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。api

@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

Widget Extends
ListView/GridView BoxScrollView => ScrollView

ListView 和 GirdView 都繼承與BoxScrollView,咱們先看看BoxScrollView跟ScrollView有什麼區別。緩存

關鍵代碼bash

/// 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.markdown

ListView

關鍵代碼app

在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…)方法是用於佈局children,在講解代碼以前咱們先看一下單個Sliver的children佈局的狀況。

圖中綠色的爲咱們能看到的部分,黃色是緩存區域,灰色爲應該回收掉的部分。

//指示開始
    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個,也就是說加載完畢以後可能有(1~9)*300=(300~2700)個圖片內存緩存,固然由於官方緩存爲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中包含圖片的內存緩存。這樣內存中只有咱們列表中少許的圖片內存,另外一方面因爲咱們圖片作了硬盤緩存,即便咱們清除了內存緩存,圖片從新加載的時候也不會再次下載,對於用戶來講無感知的。

圖片內存優化

咱們前面提到過官方的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小糖果( flutter-candiesQQ羣:181398081)

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

相關文章
相關標籤/搜索