这个是我们Flutter的原理篇第三篇内容,第二篇的内容大家感兴趣的话可以点击这个《Flutter运行原理篇之Build构建的过程》连接去查看,上篇我们说到了build构建的原理及过程,还留了个小的彩蛋,今天我们的内容是《Flutter运行原理篇之layout布局的过程》,顺便把那个小的彩蛋揭开他的秘密,好的让我们愉快的开始吧:
首先我们还是复习下Widget的更新运行要经过5个步骤:
void drawFrame() {
buildOwner!.buildScope(renderViewElement!); // 1.重新构建widget
super.drawFrame();
//下面几个是在super.drawFrame()执行的
pipelineOwner.flushLayout(); // 2.更新布局
pipelineOwner.flushCompositingBits(); //3.更新“层合成”信息
pipelineOwner.flushPaint(); // 4.重绘
if (sendFramesToEngine) {
renderView.compositeFrame(); // 5. 上屏,将绘制出的bit数据发送给GPU
}
}
今天我们说的的Layout是其中的第二个重要的步骤,我们先来思考一个问题为什么需要Layout,你想想如果一个组件的属性或者内容发生了变化,这个时候组件的显示的Size(尺寸)发生了变化,这个时候就需要布局(或者重新布局),大家需要再注意一个问题就是只有RenderObject的对象及其子类才会具有Layout的过程哦,具体原因我们会和上面的提到的彩蛋在末尾一起说明原因
我们今天的layout分两种情况来说:
1. 第一次运行程序开始布局的时候(初始化的时候)
2. 更新布局的时候(包括第一次需要布局的时候)
我们先来说说第一种的情况,这种情况步骤比较简单:
Class RendererBinding
@override
void initInstances() {
//省略部分代码
initRenderView();
//省略部分代码
}
void initRenderView() {
renderView = RenderView(configuration: createViewConfiguration(), window: window);
renderView.prepareInitialFrame();
}
这里的renderView是根RenderObject,这个大家要先记住这个
Class RenderView
void prepareInitialFrame() {
scheduleInitialLayout();
scheduleInitialPaint(_updateMatricesAndCreateNewRootLayer());
}
void scheduleInitialLayout() {
_relayoutBoundary = this;
owner!._nodesNeedingLayout.add(this);
}
这里面的owner是PipelineOwner,上文我们也见过了,上面流程可以看出初始化时会把renderView(根RenderObject)加入到需要布局list中,那么在第一帧到来时就会调用其layout进行布局,这里我们会晚点再来看这个list,先往下看
再来说说第二种情况的时候,也就是需要更新布局的时候,其实这个也包括第一次需要布局的时候触发的方法:
void markNeedsLayout() {
if (_needsLayout) {
return;
}
if (_relayoutBoundary != this) {
markParentNeedsLayout();
} else {
_needsLayout = true;
if (owner != null) {
owner!._nodesNeedingLayout.add(this);
owner!.requestVisualUpdate();
}
}
}
markNeedsLayout这个就是最关键的方法,也是整个需要布局的开始,这里有一个变量relayoutBoundary的概念,我们稍微提一下这个边界布局的概念
某个组件的布局变化后,会影响其它组件的布局,所以当有组件布局发生变化后,最笨的办法是对整棵组件树 relayout(重新布局)!但是对所有组件进行 relayout 的成本还是太大,所以我们需要探索一下降低 relayout 成本的方案。实际上,在一些特定场景下,组件发生变化后我们只需要对部分组件进行重新布局即可(而无需对整棵树 relayout ),这个时候flutter就引入了边界布局的概念,我们先借用一张图和一个例子:
假如 Text3 的文本长度发生变化,则会导致 Text4 的位置和 Column2 的大小也会变化;又因为 Column2 的父组件 SizedBox 已经限定了大小,所以 SizedBox 的大小和位置都不会变化。所以最终我们需要进行 relayout 的组件是:Text3、Column2,这里需要注意:
Text4 是不需要重新布局的,因为 Text4 的大小没有发生变化,只是位置发生变化,而它的位置是在父组件 Column2 布局时确定的。
很容易发现:假如 Text3 和 Column2 之间还有其它组件,则这些组件也都是需要 relayout 的。在本例中,Column2 就是 Text3 的 relayoutBoundary (重新布局的边界节点)。每个组件的 renderObject 中都有一个 _relayoutBoundary 属性指向自身的布局,如果当前节点布局发生变化后,自身到 _relayoutBoundary 路径上的所有的节点都需要 relayout。
那么,一个组件是否是 relayoutBoundary 的条件是什么呢?这里有一个原则和四个场景,原则是“组件自身的大小变化不会影响父组件”,如果一个组件满足以下四种情况之一,则它便是 relayoutBoundary :
当前组件父组件的大小不依赖当前组件大小时;这种情况下父组件在布局时会调用子组件布局函数时并会给子组件传递一个 parentUsesSize 参数,该参数为 false 时表示父组件的布局算法不会依赖子组件的大小,大家从名字来方便记忆啊,名字的意思也就是父亲依赖子组件的布局尺寸
组件的大小只取决于父组件传递的约束,而不会依赖后代组件的大小。这样的话后代组件的大小变化就不会影响自身的大小了,这种情况组件的 sizedByParent 属性必须为 true(具体我们后面会讲),这个名字也很明显也就是布局尺寸只依赖父亲
父组件传递给自身的约束是一个严格约束(固定宽高,下面会讲);这种情况下即使自身的大小依赖后代元素,但也不会影响父组件,这种情况就像上面提到的例子一样
组件为根组件;Flutter 应用的根组件是 RenderView,它的默认大小是当前设备屏幕大小。
对应的代码实现是:
if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {
_relayoutBoundary = this;
} else {
_relayoutBoundary = (parent! as RenderObject)._relayoutBoundary;
}
好了我们弄懂了relayoutBoundary的概念,继续往下看上面的代码markNeedsLayout()中如果relayoutBoundary和当前的renderObject不相等,那说明它并不是边界布局,他的布局会依赖他的父亲,那么就会去请求parent layout,parent会继续重复markNeedsLayout的步骤知道找到了relayoutBoundary边界布局,代码如下:
void markParentNeedsLayout() {
_needsLayout = true;
finaRenderObject parent = this.parent! as RenderObject;
// 如果当前没有处于layout阶段,那么就 请求layout
if (!_doingThisLayoutWithCallback) {
parent.markNeedsLayout();
}
}
如果relayoutBoundary和当前的renderObject相等,意思就是他自己是边界布局,就会把当前的renderObject加入到一个需要layout的list中,并且调用PipelineOwner.requestVisualUpdate
void requestVisualUpdate() {
onNeedVisualUpdate?.call();
}
onNeedVisualUpdate是一个VoidCallback,它是在RendererBinding.initInstances中加入的
void initInstances() {
super.initInstances();
_instance = this;
_pipelineOwner = PipelineOwner(
onNeedVisualUpdate: ensureVisualUpdate,
onSemanticsOwnerCreated: _handleSemanticsOwnerCreated,
onSemanticsOwnerDisposed: _handleSemanticsOwnerDisposed,
);
//省略以下代码
}
void ensureVisualUpdate() {
switch (schedulerPhase) {
case SchedulerPhase.idle:
case SchedulerPhase.postFrameCallbacks:
scheduleFrame();
return;
case SchedulerPhase.transientCallbacks:
case SchedulerPhase.midFrameMicrotasks:
case SchedulerPhase.persistentCallbacks:
return;
}
}
void scheduleFrame() {
if (_hasScheduledFrame || !framesEnabled)
return;
assert(() {
if (debugPrintScheduleFrameStacks)
debugPrintStack(label: 'scheduleFrame() called. Current phase is $schedulerPhase.');
return true;
}());
ensureFrameCallbacksRegistered();
window.scheduleFrame();
_hasScheduledFrame = true;
}
最后会去调用底层window.scheduleFrame()来注册一个下一帧时回调,他的回调就是再ensureFrameCallbacksRegistered这里面注册的,这里我们上一篇build构建偏也提到了这里
SchedulerBinding.ensureFrameCallbacksRegistered
void ensureFrameCallbacksRegistered() {
window.onBeginFrame ??= _handleBeginFrame;
window.onDrawFrame ??= _handleDrawFrame;
}
onBeginFrame :主要是用来执行动画,onDrawFrame :这个主要处理的是persistentCallbacks这个状态的内容,我们的build构建,layout都是在这里面,上一篇也介绍了这flutter运行的五种状态忘记了的可以回看一下
enum SchedulerPhase {
/// 空闲状态,并没有 frame 在处理。这种状态代表页面未发生变化,并不需要重新渲染。
/// 如果页面发生变化,需要调用`scheduleFrame()`来请求 frame。
/// 注意,空闲状态只是指没有 frame 在处理,通常微任务、定时器回调或者用户事件回调都
/// 可能被执行,比如监听了tap事件,用户点击后我们 onTap 回调就是在idle阶段被执行的。
idle,
/// 执行”临时“回调任务,”临时“回调任务只能被执行一次,执行后会被移出”临时“任务队列。
/// 典型的代表就是动画回调会在该阶段执行。
transientCallbacks,
/// 在执行临时任务时可能会产生一些新的微任务,比如在执行第一个临时任务时创建了一个
/// Future,且这个 Future 在所有临时任务执行完毕前就已经 resolve 了,这中情况
/// Future 的回调将在[midFrameMicrotasks]阶段执行
midFrameMicrotasks,
/// 执行一些持久的任务(每一个frame都要执行的任务),比如渲染管线(构建、布局、绘制)
/// 就是在该任务队列中执行的.
persistentCallbacks,
/// 在当前 frame 在结束之前将会执行 postFrameCallbacks,通常进行一些清理工作和
/// 请求新的 frame。
postFrameCallbacks,
}
继续往下看:
void handleDrawFrame() {
try {
// PERSISTENT FRAME CALLBACKS
_schedulerPhase = SchedulerPhase.persistentCallbacks;
for (finaFrameCallback callback in _persistentCallbacks)
_invokeFrameCallback(callback, _currentFrameTimeStamp!);
// POST-FRAME CALLBACKS
_schedulerPhase = SchedulerPhase.postFrameCallbacks;
finaList localPostFrameCallbacks =
List.from(_postFrameCallbacks);
_postFrameCallbacks.clear();
for (finaFrameCallback callback in localPostFrameCallbacks)
_invokeFrameCallback(callback, _currentFrameTimeStamp!);
} finally {
}
}
void initInstances() {
addPersistentFrameCallback(_handlePersistentFrameCallback);
}
void _handlePersistentFrameCallback(Duration timeStamp) {
drawFrame();
}
void drawFrame() {
assert(renderView != null);
pipelineOwner.flushLayout(); //我们layout布局就是这个步骤
pipelineOwner.flushCompositingBits();
pipelineOwner.flushPaint();
if (sendFramesToEngine) {
renderView.compositeFrame(); // this sends the bits to the GPU
pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.
_firstFrameSent = true;
}
}
最后会到pipelineOwner.flushLayout();这个函数里面
void flushLayout() {
try {
while (_nodesNeedingLayout.isNotEmpty) {
finaList dirtyNodes = _nodesNeedingLayout;
_nodesNeedingLayout = [];
for (finaRenderObject node in dirtyNodes..sort((RenderObject a, RenderObject b) => a.depth - b.depth)) {
if (node._needsLayout && node.owner == this)
node._layoutWithoutResize();
}
}
} finally {
}
}
大家应该还记得_nodesNeedingLayout这个list吧,我们把需要布局的RederObject都加入到这个里面
RenderObject._layoutWithoutResize()
void _layoutWithoutResize() {
try {
performLayout();
} catch (e, stack) {
}
_needsLayout = false;
markNeedsPaint();
}
可以看到会先执行performLayout,然后又调用了markNeedsPaint(),这里我们先讨论布局先看performLayout,markNeedsPaint作为下一篇的彩蛋(这里涉及到绘制的过程),一般父renderObject在performLayout时是会调用child的layout的,我们先开看看layout的大致步骤:
RenderObject.layout
void layout(Constraints constraints, { bool parentUsesSize = false }) {
RenderObject? relayoutBoundary;
// 先确定当前组件的布局边界
if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {
relayoutBoundary = this;
} else {
relayoutBoundary = (parent! as RenderObject)._relayoutBoundary;
}
// _needsLayout 表示当前组件是否被标记为需要布局
// _constraints 是上次布局时父组件传递给当前组件的约束
// _relayoutBoundary 为上次布局时当前组件的布局边界
// 所以,当当前组件没有被标记为需要重新布局,且父组件传递的约束没有发生变化,
// 且布局边界也没有发生变化时则不需要重新布局,直接返回即可。
if (!_needsLayout && constraints == _constraints && relayoutBoundary == _relayoutBoundary) {
return;
}
// 如果需要布局,缓存约束和布局边界
_constraints = constraints;
_relayoutBoundary = relayoutBoundary;
// 后面解释
if (sizedByParent) {
performResize();
}
// 执行布局
performLayout();
// 布局结束后将 _needsLayout 置为 false
_needsLayout = false;
// 将当前组件标记为需要重绘(因为布局发生变化后,需要重新绘制)
markNeedsPaint();
}
简单来讲布局过程分以下几步:
确定当前组件的布局边界。
-
判断是否需要重新布局,如果没必要会直接返回,反之才需要重新布局。不需要布局时需要同时满足三个条件:
- 当前组件没有被标记为需要重新布局。
- 父组件传递的约束没有发生变化。
- 当前组件的布局边界也没有发生变化时。
调用 performLayout() 进行布局,因为 performLayout() 中又会调用子组件的 layout 方法,所以这时一个递归的过程,递归结束后整个组件树的布局也就完成了。
请求重绘。
我来再帮助大家解读一下这个步骤,首先你要清楚的是第一次运行的时候肯定是都需要走layout这个过程的,并且第一次layout的时候(或者以后组件有更新的时候)才会确定每个组件的布局边界(就是上面第一个步骤);另外要强调的是一般来说组件改变一般是从子组件变化开始自下而上的,但是performLayout确是自上而下的(拿上面的图来举例子)Text3的文本发生了变化,运行markNeedsLayout这个方法里面的内容,这个时候因为他不是边界布局所以运行markParentNeedsLayout这个方法并且_needsLayout置为true,再运行parent.markNeedsLayout()这个方法(parent就是Column2),Column2自己是布局边界所以会把自己加入到_nodesNeedingLayout这个集合里面,然后等下一帧的回调到来的时候,触发这个集合遍历会取出Column2来运行他的performLayout方法,这个里面会调用Text3的layout方法重新布局,但是注意的是Text4的_needsLayout这个属性不是true,所以到执行Text4layout的时候回直接return(因为他没有改变不需要布局)
我们最后看一下这个performLayout最后是怎么运行的,我们以上文提到的Text的RenderObject为例,他的RenderObject是RenderParagraph,我们来看看他的performLayout:
@override
void performLayout() {
final BoxConstraints constraints = this.constraints;
_placeholderDimensions = _layoutChildren(constraints);
_layoutTextWithConstraints(constraints);
_setParentData();
final Size textSize = _textPainter.size;
final bool textDidExceedMaxLines = _textPainter.didExceedMaxLines;
size = constraints.constrain(textSize);
final bool didOverflowHeight = size.height < textSize.height || textDidExceedMaxLines;
final bool didOverflowWidth = size.width < textSize.width;
final bool hasVisualOverflow = didOverflowWidth || didOverflowHeight;
if (hasVisualOverflow) {
switch (_overflow) {
case TextOverflow.visible:
_needsClipping = false;
_overflowShader = null;
break;
case TextOverflow.clip:
case TextOverflow.ellipsis:
_needsClipping = true;
_overflowShader = null;
break;
case TextOverflow.fade:
assert(textDirection != null);
_needsClipping = true;
final TextPainter fadeSizePainter = TextPainter(
text: TextSpan(style: _textPainter.text!.style, text: '\u2026'),
textDirection: textDirection,
textScaleFactor: textScaleFactor,
locale: locale,
)..layout(); //重点看这里
//省略下面代码
} else {
_needsClipping = false;
_overflowShader = null;
}
}
由于RenderParagraph并不是RenderBox,我们这个例子里面没有child了,他会调用TextPainter的layout()方法
void layout({ double minWidth = 0.0, double maxWidth = double.infinity }) {
assert(text != null, 'TextPainter.text must be set to a non-null value before using the TextPainter.');
assert(textDirection != null, 'TextPainter.textDirection must be set to a non-null value before using the TextPainter.');
if (!_needsLayout && minWidth == _lastMinWidth && maxWidth == _lastMaxWidth)
return;
_needsLayout = false;
if (_paragraph == null) {
final ui.ParagraphBuilder builder = ui.ParagraphBuilder(_createParagraphStyle());
_text!.build(builder, textScaleFactor: textScaleFactor, dimensions: _placeholderDimensions);
_inlinePlaceholderScales = builder.placeholderScales;
_paragraph = builder.build();
}
_lastMinWidth = minWidth;
_lastMaxWidth = maxWidth;
// A change in layout invalidates the cached caret metrics as well.
_previousCaretPosition = null;
_previousCaretPrototype = null;
_paragraph!.layout(ui.ParagraphConstraints(width: maxWidth));
if (minWidth != maxWidth) {
double newWidth;
switch (textWidthBasis) {
case TextWidthBasis.longestLine:
// The parent widget expects the paragraph to be exactly
// `TextPainter.width` wide, if that value satisfies the constraints
// it gave to the TextPainter. So when `textWidthBasis` is longestLine,
// the paragraph's width needs to be as close to the width of its
// longest line as possible.
newWidth = _applyFloatingPointHack(_paragraph!.longestLine);
break;
case TextWidthBasis.parent:
newWidth = maxIntrinsicWidth;
break;
}
newWidth = newWidth.clamp(minWidth, maxWidth);
if (newWidth != _applyFloatingPointHack(_paragraph!.width)) {
_paragraph!.layout(ui.ParagraphConstraints(width: newWidth));
}
}
_inlinePlaceholderBoxes = _paragraph!.getBoxesForPlaceholders();
}
这个里面又会调用_paragraph.layout()方法
Paragraph是Flutter中用于文字绘制的类,Flutter中所有的文字,最后都是通过它来绘制的,连输入框也都是通过它来实现的,Paragraph是一个没有构造函数的类,它只是提供一个宿主,用于最后的渲染
所以到最后_paragraph.layout()就是最后的布局方法了
/// The [ParagraphConstraints] control how wide the text is allowed to be.
void layout(ParagraphConstraints constraints) => _layout(constraints.width);
void _layout(double width) native 'Paragraph_layout';
这里会调用原生的方法来实现这个布局的过程,最后面来说一下我们提到的彩蛋,先回顾一下代码:
RenderObjectElement.performRebuild
void performRebuild() {
widget.updateRenderObject(this, renderObject);
_dirty = false;
}
上文我们build构建篇的时候说到会触发我们的layout,我们现在就来一窥究竟,我们以Text这个Widget为例,他的widget为RichText,他的updateRenderObject方法如下:
@override
void updateRenderObject(BuildContext context, RenderParagraph renderObject) {
assert(textDirection != null || debugCheckHasDirectionality(context));
renderObject
..text = text
..textAlign = textAlign
..textDirection = textDirection ?? Directionality.of(context)
..softWrap = softWrap
..overflow = overflow
..textScaleFactor = textScaleFactor
..maxLines = maxLines
..strutStyle = strutStyle
..textWidthBasis = textWidthBasis
..textHeightBehavior = textHeightBehavior
..locale = locale ?? Localizations.maybeLocaleOf(context);
}
这里全是一些set方法,我们以maxLines为例去看看他的set方法
set maxLines(int? value) {
assert(value == null || value > 0);
if (_textPainter.maxLines == value)
return;
_textPainter.maxLines = value;
_overflowShader = null;
markNeedsLayout();
}
看这里是不是会触发markNeedsLayout方法呢,所以build构建的时候会触发layout布局,layout布局又会触发paint绘制,另外上面还有一个小彩蛋提到的:就是只有RenderObject的对象及其子类才会具有Layout的过程,我们在代码里面也很清楚了只有renderObject对象的set方法改变才会触发markNeedsLayout方法,这里也非常的明显了,另外关于paint我们下一篇再做讲解了
好了,我们layout的流程就差不多告一段落了,如果有喜欢的小伙伴欢迎点赞,有疑问的可以给我留言,我都会回复大家的,我们下期再见···
本文部分内容参考:
Flutter实战·第二版- Preview
flutter_5_深入2深入layout、paint流程