Flutter UI 渲染浅析(五)Layout
系列文章的第五篇,本篇文章主要分析下 Render Tree 的构建及 Layout 过程。
在上篇文章结尾,提到 RenderObjectElement.attachRenderObject()
方法中调用 RenderObjectElement.insertChildRenderObject(renderObject, newSlot)
将 RenderObject 插入 Render Tree中,继续分析下 Render Tree 的构建过程及 RendererBinding.drawFrame()
过程。
在开始之前,先看下 RenderObject 和 RenderTree。
1、RenderObject
RenderObject 是 Render Tree 的构成节点,主要负责布局和绘制。
前面提到过,并不是所有的 Element 都会持有 RenderObject 的引用,只有 RenderObjectWidget 及其对应 RenderObjectElement 才会创建 RenderObject。
|
|
RenderObject 继承自 AbstractNode
,AbstractNode
是树形节点定义,持有 parent
引用,提供了挂载 adoptChild()
、卸载 dropChild()
、计算深度 redepthChildren()
等方法。
同时持有 ParentData
对象,可以存放子节点的一些特定信息,比如布局信息等。
下篇文章要分析的 Layer
也是 AbstractNode
的子类。
RenderObject 混入了 HitTestTarget
,HitTestTarget
提供了处理事件的能力。
RenderObject 定义了通用的布局和绘制协议。
但是没有定义坐标系统,例如子节点是布局在笛卡尔坐标系,还是极坐标系。
也没有指定具体的布局协议,例如是通过宽高,还是通过约束和大小,或者父节点在子节点的布局前后设置位置和大小。
或者子节点是否允许方法其自身的 ParentDara
对象信息。
也没有定义子树模型,比如其是否支持挂载子节点,允许挂载几个子节点等,这些能力由RenderObject 子类 mixin 提供。
RenderObject 的子类中
mixin RenderObjectWithChildMixin<ChildType extends RenderObject>
定义了允许挂载一个子节点的实现mixin ContainerRenderObjectMixin<ChildType extends RenderObject, ParentDataType extends ContainerParentDataMixin<ChildType>>
定义了允许挂载多个子节点的实现- RenderView 是根节点,用于App启动初始化Widget、Element、RenderObject、Layer,绘制完成后将Layer Tree 提交给 Engine
- RenderBox 是最重要的一个子类,其实现了基于 2D 笛卡尔坐标系的布局方式,其中的 Offset 定义距父布局的偏移量 x、y,Size 定义了该控件的大小。这种布局方式是和 Android、iOS 原生布局一致的。约束模型对应 BoxConstraints 盒约束
- RenderSlive 实现了支持滑动效果的布局方式,可以通过 ViewViewPort 内嵌到 RenderBox 中,约束模型对应于 SliverConstraints 分片约束
RenderObject类型、Constraints类型和ParentData类型对应关系如下:
RenderObject 类型 | Constraints类型 | ParentData类型 |
---|---|---|
RenderBox | BoxConstraints | BoxParentData |
RenderSlive | SliverConstraints | SliverLogicalParentData |
2、RenderTree 构建
继续分析上篇文章的结尾部分 RenderObjectElement.insertChildRenderObject()
。
在Flutter v1.21 及以上版本上,该方法被废弃,使用 RenderObjectElement.insertRenderObjectChild(renderObject, newSlot);
代替。
该方法是个空方法,需要子类实现。
|
|
以单子节点和多子节点Element为例,看下构建流程。
2.1、单子节点
单子节点实现:
|
|
- 获取 Element.mount() 过程中通过
Widget.createRenderObject()
已经构建好的 RenderObject - 将 RenderObject 挂载到 Render Tree
SingleChildRenderObjectElement 通过 SingleChildRenderObjectWidget.createElement()
创建。
SingleChildRenderObjectWidget 的子类包括:
对应的RenderObject 通过 SingleChildRenderObjectWidget.createRenderObject()
创建,创建出的 RenderObject 混入了 RenderObjectWithChildMixin。
上面提到 RenderObjectWithChildMixin 定义了允许挂载一个子节点的实现。
|
|
- 卸载老节点
- 更新子节点引用
- 调用
RenderObject.adoptChild()
挂载新节点- 初始化 ParentData
- markNeedsLayout Layout标脏
- markNeedsCompositingBitsUpdate 合成位标脏
- markNeedsSemanticsUpdate 辅助信息Semantic标脏
- 调用AbstractNode.adoptChild(),挂载到 Render Tree,将child.parent指向自己,更新树的深度
2.2、多子节点实现
以 MultiChildRenderObject 为例,看下多子节点实现。
|
|
- 获取 Element.mount() 过程中通过
Widget.createRenderObject()
已经构建好的 RenderObject - 将 RenderObject 挂载到 Render Tree
MultiChildRenderObjectElement 通过 MultiChildRenderObjectWidget.createElement()
创建。MultiChildRenderObjectWidget 的子类包含:
定义了多子节点实现,最终调用 ContainerParentDataMixin 将 RenderObject 挂载到 Render Tree 上。
|
|
- 调用
RenderObject.adoptChild()
挂载新节点- 初始化 ParentData
- markNeedsLayout Layout标脏
- markNeedsCompositingBitsUpdate 合成位标脏
- markNeedsSemanticsUpdate 辅助信息Semantic标脏
- 调用AbstractNode.adoptChild(),挂载到 Render Tree,将child.parent指向自己,更新树的深度
- 提供一个兄弟节点,将其关联到兄弟链上,用于后续流程兄弟节点之间遍历,分几种情况
- 依附的兄弟节点为空,插入在第一个子节点
- 依附的兄弟节点没有相关联的下一个兄弟节点,插入在兄弟节点队尾
- 依附的兄弟节点有相关联的下一个兄弟节点,插入在兄弟节点中间
至此,Render Tree 构建完毕,基于 Render Tree 接下来分析下 Layout 的过程。
3、markNeedsLayout 标脏
上面提到,在 RenderObject 节点挂载到 Render Tree 过程中,会触发 markNeedsLayout
Layout 标脏操作。
|
|
- 如果已经标脏,避免重复标脏
- 如果
_relayoutBoundary
布局边界是自本节点,将本节点加入PipelineOwner
渲染管线中的_nodesNeedingLayout
脏列表,触发scheduleFrame()
流程,等待 layout 流程处理 - 如果
_relayoutBoundary
布局边界不是本节点,那么向上遍历,直到找到布局边界,将布局边界节点加入PipelineOwner
渲染管线中的_nodesNeedingLayout
脏列表,触发scheduleFrame()
流程,等待 layout 流程处理
_relayoutBoundary
布局边界用于优化测量和布局效率,缩小计算范围,通过和 PipelineOwner
渲染管线中的_nodesNeedingLayout
脏列表配合,仅对标脏节点和布局边界子树进行测量和布局,达到布局次线性复杂度。
_relayoutBoundary
有两个地方赋值:
- RenderView 根节点,在 App 启动过程中调用 RenderObject.scheduleInitialLayout() 方法,
_relayoutBoundary = this;
赋值 - RenderObject Layout 阶段,在特定条件下,一些节点会被标记为_relayoutBoundary,这个我们后面详细分析
所以,在App启动后第一次布局绘制时,只有根节点 RenderView 是_relayoutBoundary,其余符合条件的节点,只有在首次布局测量时,在Layout阶段触发 layout() 方法进行标注,在下一次布局阶段生效。
4、RendererBinding.drawFrame()
上一篇文章结尾,我们提到 WidgetsBinding.drawFrame()
流程触发 rebuild(),接着触发 RendererBinding.drawFrame()
流程,看下方法实现。
|
|
通过PipelineOwner
渲染管线和Flutter Engine进行交互,执行以下过程:
- pipelineOwner.flushLayout() 布局阶段,遍历Layout被标脏的 RenderObject,调用 performLayout() 完成测量和布局工作
- pipelineOwner.flushCompositingBits() 标记合成阶段,遍历合成位标脏被标脏的RenderObject,标记那些repaintBoundary==true 的节点及其子节点为需要合成(Compisiting),为下一步合成layer做准备
- pipelineOwner.flushPaint() 绘制阶段,遍历绘制被标脏的RenderObject,执行绘制操作,并通过PaintingContext记录到PictureLayer,最终生成一颗LayerTree
- renderView.compositeFrame()合成阶段,遍历Dart Framework LayerTree,构建Engine LayerTree & Scene,将绘制数据通过Flutter Engine发送给GPU
- pipelineOwner.flushSemantics() 语义阶段,遍历辅助信息被标脏的RenderObject,执行_updateSemantics()更新语义
在Element.rebuild、布局、绘制、合成、语义这几个阶段,有很通用的处理流程
- RenderObject 节点先被标脏,记录在脏列表中
- 接着脏列表中的节点,在 RendererBinding.drawFrame() 中被分别处理
通过遍历脏列表,再加上中布局节点和绘制阶段,分别通过 _relayoutBoundary
和 _repaintBoundary
进行优化,最终达到整个整个流程都是次线性的,这也就是为什么说 Flutter 绘制高效的根本原因。
#5、flushLayout()
看下方法实现
|
|
遍历PipelineOwner
渲染管线中的_nodesNeedingLayout
脏列表中的 ,广度优先原则调用_layoutWithoutResize()
方法。
该阶段被记录在Timeline的Layout过程,在UI线程执行。
|
|
- 只有 _relayoutBoundary 节点才会被加入脏列表
- 测量和布局,performLayout() 是空方法,需要子类实现
- 辅助信息标脏
- needsPaint标脏,并记录在_nodesNeedingPaint列表中
由于是广度优先遍历,并且根节点是RenderView,先看下RenderView.performLayout()的实现
|
|
根据屏幕最大宽高,生成一个BoxConstraints盒约束,传递给子节点,子节点layout() 用于测量和布局。
看下 RenderObject 的 layout 方法实现:
|
|
5.1、RelayoutBoundary 布局边界
这里有三个概念比较重要:
- parentUsesSize :父节点是否依赖子节点的测量布局信息,才能够确定其自身的测量布局信息;默认为 false,为 true 时,当子节点layout标脏时,父节点也要标脏
- sizedByParent:根据父节点传递过来的约束条件 constraints,就可以唯一确定该节点的 Size 大小,不需要依赖其自身和子节点的属性就可以确定大小。
- isTight: 当盒约束 BoxConstraint 的 minWidth = maxWidth && minHeight == maxHeight 时,isTight 为true
根据这三个变量,可以确定该节点是否是 relayoutBoundary 布局边界,根本原因是布局边界的触发条件是,本节点不依赖父节点的改变而改变。
布局边界相当于在RenderTree中,插入了一层布局屏障,外界改变不会引起布局边界内子树的改变;同样的,当布局边界子树内部发生布局大小变化,也不会影响父节点重新布局。
布局边界机制可以提高布局性能。
5.2 Layout 布局
performResize() 用于测量大小,子节点需要重写该方法,根据父节点传递的constraints约束计算大小,结果记录在 _size 中。仅当sizedByParent为true时,会触发该方法。
performLayout() 用于布局,子类需要重写该方法,布局信息存储在当前节点parentData中,后面在绘制阶段会由父节点使用。
在RenderTree中 递归调用 layout()->performLayout()/performResize()->child.layout()->…完成测量和布局。
布局原则:
Constraints go down.
Sizes go up.
Parent sets position.
可以理解为约束从高向下传递,子节点根据约束和自身情况确定大小,并向上告知父节点,最终布局情况由父节点确定。
整个布局过程是次线性的。
本篇文章分析了RenderObject及RenderTree的构建,以及RenderTree Layout 布局过程。
下篇文章继续分析下Paint绘制过程。
本文链接: http://w4lle.com/2021/01/15/flutter-ui-layout/
版权声明:本文为 w4lle 原创文章,可以随意转载,但必须在明确位置注明出处!
本文链接: http://w4lle.com/2021/01/15/flutter-ui-layout/