即使是哥布林也能分分钟实现一个支持复用的ListView?——关于flutter listView复用的实验性想法和发现

1,853 阅读7分钟

前言

这篇文章呢,可以说是对最近flutter的listView研究做个小小的汇总;

其实呢,我一直对flutter的设计没太有什么好感,尤其是完成那个支持仿真翻页的小说阅读器的那段时期,不止一次的想:md这做的啥玩意,看看隔壁Android 的xxx,就你这还好意思对标Android原生?

PS:其实现在关于文字绘制这块我还是这么想的;

不过最近看了闲鱼的 Flutter 高性能、多功能的全场景滚动容器,一定要看!,有了些想法改进重写那个小说阅读器,研究了下,发现flutter的listView设计,好像还是蛮不错的,思路很清晰很轻量,相较之下,Android 就比较沉重了;(可能也是现在的listView太初期的缘故,不过设计思路倒是蛮好读的)

PPS:话说是不是闲鱼的自定义engine版本太低了,所以才没那些功能?像曝光、复用这块都已经有现成的部分了啊……为啥要费劲自定义呢?

演员就位,好戏开始

首先呢,默认大家都对flutter的一些基础知识、比如说三棵树及其作用啊什么的都已经了解;当然没了解的也没太大关系,百度谷歌下,这玩意的讲解已经烂大街了,看个5分钟了解下大概就够了;

PS:如果懒得看,直接翻后面总结部分,一步到胃;

因为widget树和element树并不参与绘制过程,所以相对轻量,所以在我看来,复用renderObject,即可提高很多性能,所以问题来了:

怎么去复用renderObject?

要解决这个问题,我们就要开始追踪下RenderObject跟element的爱恨情仇;

当然关于他俩的鸡毛蒜皮或陈谷子烂芝麻的事就不在这里提了,直接看相关的部分;

widget树?那玩意就是个舔狗,召之即来挥之即去的家伙,不用管;

众所周知,一个View要想展示,必须要走三步:measure、layout、draw;flutter中同样道理,只不过这事是renderObject来做的;在listView中找下相关方法就能找到相关部分:

class RenderSliverList extends RenderSliverMultiBoxAdaptor {
  /// Creates a sliver that places multiple box children in a linear array along
  /// the main axis.
  ///
  /// The [childManager] argument must not be null.
  RenderSliverList({
    required RenderSliverBoxChildManager childManager,
  }) : super(childManager: childManager);

  @override
  void performLayout() {
    …………一堆不相关的
    
    /// 好哥哥看过来
    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!);
      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 = childScrollOffset(child!)! + paintExtentOf(child!);
      return true;
    }

    // Find the first child that ends after the scroll offset.
    while (endScrollOffset < scrollOffset) {
      leadingGarbage += 1;
      if (!advance()) {
        assert(leadingGarbage == childCount);
        assert(child == null);
        // 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;
      }
    }
    
    ……

    collectGarbage(leadingGarbage, trailingGarbage);

    ……
  }
}

注意其中的advance(),其中有个insertAndLayoutChild 方法,从字面上来看,就是它负责插入子RenderObject的

  @protected
  RenderBox? insertAndLayoutChild(
    BoxConstraints childConstraints, {
    required RenderBox? after,
    bool parentUsesSize = false,
  }) {
    assert(_debugAssertChildListLocked());
    assert(after != null);
    final int index = indexOf(after!) + 1;
    _createOrObtainChild(index, after: after);
    final RenderBox? child = childAfter(after);
    if (child != null && indexOf(child) == index) {
      child.layout(childConstraints, parentUsesSize: parentUsesSize);
      return child;
    }
    childManager.setDidUnderflow(true);
    return null;
  }

唉,其中有个_createOrObtainChild 方法,从字面上翻译,意思是,创建或获取child?获取child?

再点进去看看

  void _createOrObtainChild(int index, { required RenderBox? after }) {
    invokeLayoutCallback<SliverConstraints>((SliverConstraints constraints) {
      assert(constraints == this.constraints);
      if (_keepAliveBucket.containsKey(index)) {
        final RenderBox child = _keepAliveBucket.remove(index)!;
        final SliverMultiBoxAdaptorParentData childParentData = child.parentData as SliverMultiBoxAdaptorParentData;
        assert(childParentData._keptAlive);
        dropChild(child);
        child.parentData = childParentData;
        insert(child, after: after);
        childParentData._keptAlive = false;
      } else {
        _childManager.createChild(index, after: after);
      }
    });
  }

下面那个createChild肯定不是缓存相关的了,所以这个_keepAliveBucket 嫌疑很大唉,看下他的方法内容,应该就是它做复用了;但是被复用的对象是谁加进来的呢?

点一下看下都是谁在用这个_keepAliveBucket

em,好像还不少人用,但是没有关西;反正我就想知道是谁往里面塞数据,这么再一看,只剩下两个方法:

一个是move方法,看名字就不像; 一个是_destroyOrCacheChild 方法,看上去就是它,点进去看看;

  void _destroyOrCacheChild(RenderBox child) {
    final SliverMultiBoxAdaptorParentData childParentData = child.parentData as SliverMultiBoxAdaptorParentData;
    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);
    }
  }

