刚入门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
在flutter中,Sliver可以看成是一个可滑动组件中的child。即一个可滑动组件是由多个child组成的。
PerformLayout
这张图实线框框是你的屏幕,虚线框框是目前列表滑到的距离。
几个变量解释:
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都感到滑动, 看过这个第三方库就会知道以下几方面导致:
- 不断创建新的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
- 不断回收老的child
indices.toSet().difference(visibleIndices).forEach(_destroyOrCacheChild);
在每次layout时和listview一致会进行child的回收,但是回收策略比较无语,会比较当前所有存在的child和【屏幕可见】的child,如果不是屏幕可见则会进行destroy回收。
这两点使得每次稍微滚动就会不断回收不断build, 最终导致卡顿。
解决方法也很简单
在create中判断当前child是否存在,如果存在则不创建
销毁时对于指定缓存区域中的不销毁。
这两点能导致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