Flutter增强列表-ListView性能问题分析

·  阅读 4925

ListView的构建过程与使用中的性能问题分析

学习最忌盲目,无计划,零碎的知识点无法串成系统。学到哪,忘到哪,面试想不起来。这里我整理了Flutter面试中最常问以及Flutter framework中最核心的几块知识,大概化二十篇左右文章分析,欢迎关注,共同进步。![Flutter framework]

欢迎搜索公众号:进击的Flutter或者runflutter 里面整理收集了最详细的Flutter进阶与优化指南。关注我,获取我的最新文章~

导语:

最近因为在做Flutter中相关的性能优化,搜刮了网上所有的文章之后,看到了闲鱼的Flutter 高性能、多功能的全场景滚动容器。但奈何该组件没有开源,因此准备从文章给出的思路尝试研究和开发一个高性能的ScrollView。这个系列预计会分为4-5篇文章,前三篇主要对现有问题研究和分析,后两篇实际的进行开发。

原理篇:

1、Widget、Element、Render树究竟是如何形成的?

2、ListView的构建过程与性能问题分析

要想分析ListView的性能问题,首先我们得掌握ListView的构建过程。在阅读本文之前,最好已经熟悉Flutter三棵树以及基本的布局原理与Flutter滑动原理,不然构建过程的理解可能任然停留在表面。推荐Widget、Element、Render树究竟是如何形成的?总结了30个例子之后,我悟到了Flutter的布局原理深入进阶-一张图理清Flutter的滑动原理


ListView类结构关系

上一期文章中我们学习了Flutter中三棵树的构建过程,引申出Flutter UI体系中的一个重要设计思想,即:

Widget -> 对于每一个页面元素的抽象,便于开发者使用。
Element -> 管理整个UI的构建,桥接Widget与RenderObject,提供高效的刷新机制。 RenderObject -> 屏蔽每一个元素具体的布局和渲染细节。

对于Flutter中任何的控件,我们都可以从这三个类掌握它的构建渲染过程(animation阶段,build阶段,layout阶段,paint阶段)!!! 同样的,我们也以这三个类为线索剖析ListView,先来张总览图压压惊!

看到这张图是不是头皮发麻,别急,我们一步步分析。


ListView嵌套结构

首先,我们将聚光灯打在我们今天的主角ListView上

上图中我们可以看出,首先ListView继承于BoxScrollView继承于ScrollView继承于StatelessWidget。我们知道StatelessWidget是组合类的Widget,它只是在build()方法中组合多个Widget形成嵌套结构。在这个继承关系中build()方法由ScollView实现。

这个方法中,首先调用了 List<Widget> buildSlivers(BuildContext context)这个抽象方法得到一个Widget的集合 slivers。这个方法最终由ListView实现,返回的是一个SliverList(slivers集合中只有这一个元素)。这个集合作为参数被传入buildViewport,而这个方法的结果被嵌套在Scrollable中

这个方法返回一个Viewport类的组件,Viewport是一个可以显示多个Widget(采用Sliver布局协议)的组件,根据滑动偏移显示不同区域,这个滑动偏移由Scrollable收集滑动手势提供。整个Widger的主要嵌套结构就是 ScollView(ListView) -> Scrollable -> Viewport -> SliverList 。看完这两段源码之后回过去看上面的小图,是不是清晰了许多。


三棵树的形成过程

我们知道,Flutter中,widget只是一个配置文件,构建过程中主要的开销在于Element树建立与更新,由BuildOwner管理。根据上面梳理的Widget嵌套结构,我们可以查出对应的三棵树的结构(忽略其中次要嵌套结构)

Widget、Element、Render树究竟是如何形成的一期中提到,Element树形成的过程就是根据Widget的嵌套的每一个节点递归的调用Element.mount()这个方法将自己插入树中。对于组合类的Widget-ScrollView和Scrollable我们很清楚,他的mount()过程核心在于updateChild(_child, built, slot)方法,在第一次构建的时候这个方法会调用子节点的inflateWidget(newWidget, newSlot)生成对应的Element对象并插入到树中。