所以又冒出一个新东西:SliverMultiBoxAdaptorParentData,那么是不是只要让这玩意的 keepAlive为true 就行了

先看下是谁在调用这个_destroyOrCacheChild;

唉,就一个方法 collectGarbage ,而这个方法只在performLayout 调用,跟那个_createOrObtainChild 方法一样,都是直接或间接在performLayout方法中调用,看来跟它一样,属于比较直接的生命周期类方法;

那么直接重写这个collectGarbage 方法,讲要回收的对象的keepAlive改为true,不就可以了么?

就这?????

最后试了下,好像还真是这样,创建过的RenderObject,不会再创建第二次,createChild 方法每个index只调用一次;应该是复用成功了;

拓展与猜想

复用问题就这么解决了?就这 两个字真的是我当时的想法,甚至感觉好像不会这么简单,是不是哪里有坑啊……

不过我因此产生了一些猜想:

如果这样的话,能否像RecyclerView那样彻底复用RenderObject?

现在是根据index来复用的,很简单;如果滑动到新的item,而且缓存中不存在,还是会走create;

但是如果像Android的RecyclerView那样,直接拿缓存区的RenderObject,然后重新塞回去替代创建,并通过更新机制更新展示数据,这样是否可行呢?记得看过某篇文章中说过,flutter内部更新使用一个diff算法来做的,挺高效的,这样用高效的diff算法应该比单纯创建高效吧,应该吧~~~

总结

复用这块直接用flutter官方提供的就完事了,当然纯属试验性质,有没有坑还真没试出来,也没做全面测试

这是我的flutter doctor -v 关于flutter部分的信息,如果你那代码有差别的话,可以对比参考下:

[√] Flutter (Channel stable, 1.22.5, on Microsoft Windows [Version 10.0.17763.1577], locale zh-CN)
    • Flutter version 1.22.5 at D:\Program File\sdk\flutter\flutter_windows_v1.9.1+hotfix.2-stable
    • Framework revision 7891006299 (9 weeks ago), 2020-12-10 11:54:40 -0800
    • Engine revision ae90085a84
    • Dart version 2.10.4

下面上关键部分代码,默认自定义的 RenderSliverList 已经通过继承和重写引入进去

class RecyclerRenderSliverList extends RenderSliverList {
  RecyclerRenderSliverList({
    @required RenderSliverBoxChildManager childManager,
  }) : super(childManager: childManager);

  @override
  void collectGarbage(int leadingGarbage, int trailingGarbage) {
    /// 如果从头开始要回收的垃圾数量+从尾开始要回收的垃圾数量 不等于 0(也就是大于0)
    if (leadingGarbage + trailingGarbage != 0) {
      print("collectGarbage : " +
          " leadingGarbage : " +
          leadingGarbage.toString() +
          ", trailingGarbage : " +
          trailingGarbage.toString());

      if (childCount >= leadingGarbage + trailingGarbage) {
        int tempLeadingGarbage = leadingGarbage;
        int tempTrailingGarbage = trailingGarbage;

        RenderObject tempFirstChild = firstChild;
        RenderObject tempLastChild = lastChild;

        while (tempLeadingGarbage > 0) {
          /// 标记keepAlive为true
          (tempFirstChild.parentData as SliverMultiBoxAdaptorParentData)
              .keepAlive = true;
          tempFirstChild = childAfter(tempFirstChild);
          tempLeadingGarbage -= 1;
        }

        while (tempTrailingGarbage > 0) {
          /// 标记keepAlive为true
          (tempLastChild.parentData as SliverMultiBoxAdaptorParentData)
              .keepAlive = true;
          tempLastChild = childBefore(tempLastChild);
          tempTrailingGarbage -= 1;
        }
      }
    }

    /// 剩下的flutter都做好了
    super.collectGarbage(leadingGarbage, trailingGarbage);
  }
}

就这么简单…………

当然这块是完全复用,貌似没上限的那种,就是那种你要有一万个item,他就给你缓存一万个,所以理论上会非常吃内存,正确的做法应该要结合自己定义的缓存规则来做,不过那块还没搞~

所以还需要进一步测试研究~

另外补充一小点:

曝光这块其实也蛮简单的,这帮renderObject的parentData都是SliverMultiBoxAdaptorParentData ,里面都带上了index……

所以其实只要遍历下子child,看下保存的offset的数值,就可以得知当前第一个可见项什么的……然后直接从parentData中拿index就完事了

这个我是真的感觉没啥问题的

题外话

如果还有需要,可以拉下 flutter_novel 的dev分支,里面的reader2文件夹就是相关部分的,不过需要自己找,目前那块都是试验性质的,搞的其实有点乱;

在这里先给大家拜个年,如果不出意外的话,接下来的12天我就要蹲在提瓦特大陆了~所以嘛,失个联,很正常嘛,除非大家能在提瓦特大陆相遇,然后问候一句:

原来你也玩原神?

咳咳,真tmd尬