Flutter UI渲染与列表绘制原理

刚入门Flutter的时候,不知道你是否也被无穷无尽的build所困扰,或者莫名其妙就发现列表突然卡顿了, 我们的feed使用的是双列瀑布流,[一个比较有名的第三方库]https://github.com/letsar/flutter_staggered_grid_view),但使用过程中发现滑动卡顿,
这篇文章总结了一些基本的UI渲染与列表绘制原理,希望从原理到代码帮助大家能理清一些思路😸

UI绘制逻辑

举个最简单的例子:


class MyApp extends StatelessWidget  {

 @override
 Widget build(BuildContext context)  {
 return Text("MyApp");

}

众所周知,flutter里的UI有三棵树,分别为widget树,element树和renderObject树。

其中element树连接widget&renderObject。

当我们的页面第一次渲染时,整个app会有一个rootElement(这个是啥可以不管,就理解成最顶层flutter会帮你创建一个element, 绑定一个rootWidget), 然后它会调用build方法:(flutter里的build走的都是rebuild, 不要问我为什么)

这里build就会进入MyApp.build, 创建出Text("MyApp")

@override
void performRebuild() {
    built = build();
   _child = updateChild(_child, built, slot);
}

  • 对于statelesselement, build即执行widget.build

  • 对于statefulelement, build执行state.build

build完后会进入updateChild(非常关键的方法)

updateChild

我们先看下这个element.updateChild的逻辑:

  • 当newWidget(即这里的Text("MyApp")不为空时),因为child没有创建过,所以child为空,因此进入inflateWidget(newWidget, newSlot)流程 (可以先不看中间那一大段逻辑,直接看最后一句)
Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
  if (newWidget == null) {
    if (child != null)
      deactivateChild(child);
    return null;
  }
  if (child != null) {
    if (child.widget == newWidget) {
      if (child.slot != newSlot)
        updateSlotForChild(child, newSlot);
      return child;
    }
    if (Widget.canUpdate(child.widget, newWidget)) {
      if (child.slot != newSlot)
        updateSlotForChild(child, newSlot);
      child.update(newWidget);
      return child;
    }
    deactivateChild(child);
  }
  return inflateWidget(newWidget, newSlot);
}

这里inflateWidget就可以简单理解成开始创建element并挂载

Element inflateWidget(Widget newWidget, dynamic newSlot) {
  ....
  final Element newChild = newWidget.createElement();
  
  newChild.mount(this, newSlot);

  return newChild;
}

在inflateWidget中, widget创建对应的element, 然后这个element会挂载到this=parent上去。即关联子element与父element, 同时调用child element的performRebuild,可以看到又回到了最前面。开始会去构建Text的child(当然例子里没加)

所以,整棵树就是以这样的逻辑完成初次构建的!

SetState

_element.markNeedsBuild();

会把自己加入到​_dirtyElements​中,当下一次刷新时会调用​buildScope​

void buildScope(Element context, [ VoidCallback callback ]) {
    _dirtyElements[index].rebuild();
}

@override
void performRebuild() {
    built = build();
   _child = updateChild(_child, built, slot);
}

可以看到就走到了刚才的​updateChild​,那么我们再来看刚才中间没看的那段逻辑:

if (child != null) {
    if (child.widget == newWidget) {
      if (child.slot != newSlot)
        updateSlotForChild(child, newSlot);
      return child;
    }
    if (Widget.canUpdate(child.widget, newWidget)) {
      if (child.slot != newSlot)
        updateSlotForChild(child, newSlot);
      child.update(newWidget);
      return child;
    }
    deactivateChild(child);
  }

  • 如果新构建出来的widget和之前是一样的,那不用重新构建element, 可能只需要移动一下位置而已。(updateSlot)

  • 如果不一样,但是但是可更新,那需要调一下​child.update(newWidget)​;即要更新它的子节点

  • 如果都不能更新,那又要创新创建element和renderObject了。而我们知道,走到这一步会很耗时,是我们不希望看到的结果。

那么什么是可更新呢?

static bool canUpdate(Widget oldWidget, Widget newWidget) {
  return oldWidget.runtimeType == newWidget.runtimeType
      && oldWidget.key == newWidget.key;
}

即需要类是同一个类,key是同一个key。 类是同一个类好办,key是啥?

Key

前面创建element时(inflateWidget)少说了一些代码:

