Flutter Sliver一生之敵 (ExtendedList)

法的空間發表於2019-12-08

Flutter Sliver一生之敵 (ExtendedList)

前言

接著上一章Flutter Sliver一生之敵 (ScrollView),我們這章將沿著ListView/GridView => SliverList/SliverGrid => RenderSliverList/RenderSliverGrid的感情線,梳理列表計算的最終一公里程式碼,舉一反N。

歡迎加入Flutter Candies flutter-candies QQ群: 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

Widget Extends
ListView/GridView BoxScrollView => 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…)方法是用於佈局children,在講解程式碼之前我們先看一下單個Sliver的children佈局的情況。

Flutter Sliver一生之敵 (ExtendedList)

圖中綠色的為我們能看到的部分,黃色是快取區域,灰色為應該回收掉的部分。

    //指示開始
    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.

列表記憶體測試

Flutter Sliver一生之敵 (ExtendedList)

首先,我們來看看不做任何處理的情況下,圖片列表的記憶體。我在這裡做了一個圖片列表,常見的9宮格的圖片列表,增量載入child的總個數為300個,也就是說載入完畢之後可能有(1~9)*300=(300~2700)個圖片記憶體快取,當然因為官方快取為1000,最終圖片記憶體快取應該在300到1000之間(如果總的圖片大小沒有超過官方的限制)。

記憶體檢測工具

  • 首先,執行 flutter packages pub global activate devtools 啟用 dart devtools
    Flutter Sliver一生之敵 (ExtendedList)
  • 啟用成功之後,執行 flutter --no-color packages pub global run devtools --machine --port=0
    Flutter Sliver一生之敵 (ExtendedList)
    將上圖中的 127.0.0.1:9540 地址輸入到瀏覽器中。

Flutter Sliver一生之敵 (ExtendedList)

  • 接下來我們需要執行 flutter run --profile 執行起來我們的測試應用
    Flutter Sliver一生之敵 (ExtendedList)
    執行完畢之後,會有一個地址,我們將這個地址copy到devtools中的Connect
    Flutter Sliver一生之敵 (ExtendedList)
  • 點選Connect之後,在上部切換到Memory,我們就可以看到應用的實時記憶體變化監控了
    Flutter Sliver一生之敵 (ExtendedList)

不做任何處理的測試

  • 安卓,我開啟列表,一直向下拉,直到載入完畢300條,記憶體變化為下圖,可以看到記憶體起飛爆炸
    Flutter Sliver一生之敵 (ExtendedList)

Flutter Sliver一生之敵 (ExtendedList)

  • 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();
            });
          }
            });
          },
複製程式碼

經過優化之後執行同樣的步驟,安卓記憶體變化為下

Flutter Sliver一生之敵 (ExtendedList)

Flutter Sliver一生之敵 (ExtendedList)

ios也差不多,表現為下

Flutter Sliver一生之敵 (ExtendedList)

不夠極限?

從上面測試中,我們可以看到經過優化,圖片列表的記憶體得到了大大的優化,基本滿足我們的需求。但是我們做的還不夠極限,因為對於列表圖片來說,通常我們對它的圖片質量其實不是那麼高的(我又想起來了列表圖片一張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);
  }
複製程式碼
  • 在做了這些優化之後,我們再次進行測試,下面試記憶體變化情況,記憶體消耗再次被降低。
    Flutter Sliver一生之敵 (ExtendedList)

支援我的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全家桶,真香。

Flutter Sliver一生之敵 (ExtendedList)

相關文章