深入理解 Flutter 中的 Widget, Element, RenderObject

这篇文章基于 Flutter stable v1.7 总结下 Flutter 当前的 UI 系统以及相关的概念, 在最后会通过自己组合一个 Gradient Button 按钮的方式来熟悉 Flutter 的一些 UI 实现。

Flutter 框架整体分层:

flutter

下面会主要关注 Widgets 和 Rendering, 也会涉及到一点 Painting 部分的内容。

Flutter 三层树

React 中,Component 对象并不是真正负责渲染,需要框架生成 element 后,进行初始渲染或者 diff 后 patch 变更。Flutter 也借鉴了 React 的设计,Widget 并不是真正的渲染对象,真正的渲染是经历了从 Widget => Element => RenderObject 的过程,与 React 拼接成 dom 交给浏览器渲染不同,Flutter 中 RenderObject 负责来在绘制引擎的上层进行绘制。

先来看下类分布及其职责:

objects

-- Widget 存储配置信息,另外其由于是 immutable 的,所以会不断重新创建刷新。
-- Element 是分离 Widget 和真正渲染对象的中间层,很多控制渲染的行为在这层去处理。
-- RenderObject 来真正的执行 Diff, Hit Test, 布局以及绘制。

三层对象构成的树之间的关联则如下图表示:

tree

如图可知,Widget 会创建 Element,然后 Element 创建相应的 RenderObject,Element 就是 Widget 在 UI 树具体位置的一个对象,一个 Widget 可能有多个 Element,大多的 Element 只有唯一的 renderObject,但也有一些 Element 会有多个子节点,比如 MultiChildRenderObjectElement。最终所有 RenderObject 构成 render tree 。

Stateful & Stateless Widget

关于 StatefulWidget 以及 StatelessWidget 的使用,已经有很多的文章进行了描述,这些 Widget 可能不是传统原生开发中的 UI 控件,也可能是布局,语义化或者主题等等,通过这些组件组合堆叠,来完成应用界面的绘制。

在一开始接触 Flutter 时会有点抵触这个 State 职责定位,因为涉及到构建树的 build 方法也需要在 State 中重载。不过整体看下来,这么设计也是基于 Flutter 的一部分机制导致的,首先 Widget 本身也分为 UI 和功能型,另外 Widget 树会随着 State 的变化而变化,每次 build 都会重新生成树,Widget 会频繁的销毁重建而 State 对象则不会,所以这么看来由 State 对象来返回树会更合适一些。

State 的生命周期

state-lifecycle

-- initState 方法会在 State 初始化时调用,由于 State 对象会被 Framework 长期持有,所以该方法在其生命周期中只调用一次,我们经常会在这做一些一次性的操作,比如状态初始化,订阅事件等。
-- didChangeDependencies 这个方法会在初始化后调用一次,直到 State 对象的依赖发生变化时才会被调用,比如上层的树中包含 InheritedWidget,如果其发生了变化,那么此时 InhertiedWidget 的子 widget 的 didChangeDependencies() 都会被调用。典型的场景是一些全局的配置比如 Locale, Theme, 或者 Redux 的 StoreProvider 组件改变会导致子树收到该消息并 re-build 组件树。另外如果有些行为不希望在每次 build 都触发,也可以考虑到将其放到 didChangeDenpendencies 中来。
-- didUpdateWidget 当 widget 的配置变化时,会调用该方法并触发 build
-- build 返回改组件构建的树结构
-- deactivate 当前组件暂时被从视图树中移除时会调用该方法,比如页面切换或者应用挂起都会触发这个方法。
-- dispose 永久移除时作为析构函数,可以在这里做一些资源的释放操作。

上面有提到 InheritedWidget,这个类的存在主要来解决需要逐层传递 State 的问题,当我们有 State 需要共享时,就可以将其放在一个继承 InheritedWidget 的类中,然后使用的组件直接取用就可以。常见与其相关的场景比如 Theme.of(context), Locale.of(context), ButtonTheme.of(context) 等等。

Element