而对于渲染类的Widget-ViewportElement在上一篇文章中也提到了,child节点的element集合会挂载到他的children属性上,RenderObject对象通过双向链表进行管理。这里由于Viewport下面只有一个child即SliverList,所以这里他只有一个子节点SliverMutiBoxAdaptorElement。而最后的SliverMultiBoxAdaptorElement节点中,我们发现他并没有重写mount()方法

所以这里执行的是父类RenderObjectElement的mount()。

RenderObjectElement.mount()这个方法我们在上一期分析过,首先调用super.mount()将自己挂载在Element树上。之后的核心逻辑就是图中标记的方法。这个方法会向上找到最近的RenderObject,然后将自己挂载上去,形成RenderObject树。

看到这里实际上Element和Render树都只到了ListView这一层级,与每一个item没有关联。那么我们在使用ListView的时候,每一个item节点究竟是如何插入到这个树中的呢?


ListView的懒加载过程

要解决上面的疑惑,先思考两个本质问题。

1、在当前Flutter的UI体系中,有没有Widget可以绕过Element树直接显示到屏幕上(不考虑Scene等底层Api)?

2、如果ListView的item在mount阶段就全部挂载到element树上了,会有什么问题?

第一个问题,如果这样的Widget,那么ListView的每一个item可能不需要挂载就可以显示。但就目前我的了解,是不存在的(如果有误,欢迎评论交流)。渲染到屏幕上的Widget最终都会通过RenderObject实现绘制的细节。查看RenderObject的markNeedsPaint()方法,在其调用里面有一个关键点,就是他会依赖树形结构。而RenderObject树的形成依赖RenderObjectElement。所以ListView的每一个item一定会在某个阶段并入到Element和RenderObject树中

第二个问题,一般我们在使用ListView的时候往往是item数量较多,如果在mount阶段一次性挂载了所有的节点,那么在构建的节点很容易发生卡顿,借鉴原生的思路也有一个重要的设计方法懒加载

懒加载可以理解为按需加载,如何理解"按需"?"按需"就是需要显示到屏幕上的页面元素,那么我们如何判断这个元素需要显示到页面上呢?最简单的思路就是,在布局过程过程中,不停的布局子节点,直到当前窗口范围被布满或者没有子节点。在Flutter中,还额外增加了一个缓存区(double cacheExtent),所以这个范围变成了窗口大小加上缓存区大小(默认是250)


布局构建过程

提到布局,那么自然我们从RenderObject树开始捋,先看看RenderViewport的布局过程。

  ///布局仅由父节点决定,与child节点无关,宽高从performResize中获取
  @override
  bool get sizedByParent => true;

  @override
  void performResize() {
    assert(() {
      if (!constraints.hasBoundedHeight || !constraints.hasBoundedWidth) {
	///抛出没有宽高限制的异常
      }
      return true;
    }());
    ///尺寸为宽高的最大值
    size = constraints.biggest;
    switch (axis) {
      case Axis.vertical:
        offset.applyViewportDimension(size.height);
        break;
      case Axis.horizontal:
        offset.applyViewportDimension(size.width);
        break;
    }
  }
复制代码

因为RenderViewport中sizeByParent为true,说明他的大小仅由父元素给约束决定,与子节点无关。 再看他的performLayout()

@override
  void performLayout() {
    double mainAxisExtent;
    double crossAxisExtent;
    switch (axis) {
      case Axis.vertical:
        mainAxisExtent = size.height;
        crossAxisExtent = size.width;
        break;
      case Axis.horizontal:
        mainAxisExtent = size.width;
        crossAxisExtent = size.height;
        break;
    }

    final double centerOffsetAdjustment = center.centerOffsetAdjustment;
    double correction;
    int count = 0;
    do {
      ///尝试布局
      correction = _attemptLayout(mainAxisExtent, crossAxisExtent, offset.pixels + centerOffsetAdjustment);
      if (correction != 0.0) {
      	///有误差,修正
        offset.correctBy(correction);
      } else {
      	///没有误差,跳出循环
        if (offset.applyContentDimensions(
              math.min(0.0, _minScrollExtent + mainAxisExtent * anchor),
              math.max(0.0, _maxScrollExtent - mainAxisExtent * (1.0 - anchor)),
           ))
          break;
      }
      count += 1;
    } while (count < _maxLayoutCycles);
  }
复制代码

