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

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

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

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

导语:

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

原理篇:markdown

一、Widget、Element、Render树到底是如何造成的?app

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

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


ListView类结构关系

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

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

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

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


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的懒加载过程

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

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

二、若是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。

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

固然这里面有两个细节咱们能够关注一下

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

二、ListView会对每个child节点经过delegate嵌套KeyedSubtree、AutomaticeKeepAlive、RepaintBoundary组件

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

一、ListView的建立

二、ListView的滚动


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

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

一、加载更多的更新问题

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

清空全部 child widget 缓存,从新 build child widget,update child Element;若是遇到数据的变化,例如 insert、delete,颇有可能致使 element 没法复用,这样 rebuild 的成本会更高。经过断点发现,调用setState()以后,item的build会重走一遍。这时若是对于ListView有粒度更细的操做,例如原生上Adapter的增长删除等操做,那么在这种场景下就能带来必定的优化。

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

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

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

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

三、分帧上屏

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


结尾

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

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

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

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

TravelingLight_:大佬,能够看看各类Sliver的解析Flutter - 按部就班 Sliver

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

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

相关文章
相关标签/搜索