本文目的
- 分析flutter的Layout与Paint
- relayout boundary和repaint boundary是什么
- 开发者如何使用relayout boundary和repaint boundary
目录结构
- Flutter的绘图原理和UI的基本流程
- Widget在flutter绘图时的作用
- 分析Layout
- 分析Paint
- 总结
Flutter的绘图原理和UI的基本流程
-
Flutter的绘图原理
从图中可以看到,当GPU发出Vsunc信号时,会执行Dart代码绘制新UI,Dart-code会被执行为Layer Tree,然后经过Compositor合成后交由Skia引擎渲染处理为GPU数据,最后通过GL/Vulkan发给GPU。
而我们要分析的地方就在Dart->Layer Tree这里。 -
UI的基本流程
比如用户一个输入操作,可以理解发出为Vsunc信号,这时,fliutter会先做Animation相关工作,然后Build当前UI,之后视图开始布局和绘制。生成视图数据,但是只会生成Layer Tree,并不能直接使用,还是需要Composite合成为一个Layer进行Rasterize光栅化处理。层级合并的原因是因为一般flutter的层级很多,直接把每一层传给GPU传递,效率很低,所以会先做Composite,提高效率。
光栅化之后才会给Flutter-Engine处理,这里只是Framework层面的工作,所以看不到Engine,而我们分析的也只是Framework中的一小部分。
通过上面的讲解,我们大概已经了解了flutter的绘图的基本流程,但是我们并不清楚layout和paint做了什么,而Widget是如何变成Layou Tree的。但是这里内容太多,一句话说不清,所以我们还是先看下我们平时写的大量Widget在flutter绘图时的到底是啥用吧。
Widget在Flutter绘图时的作用
在这之前,我们要先了解几个概念
- Widget
- Element
- RenderObject
Widget
- 这里的Widget就是我们平时写的Widget,它是 Flutter中控件实现的基本单位。一个Widget里面一般存储了视图的配置信息,包括布局、属性等等。所以它只是一份直接使用的数据结构。在构建为结构树,甚至重新创建和销毁结构树时都不存在明显的性能问题。
Element
- Element是Widget的抽象,它承载了视图构建的上下文数据。flutter系统通过遍历 Element树来构建 RenderObject数据,所以Element是真正被使用的集合,Widget只是数据结构。比如视图更新时,只会标记dirty Element,而不会标记dirty Widget。
RenderObject
- 我们要分析的Layout、Paint均发生在RenderObject中,并且LayerTree也是由RenderObject生成,可见其重要程度。所以 Flutter中大部分的绘图性能优化发生在这里。RenderObject树构建的数据会被加入到 Engine所需的 LayerTree中。
而以上这三个概念也对应着三种树结构:模型树、呈现树、渲染树。
在解释他们的概念和关系以后,我们已经认识到RenderObject的重要性,因为以下Layout、Paint包括relayout boundary和repaint boundary都是在这里发生的。
一般一个Widget被更新,那么持有该 Widget的节点的Element会被标记为dirtyElement,在下一次更新界面时,Element树的这一部分子树便会被触发performRebuild,在Element树更新完成后,便能获得RenderObject树,接下来会进入Layout和Paint的流程。
Layout
-
Layout的目的是要计算出每个节点所占空间的真实大小。
在构建视图树的时候,节点的Constraints是自上而下的,但是计算layout是深度优先遍历,这是因为节点通过Constraints并不一定能够明确自己的size,有时它会依赖子节点的size,所以获取size大小是自下而上。
每个节点会接受到父对象的Constraints,子节点根据其来决定自己的大小,父对象会根据自己的逻辑决定子对象的位置来完成布局。
所以flutter的layout实际上就是这么简单的操作。那么简单肯定就有一些问题,比如某个节点的size变了,整个视图树就得重新计算?
肯定不是这样的,否则flutter就不存在图形的高性能了。flutter是通过Relayout boundary来处理这样的问题的。 - Relayout boundary
它的目的是提高flutter的绘图性能,它的作用是设置测量边界,边界内的Widget做任何改变都不会导致边界外重新计算并绘制。 -
当然它是有条件的,当满足以下三个条件的任意一个就会触发Relayout boundary
- constraints.isTight
- parentUsesSize == false
- sizedByParent == true
constraints.isTight
什么是isTight呢?用BoxConstraints为例
它有四个属性,分别是minWidth,maxWidth,minHeight,maxHeight
- tight
如果最小约束(minWidth,minHeight)和最大约束(maxWidth,maxHeight)分别都是一样的 - loose
如果最小约束都是0.0(不管最大约束),如果最小约束和最大约束都是0.0,就同时是tightly和loose - bounded
如果最大约束都不是infinite - unbounded
如果最大约束都是infinite - expanding
如果最小约束和最大约束都是infinite
所以isTight就是强约束,Widget的size已经被确定,里面的子Widget做任何变化,size都不会变。那么从该Widget开始里面的任意子Wisget做任意变化,都不会对外有影响,就会被添加Relayout boundary(说添加不科学,因为实际上这种情况,它会把size指向自己,这样就不会再向上递归而引起父Widget的Layout了)
parentUsesSize == false
实际上parentUsesSize与sizedByParent看起来很像,但含义有很大区别
parentUsesSize表示父Widget是否要依赖子Widget的size,如果是false,子Widget要重新布局的时候并不需要通知parent,布局的边界就是自身了。
sizedByParent == true
sizedByParent表示当前的Widget虽然不是isTight,但是通过其他约束属性,也可以明确的知道size,比如Expanded,并不一定需要明确的size。
通过查看RenderObject-1579行,当然可以看到Layout的实现
void layout(Constraints constraints, { bool parentUsesSize = false }) {
...
if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {
relayoutBoundary = this;
} else {
final RenderObject parent = this.parent;
relayoutBoundary = parent._relayoutBoundary;
}
...
}
通过Layout可以看到,flutter为了提高效率所做的努力,那作为开发者可以直接使用relayout boundary吗?
一般情况是不可以的,但是如果当你决定要自定义一个Row的时候,肯定是要使用它的。但是你可以间接的利用上面的三个条件来使你的Widget树某些地方拥有relayout boundary。比如以下用法
Row(children: [
Expanded(
child: Container(
height: 50.0, // add for test relayoutBoundary
child: LayoutBoundary(),
)),
Expanded(
child: Text('You have pushed the button this many times:'))
]
如果你想测试上面的三个条件成立时是否真的不会再layout,你可以自定义LayoutBoundaryDelegate来测试,比如
class LayoutBoundaryDelegate extends MultiChildLayoutDelegate {
LayoutBoundaryDelegate();
static const String title = 'title';
static const String summary = 'summary';
static const String paintBoundary = 'paintBoundary';
@override
void performLayout(Size size) {
print('TestLayoutDelegate performLayout ');
final BoxConstraints constraints = BoxConstraints(maxWidth: size.width);
final Size titleSize = layoutChild(title, constraints);
positionChild(title, Offset(0.0, 0.0));
final double summaryY = titleSize.height;
final Size descriptionSize = layoutChild(summary, constraints);
positionChild(summary, Offset(0.0, summaryY));
final double paintBoundaryY = summaryY + descriptionSize.height;
final Size paintBoundarySize = layoutChild(paintBoundary, constraints);
positionChild(
paintBoundary, Offset(paintBoundarySize.width / 2, paintBoundaryY));
}
@override
bool shouldRelayout(LayoutBoundaryDelegate oldDelegate) => false;
}
自定义的MultiChildLayoutDelegate需要使用CustomMultiChildLayout来配合使用
Container(
child: CustomMultiChildLayout(
delegate: LayoutBoundaryDelegate(),
children: [
LayoutId(
id: LayoutBoundaryDelegate.title,
child: Row(children: [
Expanded(child: LayoutBoundary()),
Expanded(child: Text( 'You have pushed the button this many times:'))
])),
LayoutId(
id: LayoutBoundaryDelegate.summary,
child: Container(
child: InkWell(
child: Text(
_buttonText,
style: Theme.of(context).textTheme.display1),
onTap: () {
setState(() {
_index++;
_buttonText = 'onTap$_index';
});
},
))),
LayoutId(
id: LayoutBoundaryDelegate.paintBoundary,
child: Container(
width: 50.0,
height: 50.0,
child: PaintBoundary())),
]),
)
我们在performLayout方法里做了打印操作,如果CustomMultiChildLayout的children里的任意一个child的size变化,就会打印这条信息,所以这样的代码在每次点击onTap的时候,都会打印'TestLayoutDelegate performLayout '
所以为了达到有RelayoutBoundary的效果,可以将代码中的Container添加宽高以达到constraints.isTight条件,这个实验就留给读者自己测试吧。 读者可以自己尝试验证
Paint
-
Paint的一个重要工作就是确定哪些Element放在同一Layer
-
布局size计算是自下而上的,但是paint是自上而下的。在layout之后,所有的Widget的大小、位置都已经确定,这时不需要再做遍历。
Paint也是按照深度优先的顺序,而且总是先绘制自身,再是子节点,比如节点 2是一个背景色绿色的视图,在绘制完自身后,绘制子节点3和4。当绘制完以后,Layer是按照深度优先的倒叙进行返回,类似Size的计算,而每个Layer就是一层,最后的结果是一个Layer Tree。
也许你已注意到在2节点由于一些其他原因导致它的部分UI5与6处于了同一层,这样的结果会导致当2需要重绘的时候,与其不想相关的6实际上也会被重绘,而存在性能损耗。Flutter的工程师当然不会作出这么愚蠢的设计。所以为了提高性能,与relayout boundary相应的存在repaint boundary。 -
repaint boundary
如果发生上面情况,repaint boundary会强制的使2切换到新Layer
这样强制使图层分开,以达到毫不相关的控件的Paint的时候,不会被影响导致重绘。
Repaint boundary一般不需要开发者设置。但开发者可以手动设置,Flutter提供RepaintBoundary组件,你可以在你认为需要的地方,设置Repaint boundary。
如何验证添加RepaintBoundary后,child就不会被同层的Widget的repaint影响呢,我们可以自定义一个Paint,比如
class PaintBoundary extends StatelessWidget {
@override
Widget build(BuildContext context) {
return CustomPaint(painter: CirclePainter(color: Colors.orange));
}
}
class CirclePainter extends CustomPainter {
final Color color;
const CirclePainter({this.color});
@override
void paint(Canvas canvas, Size size) {
print('CirclePainter paint');
var radius = size.width / 2;
var paint = Paint()
..color = color
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(radius, size.height), radius, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
只是很简单的绘制一个橙色的圆,在RelayoutBoundary验证代码中已贴出使用。我们只需看设置RepaintBoundary和不设置时候的区别。实验验证结果RelayoutBoundary确实可以避免CirclePainter发生重绘,即'CirclePainter paint'只会打印一次。读者可以自己尝试验证
总结
relayout boundary和repaint boundary都是Flutter为了提高绘图性能而做的努力。通常开发者可以使用RepaintBoundary组件来提高应用的性能,也可以根据relayout boundary的几个规则来使relayout boundary生效,从而提高性能。
测试代码传送门
参考
Flutter's Rendering Pipeline
深入了解Flutter界面开发(强烈推荐)
Flutter 渲染流水线浅析
Flutter原理与实践
Flutter中的布局绘制流程简析
Flutter Dart Framework原理简解