Element inflateWidget(Widget newWidget, dynamic newSlot) {
  final Key key = newWidget.key;
  if (key is GlobalKey) {
    final Element newChild = _retakeInactiveElement(key, newWidget);
    if (newChild != null) {
      newChild._activateWithParent(this, newSlot);
      final Element updatedChild = updateChild(newChild, newWidget, newSlot);
      return updatedChild;
    }
  }
  final Element newChild = newWidget.createElement();
  newChild.mount(this, newSlot);
  return newChild;
}

这里看到,判断了如果key is GlobalKey, 会进行_retakeInactiveElement的操作,就会从以前element中找对应的key的element, element进行复用防止重建

对于key存在两类:

  • GlobalKey

GlobalKey会记录到一个全局的map中,从globalkey可以拿到element/state/widegt,因此可以用它调用某widget中的方法

static final Map<GlobalKey, Element> _registry = <GlobalKey, Element>{};

  • LocalKey

考虑某种场景,某column下有两个widget, 点击某按钮后要交换这两个widget,如果没有key则交换会失败。原理可见https://juejin.im/post/5ca2152f6fb9a05e1a7a9a26#heading-4中的​StatefulContainer 比较过程。​

优先用localKey, 要跨widget访问时再用globalkey。

可优化的地方

既然从updateChild中知道只要widget和过去element持有的widget一样,那就不会重新创建element,也就不会耗时。因此很多页面可以将第一次构建出来的widget缓存下来

此时就回想到以前代码里为了解决build卡顿,做了很多如下操作(对于statefulWidget)

Widget _child;
@override
Widget build(BuildContext context) {
    if(_child == null){
        _child = xxx
    }
    return _child;
}

...那么,这种代码如果setState是不会起到作用的,updateChild中会走到:

if (child.widget == newWidget) {
  if (child.slot != newSlot)
    updateSlotForChild(child, newSlot);
  return child;
}

完全没更新直接返回了。。

flutter中的列表

ListView 组件的子元素都是按需加载的。换句话说,只有在可视区域的元素才会被初始化

ViewPort

ViewPort可以理解为可见视图,列表就是绘制在这片视图上的。

child.layout(SliverConstraints(
  axisDirection: axisDirection,
  growthDirection: growthDirection,
  userScrollDirection: adjustedUserScrollDirection,
  scrollOffset: sliverScrollOffset,
  precedingScrollExtent: precedingScrollExtent,
  overlap: maxPaintOffset - layoutOffset,
  remainingPaintExtent: math.max(0.0, remainingPaintExtent - layoutOffset + initialLayoutOffset),
  crossAxisExtent: crossAxisExtent,
  crossAxisDirection: crossAxisDirection,
  viewportMainAxisExtent: mainAxisExtent,
  remainingCacheExtent: math.max(0.0, remainingCacheExtent + cacheExtentCorrection),
  cacheOrigin: correctedCacheOrigin,
), parentUsesSize: true);
  • remainingPaintExtent表示在Viewport中剩余绘制区域大小

  • scrollOffset: 相比于ViewPort向上滑出的距离, 往上滑动为正

  • remainingCacheExtent: 可以绘制的cache剩余大小

  • cacheOrigin: math.max(cacheOrigin, -sliverScrollOffset) 这个比较优异,cacheOrigin默认为-250, 如果scrolloffset是100, 那么cacheOrigin = -100;如果scrolloffset是-300,那么cacheOrigin就是-250,后面会看到为什么要这么设计

RenderSliverList

1.png

在flutter中,Sliver可以看成是一个可滑动组件中的child。即一个可滑动组件是由多个child组成的。

PerformLayout

2.png

这张图实线框框是你的屏幕,虚线框框是目前列表滑到的距离。

几个变量解释:

  • Fake scrollOffset: 目前这个sliver已经滚动的距离。

  • cacheOrigin 给这个列表提供的缓存

  • True scrollOffset = fake scrolloffset + cacheorigin 即整个列表会从这个true scrolloffset开始绘制

  • remainingCacheExtent 剩下可以绘制的部分(包含cache)

  • targetEndScrollOffset = scrollOffset + remainingExtent 即整个列表绘制到这个targetEndScrollView

注意后面说的scrollOffset均为trueScrollOffset

Ps: 这里的每个变量都是可以和前面ViewPort中child.layout里的变量对应起来的。所以看到这里如果scrolloffset是300, 那之前说过传入的cache为-250,因此从50开始绘制;如果offset是-200, 那么cache就是-200,就会从0开始绘制。合理~

step1

找到目前整个列表中存在的第一个child(前面有些child可能已经被回收了即不存在了),计算它是否大于scrollOffset, 如果大于说明中间有child应该被layout但是这个child却不存在, 因此需要创建并插入到父节点中。

for (double earliestScrollOffset = childScrollOffset(earliestUsefulChild);
    earliestScrollOffset > scrollOffset;
    earliestScrollOffset = childScrollOffset(earliestUsefulChild)) {
    earliestUsefulChild = insertAndLayoutLeadingChild(childConstraints, parentUsesSize: true);
}
step2

如果endScrollOffset(当前最后一个child)在scrollOffset之前,说明这部分child是需要被回收掉的

while (endScrollOffset < scrollOffset) {
  leadingGarbage += 1;
  if (!advance()) {
  collectGarbage(leadingGarbage - 1, 0);
  final double extent = childScrollOffset(lastChild) + paintExtentOf(lastChild);
  geometry = SliverGeometry(
    scrollExtent: extent,
    paintExtent: 0.0,
    maxPaintExtent: extent,
  );
  return;
}
step3

从scrollOffset到endScrollOffset 遍历child, 如果存在就layout, 不存在就create&insert

while (endScrollOffset < targetEndScrollOffset) {
  if (!advance()) { //advance里就是看当前child存不存在,存在layout,不存在就create
    reachedEnd = true;
    break;
  }
}
step4

在targetEndScrollOffset后面的child也需要回收掉

// Finally count up all the remaining children and label them as garbage.
if (child != null) {
  child = childAfter(child);
  while (child != null) {
    trailingGarbage += 1;
    print("trailingGarbage:${trailingGarbage}");
    child = childAfter(child);
  }
}
step5

返回geometry,这个geometry就是list告诉viewport自己layout后的结果:

  • scrollExtent: 总的List可滚动距离,后面会说如果计算

  • paintExtent: 自己绘制了多少【可见】大小

  • cacheExtent: 自己占了多少cache大小。

geometry = SliverGeometry(
  scrollExtent: estimatedMaxScrollOffset,
  paintExtent: paintExtent,
  cacheExtent: cacheExtent,
  maxPaintExtent: estimatedMaxScrollOffset,
  // Conservative to avoid flickering away the clip during scroll.
  hasVisualOverflow: endScrollOffset > targetEndScrollOffsetForPaint || constraints.scrollOffset > 0.0,
);

CollectGarbage

即调用 _childManager.removeChild(child);

if (childParentData.keepAlive) { //keepalive后面说
  remove(child);
  _keepAliveBucket[childParentData.index] = child;
  child.parentData = childParentData;
  super.adoptChild(child);
  childParentData._keptAlive = true;
} else {
  _childManager.removeChild(child);
}

estimatedMaxScrollOffset

list会预估最大可滚动距离,根据目前已经渲染的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));
}

shrinkWrap

什么是shrinkWrap

https://stackoverflow.com/questions/54007073/what-does-the-shrink-wrap-property-do-in-flutter

return Column(
  children: <Widget>[
    ListView(
      children: <Widget>[
        Container(color: Colors.red, child: Text("1")),
        Container(color: Colors.orange, child: Text("2")),
      ],
    ),
  ],
);

开发业务的时候发现这么写是会报错的,因为column的大小是需要靠listview撑起来的,而listview本身是会撑满整个父view的:

size = constraints.biggest;
// We ignore the return value of applyViewportDimension below because we are
// going to go through performLayout next regardless.
switch (axis) {
  case Axis.vertical:
    offset.applyViewportDimension(size.height);
    break;
  case Axis.horizontal:
    offset.applyViewportDimension(size.width);
    break;
}

因此两者就死锁了。。。flutter遇到这种情况会直接报错,并提供shrinkWrap这个组件,此时viewport的大小将不是由它的父亲而决定,而是由它自己决定。此时它的mainAxisExtent会变成infinate,即会完整的进行排布。

Item keepAlive

这是一个有点鸡肋的变量,如果你列表里的item mixin AutomaticKeepAliveClientMixin 同时keepalive返回true, 那么你列表里所有的Item都不会被回收。。。那么显而易见内存可能会飙升,仅适合与列表内容不多又想改善点性能的情况。

我们回顾下创建和销毁child:

销毁:

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);
}

可以看到如果keepalive为true, 会放入一个_keepAliveBucket的篮子中。

创建:

if (_keepAliveBucket.containsKey(index)) {
  final RenderBox child = _keepAliveBucket.remove(index);
  final SliverMultiBoxAdaptorParentData childParentData = child.parentData;
  assert(childParentData._keptAlive);
  dropChild(child);
  child.parentData = childParentData;
  insert(child, after: after);
  childParentData._keptAlive = false;
} else {
  _childManager.createChild(index, after: after);
}

会从_keepAliveBucket这个篮子中去寻找当前index下的child, 以免重建。

SliverChildDelegate

当你创建一个Listview时,是可以传入一个叫delegate的值的

const ListView.custom({
    @required this.childrenDelegate,
})

后面,列表每次build一个元素时,都会走widget.delegate.build

Widget _build(int index) {
  return _childWidgets.putIfAbsent(index, () => widget.delegate.build(this, index));
}

以及layout结束时,会调用​widget.delegate.didFinishLayout(firstIndex, lastIndex);​传入此次绘制的第一个Index&最后一个index

@override
void didFinishLayout() {
  assert(debugAssertChildListLocked());
  final int firstIndex = _childElements.firstKey() ?? 0;
  final int lastIndex = _childElements.lastKey() ?? 0;
  widget.delegate.didFinishLayout(firstIndex, lastIndex);
}

这里其实就暴露接口给业务,让业务知道绘制情况。

list的keepalive

在build的child外面套了一层AutomaticKeepAlive,这里一开始可能会和前面的item keepalive混淆。

具体可以查看https://www.jianshu.com/p/db7ed17a4273

总结下,只有item mixin了AutomaticKeepAliveClientMixin, 上级中有人实现了AutomaticKeepAlive才可以

滚动实现

稍微总结下,在Sliver布局中,SliverConstraints(约束)和输出SliverGeometry 为输入和输出,而手势监听和滚动距离的计算则是靠Scrollable& ScrollController完成的,这两者会进行down&move&up手势的处理,具体不再具体分析了。代码如下:

@override
Widget build(BuildContext context) {
  final List<Widget> slivers = buildSlivers(context);
  final AxisDirection axisDirection = getDirection(context);

  final ScrollController scrollController = primary
    ? PrimaryScrollController.of(context)
    : controller;
  final Scrollable scrollable = Scrollable(
    dragStartBehavior: dragStartBehavior,
    axisDirection: axisDirection,
    controller: scrollController,
    physics: physics,
    semanticChildCount: semanticChildCount,
    viewportBuilder: (BuildContext context, ViewportOffset offset) {
       //我们的列表布局被通过buildViewport嵌套在了ViewPort或者ShrinkWrappingViewport中
      return buildViewport(context, offset, axisDirection, slivers);
    },
  );

双列瀑布流

为什么我们用的双列瀑布流会卡?只滑动0.1mm都感到滑动, 看过这个第三方库就会知道以下几方面导致:

  1. 不断创建新的child
for (var index = viewportOffset.firstChildIndex;
    mainAxisOffsets.any((o) => o <= targetEndScrollOffset);
    index++) {
    addAndLayoutChild(index, geometry.getBoxConstraints(constraints));
}

_createOrObtainChild(index);

_childManager.createChild(index);

在布局时,它会计算当前可见+缓存总的区域,在这片区域里进行addAndLayoutChild操作 => _createOrObtainChild中不管child存不存在都会进行_childManager.createChild(index);导致重新build

  1. 不断回收老的child

indices.toSet().difference(visibleIndices).forEach(_destroyOrCacheChild);

在每次layout时和listview一致会进行child的回收,但是回收策略比较无语,会比较当前所有存在的child和【屏幕可见】的child,如果不是屏幕可见则会进行destroy回收。

这两点使得每次稍微滚动就会不断回收不断build, 最终导致卡顿。

解决方法也很简单

  1. 在create中判断当前child是否存在,如果存在则不创建

  2. 销毁时对于指定缓存区域中的不销毁。

这两点能导致fps的提升,当然还不能达到非常顺畅的fps, 原因为loadmore时会重新setState导致重新build, 这一点需要研究新的方案(其实就是我还没研究出来)

技巧

  • print&debug大法好

相比于android&ios, flutter的framework源码会直接缓存在本地,因此可以自由的在源码里面加print与调试。(调试完记得代码改回去不然运行可能会出现未知异常)

  • 善用flutter performance(仅在as里尝试,vscode自行判断)

debug连接时勾上Track widget rebuilds 右边就能实时看到哪个widget build了, 同时你的dart代码里对应的widget左边会转黄色的圈(无缘无故build是真的烦), 注意打断点时右边会显示不出来(不知道为啥)

推荐资料

https://juejin.im/post/5caec613f265da03a00fbcde#heading-2

https://zhuanlan.zhihu.com/p/52723705

https://juejin.im/post/5ca2152f6fb9a05e1a7a9a26#heading-10

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342