Element 的生命周期

1> Widget.createElement 创建一个 Element 实例。
2> element.mount() 会让其 widget createRenderObject 并将对象 attach 到渲染树中插槽指定的位置,插入后该 element 标记为 'active' 状态。
3> 当 widget 的配置数据改变时,为了对 element 进行复用,Framework 在决定重新创建 Element 前会先尝试复用相同位置旧的 element, 调用对应的 widget 的 canUpdate() 方法来确定是否更新,canUpdate() 方法主要判断新旧 widget 的 runtimeType 以及 key, 所以我们可以通过指定不同的 Key 来强制刷新。
4> 当有祖先元素决定要移除 element 时,会调用 deactivateChild 方法来移除孩子,移除后 element.renderObject 也会被从渲染树中移除,然后 Framework 会调用 element.deactivate 方法,这时 element 标记为 'inactive' 状态。
5> 'inactive' 态的 element 将不会再显示到屏幕。为了避免在一次动画执行过程中反复创建、移除某个特定 element, 'inactive' 态的 element 在当前动画最后一帧结束前都会保留,如果动画结束后不能重新 'active', 则会调用 unmount 方法将其彻底移除,这是 element 状态标为 defunct 。
6> 如果 element 要重新插入到 Element 树其它位置,如 element 或 element 的祖先拥有一个 GlobalKey, 那么 Framework 会先将 element 从现有位置移除,然后再调用 activate 方法,并将其 renderObject 重新 attach 到渲染树。

BuildContext

我们在 build widgets 时都会重载 build 方法,build 方法有一个参数就是 BuildContext 上下文对象,我们可以拿到 context 去其祖先寻找指定类型的对象,比如 Theme, Navigator, Localizations 等等。

build-context

文档表示的很清楚,BuildContext 对象实际上就是 Element 对象, 它设计成抽象接口类主要为了屏蔽使用者对 Element 进行操作。而我们常用的 of 操作则是调用了 Element 的 inheritFromWidgetOfExactType() 方法。

Theme.of(context) 为例,我们可以看到它的实现:

这里会调用 context 也就是 element 的 inheritFromWidgetOfExactType 方法拿到指定类型的对象,返回一个经过本地化的主题数据。

inheritFromWidgetOfExactType

inheritFromWidgetOfExactType 方法也很简单,直接从缓存的哈希表里找到拥有指定对象类型的祖先节点,然后返回给调用方,缓存中没有则添加到缓存中。另外值得注意的是,这里的哈希表就是之前在 Widget state 里提到的 dependencies 。

BuildOwner

上文看到 BuildContext 中有一个 getter 方法来获取 buildOwner, 那么 BuildOwner 具体会做些什么呢?

它作为 widgets 的管理者,会追踪哪些 widget 需要重建,并且处理一些组件树上的其他任务比如管理 inactive 的 element 列表,或者触发 reassemble 命令当 hot reload 的时候。最主要的 build owner 是由 WidgetsBinding 持有的,它会被操作系统驱动去执行 build/layout/paint 的 pipeline。

另外 build owners 也被用来管理 off-screen 的组件树,谷歌官方介绍组件树的视频有提到,每当 widget 改变需要重新渲染时,framework 会在绘制的 idle time 去计算要新渲染的树,完成后直接对当前的进行替换并渲染。利用 idle time 的方式也很类似 React 的 Fiber 设计 (利用浏览器 requestIdleCallback api)。

布局及绘制

Flutter 界面渲染过程分为三个阶段:布局,绘制和合成,布局和绘制会在 Flutter 框架中完成,而合成则交给引擎负责:

主要涉及的类有:

abstract class RendererBinding extends BindingBase with ServicesBinding, SchedulerBinding, HitTestable { ... }
abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin implements HitTestTarget {
abstract class RenderBox extends RenderObject { ... }
class RenderParagraph extends RenderBox { ... }
class RenderImage extends RenderBox { ... }
class RenderFlex extends RenderBox with ContainerRenderObjectMixin<RenderBox, FlexParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, FlexParentData>,
DebugOverflowIndicatorMixin { ... }

前文有提到每个 Element 都会对应一个 RenderObject,它的职责主要则是布局和绘制,所有的 RenderObject 会组成一棵渲染树 RenderTree 。

RenderObject 拥有一个 parent 和一个 parentData 插槽,parentData 这个预留变量正是由 parent 来赋值,parent 通常会通过子 RenderObject 来存储一些和子元素相关的数据比如偏移量。当然,其不仅仅可以存储偏移信息,通常所有和子节点特定的数据都可以存储到子节点的 parentData 中,如 ContainerBox 中该属性就保存了指向兄弟节点的 previousSiblingnextSibling,Element 的 visitChildren() 方法也是通过它们来实现对子节点的同层级(广度)遍历。

RenderObject 类本身实现了一套基础的 layout 和绘制协议,但是并没有定义子节点模型,坐标系统以及具体的布局协议。为此,Flutter 提供了一个 RenderBox 类,继承自 RenderObject,坐标系采用笛卡尔坐标系。

布局过程

渲染树种每个节点都会接受父节点的 Contraints 参数,决定自己大小,然后父节点就可以按照自己的逻辑决定各个子节点的位置,完成布局过程。

具体来看,RenderBox 中有一个 size 属性用来保存宽高,RenderBox 的 layout 是通过在组件树上从上往下传递 BoxConstraints 对象实现的,它可以限制子节点的最大和最小宽高,布局阶段,父节点会调用子节点的 layout() 方法,大致实现如下:

void layout(Constraints constraints, { bool parentUsesSize = false }) {
   ...
   RenderObject relayoutBoundary; 
    if (!parentUsesSize || sizedByParent || constraints.isTight 
        || parent is! RenderObject) {
      relayoutBoundary = this;
    } else {
      final RenderObject parent = this.parent;
      relayoutBoundary = parent._relayoutBoundary;
    }
    ...
    if (sizedByParent) {
        performResize();
    }
    performLayout();
    ...
}

布局前先要确定 relayoutBoundary,该参数标识当前节点是否是布局边界。即当边界内的节点发生重新布局时,不会影响边界外的节点。

在 Element 层,如果其被标记为 dirty 时(通过 markNeedsBuild())则会重新 build,这时 RenderObject 便会重新布局。在 RenderObject 中则有一个 markNeedsLayout() 方法,它会将 RenderObject 的布局状态标记为 dirty,这样在下一帧便会重新 layout,我们来看下该方法:

void markNeedsLayout() {
  ...
  assert(_relayoutBoundary != null);
  if (_relayoutBoundary != this) {
    markParentNeedsLayout();
  } else {
    _needsLayout = true;
    if (owner != null) {
      ...
      owner._nodesNeedingLayout.add(this);
      owner.requestVisualUpdate();
    }
  }
}

确定 relayoutBoundary 是不是自己,不是则继续向上寻找,是则告知 buildOwner 当前节点需要布局,并调用了更新方法。

另外在 layout() 方法中我们看到其是通过 performLayout() 来去真正的执行布局,则总结起来调用顺序为: layout() > performResize()/perforLayout() > child.layout() > ... 如此递归完成整个 UI 树的布局。如果需要子类化 RenderBox 类来定制布局,则应该通过重写 performResizeperformLayout 来实现,而不是 layout

另外有一个问题,除非显式的指定 size,很多控件在 build 时是不清楚具体尺寸的,但很多时候我们需要提前清楚 size 来做一些操作或者布局,闲鱼团队的 深入了解Flutter界面开发 中有提到可以在 layout 阶段后发送一个 notification 通知上层,另外也可以并推荐的方式是通过胶水层 WidgetsBinding 注册一个 PostFrameCallback (WidgetsBinding.instance.addPostFrameCallback),然后回调里通过

_listViewKey.currentContext.findRenderObject().paintBounds.size.width;

的方式来拿到尺寸。

绘制过程

RenderObject 可以通过 paint() 方法来完成具体绘制逻辑,流程和布局类似。这里以 RenderFlex 的 paint 方法为例说明:

@override
void paint(PaintingContext context, Offset offset) {

  // 如果子元素未超出当前边界,则绘制子元素  
  if (_overflow <= 0.0) {
    defaultPaint(context, offset);
    return;
  }

  // 如果size为空,则无需绘制
  if (size.isEmpty)
    return;

  // 剪裁掉溢出边界的部分
  context.pushClipRect(needsCompositing, offset, Offset.zero & size, defaultPaint);

  // 溢出在 debug 时会有提示
  assert(/*...*/);
}

defaultPaint:

void defaultPaint(PaintingContext context, Offset offset) {
  ChildType child = firstChild;
  while (child != null) {
    final ParentDataType childParentData = child.parentData;
    //绘制子节点, 
    context.paintChild(child, childParentData.offset + offset);
    child = childParentData.nextSibling;
  }
}

由于 Flex 属于一个布局类,自身没有需要绘制的部分,则直接遍历子节点并调用 paintChild 方法触发子节点绘制。

渲染流程中也有个与 relayoutBoundary 对应的属性 repaintBoundary,用于确定重绘边界,提高绘制效率,避免绘制的干扰以及不必要的重绘。

RenderObject 中有一个 isRepaintBoundary 属性,决定重绘时是否独立于其父元素,若为 true 则单独建立图层绘制。可以看下 paintChild() 方法:

void paintChild(RenderObject child, Offset offset) {
  ...
  if (child.isRepaintBoundary) {
    stopRecordingIfNeeded();
    _compositeChild(child, offset);
  } else {
    child._paintWithContext(this, offset);
  }
  ...
}

如果子节点是 repaintBoundary 则会调用 _compositeChild 方法并将偏移量传递过去,不是则直接从上下文进行绘制。

另外看下触发重绘的 markNeedsPaint() 方法:

void markNeedsPaint() {
 ...
  //如果RenderObject.isRepaintBoundary 为true,则该RenderObject拥有layer,直接绘制  
  if (isRepaintBoundary) {
    ...
    if (owner != null) {
      //找到最近的layer,绘制  
      owner._nodesNeedingPaint.add(this);
      owner.requestVisualUpdate();
    }
  } else if (parent is RenderObject) {
    // 没有自己的layer, 会和一个祖先节点共用一个layer  
    assert(_layer == null);
    final RenderObject parent = this.parent;
    // 向父级递归查找  
    parent.markNeedsPaint();
    assert(parent == this.parent);
  } else {
    // 如果直到根节点也没找到一个Layer,那么便需要绘制自身,因为没有其它节点可以绘制根节点。  
    if (owner != null)
      owner.requestVisualUpdate();
  }
}

与 layout 类似,会判断边界并交给 pipeline owner 去做相关工作。

在 iOS 中,不同职责的 layer 组合在一起组成了 view,Flutter 中刚才可以看到如果有一个 repaintBoundary 区域形成时,框架会创建一个 Layer,不同 Lyaer 也是可以独立工作的,比如 OffsetLayer 在 RenderObject 中就是用来做定位绘制的。

layer

其次在 RenderObject 中有一个属性为 needsCompositing,它会影响生成多少层的 Layer, 而这些 Layer 又会组成一棵 Layer Tree ... 也就是实际去给引擎绘制的树。

layer tree

实现一个 Button

最后,我们会以框架实现 Button 的方式来实现一个带渐变背景的按钮:

语义化在最顶层,使用 Semantics 来包装整个 Widget;
管理焦点(1.7 新增)使用 Focus;
布局和焦点用到两个 'Box',分别为 ConstrainedBoxDecoratedBox;
样式及点击水波效果通过 MaterialInkwell;
Default 的样式则通过全局的 Theme, ButtonThemeIconTheme 来控制;

综上,build 方法大概如下:

-- EOF --
以上为本篇博客的全部内容,欢迎提出建议和指正,
个人联系方式详见关于

参考资源:

Comments
Write a Comment