这个方法会调用_attemptLayout,最终调用layoutChildSequence对于viewport中的每一个child进行layout(使用Sliver约束布局,区别于之前提到的Box约束)由于我们的child只有一个即RenderSliverList,所以查看他的布局过程是怎样。

源码太长,以Element为线索的话,我画了时序图标明主要的流程。RenderSliverList中有个循环,当endScrollOffset小于targetEndScrollOffset的时候,会调用insertAndLayoutChild(),这个方法最终会调用到SliverMultiBoxAdaptorElement中,由代理类SliverChildBuilderDelegate生成child(ListView的itemBuilder传递到这儿),之后对每一个child进行layout,累加endScrollOffset。

有了这样的认识回去看前面的结构图,是不是要更清晰了一点。

当然这里面有两个细节我们可以关注一下

1、在RenderSliverList的布局过程中,child节点的element创建是运行在BuildOwner的buildScope方法中

2、ListView会对每一个child节点通过delegate嵌套KeyedSubtree、AutomaticeKeepAlive、RepaintBoundary组件

最后借用upYang大佬在Flutter ListView 是如何管理 item 的?中画的两张神图表示这个过程。

1、ListView的创建

2、ListView的滚动


使用ListView时存在的性能问题分析

在粗略的了解了ListView的构建过程之后,我们开始对ListView使用过程中的问题进行分析。

1、加载更多的更新问题

闲鱼的Flutter 高性能、多功能的全场景滚动容器一文中提到,我们在使用ListView的使用往往会组合刷新控件,添加加载更多的功能。当加载更多的时候,我们一般会通过刷新列表来显示更多元素。最终会调用到SliverMultiBoxAdaptorElement.performRebuild()

清空所有 child widget 缓存,重新 build child widget,update child Element;如果遇到数据的变化,例如 insert、delete,很有可能导致 element 无法复用,这样 rebuild 的成本会更高。通过断点发现,调用setState()之后,item的build会重走一遍。这时如果对于ListView有粒度更细的操作,例如原生上Adapter的增加删除等操作,那么在这种场景下就能带来一定的优化。

2、Element被回收后的复用问题

其次就是Element的复用,SliverMultiBoxAdaptorElement 通过 _childElements 来缓存 elements,当滚动超出 viewport 的显示以及预加载范围或者数据源发生变化,会通过调用 collectGarbage 方法回收不需要的 elements;

这个方法最终会调用到SliverMultiBoxAdaptorElement.removeChild(RenderBox child)

其中核心的updateChild方法的第一个参数传递的是index对应的element对象,而第二个参数变成了null,在原来我一直在错误的使用 setState()? 中提到过,在第二个参数为null的时候,那么之前的element对象会被卸载unmount()。这样在二次创建的时候,该index对应的element对象又会被再次创建。所以这里可以通过建立一个element缓存池,在创建的时候优先从缓冲池获取;

3、分帧上屏

最后一点就是每一个item的分帧上屏,个人感觉这点比较有意义。因为即使上面我们将加载更多的场景进行了优化,但是在ListView创建的时候,任会对屏幕上能显示的Widget进行构建,如果item较为复杂,在进入页面的时候,可能发生构建卡顿。通过占位削峰的,将复杂widget分为多帧渲染是一个不错的思路,不过暂时还没想明白如何实现。


结尾

这期源码梳理花了很长的时间,远远没有行文时的流畅,因为早期陷入了Sliver约束的布局过程中,研究了很久。但其实我们的优化和布局关系不是很大,要根据线索去梳理主干源码,这样才不会陷入其中无法自拔。

最后感谢一下参考学习到各位大佬的文章:

法佬:大佬,sliver的一生之敌 Flutter Sliver一生之敌 (ExtendedList)

upYang:制图大佬,对于滑动研究非常深刻Flutter ListView 是如何滚动的?

TravelingLight_:大佬,可以看看各种Sliver的解析Flutter - 循序渐进 Sliver

对于ListView的分析就到这儿了,下面就打算对于这几个问题开干了!理解完原理之后,对于解决问题也有了一定的思路,下一期聊聊对于这个ListView的功能设计与规划,欢迎持续关注!!

最后求个赞QAQ,你的赞是我更新路上强大的动力。

分类:
Android
标签:
分类:
Android
标签: