Flutter UI框架

  • UI 框架解决的主要问题就是:如何基于基础的图形API(Canvas)来封装一套可以高效创建UI的框架。
  • Flutter提供了一套Dart API,然后在底层通过OpenGL这种跨平台的绘制库(内部会调用操作系统API)实现了一套代码跨多端。由于Dart API也是调用操作系统API,所以它的性能接近原生。
  • 从创建到渲染的大体流程是:根据Widget生成Element,然后创建相应的RenderObject并关联到Element.renderObject属性上,最后再通过RenderObject来完成布局排列和绘制
  • Element就是Widget在UI树具体位置的一个实例化对象,大多数Element只有唯一的renderObject,但还有一些Element会有多个子节点,如继承自RenderObjectElement的一些类。
    最终所有Element的RenderObject构成一棵树,我们称之为”Render Tree“即”渲染树“。
  • 总结一下,我们可以认为Flutter的UI系统包含三棵树:Widget树、Element树、渲染树。他们的依赖关系是:Element树根据Widget树生成,而渲染树又依赖于Element树。

Element的生命周期如下

  1. Framework 调用Widget.createElement 创建一个Element实例,记为element。
  2. Framework 调用 element.mount(parentElement,newSlot) ,mount方法中首先调用element所对应Widget的createRenderObject方法创建与element相关联的RenderObject对象,
    然后调用element.attachRenderObject方法将element.renderObject添加到渲染树中插槽指定的位置(这一步不是必须的,一般发生在Element树结构发生变化时才需要重新attach)。
    插入到渲染树后的element就处于“active”状态,处于“active”状态后就可以显示在屏幕上了(可以隐藏)。
  3. 当有父Widget的配置数据改变时,同时其State.build返回的Widget结构与之前不同,此时就需要重新构建对应的Element树。
    为了进行Element复用,在Element重新构建前会先尝试是否可以复用旧树上相同位置的element,element节点在更新前都会调用其对应Widget的canUpdate方法,如果返回true,则复用旧Element,旧的Element会使用新Widget配置数据更新,反之则会创建一个新的Element。Widget.canUpdate主要是判断newWidget与oldWidget的runtimeType和key是否同时相等,如果同时相等就返回true,否则就会返回false。根据这个原理,当我们需要强制更新一个Widget时,可以通过指定不同的Key来避免复用。
  4. 当有祖先Element决定要移除element 时(如Widget树结构发生了变化,导致element对应的Widget被移除),这时该祖先Element就会调用deactivateChild 方法来移除它,移除后element.renderObject也会被从渲染树中移除,然后Framework会调用element.deactivate 方法,这时element状态变为“inactive”状态。
  5. “inactive”态的element将不会再显示到屏幕。为了避免在一次动画执行过程中反复创建、移除某个特定element,“inactive”态的element在当前动画最后一帧结束前都会保留,如果在动画执行结束后它还未能重新变成“active”状态,Framework就会调用其unmount方法将其彻底移除,这时element的状态为defunct,它将永远不会再被插入到树中。
  6. 如果element要重新插入到Element树的其他位置,如element或element的祖先拥有一个GlobalKey(用于全局复用元素),那么Framework会先将element从现有位置移除,然后再调用其activate方法,并将其renderObject重新attach到渲染。

BuildContext

Widget build(BuildContext context) {}

这里的context是什么?可以查看下StatelessWidget的build方法中,以StatelessElement为例:

class StatelessElement extends ComponentElement {
  ...
  @override
  Widget build() => widget.build(this);
  ...
}

发现build传递的参数是this,很明显!这个BuildContext就是StatelessElement。
继续跟踪代码,发现它们间接继承自Element类,然后查看Element类定义

class Element extends DiagnosticableTree implements BuildContext {
    ...
}

因此,BuildContext就是widget对应的Element,所以我们可以通过context在StatelessWidget和StatefulWidget的build方法中直接访问Element对象。
在开发过程中,Theme.of(context) ;Navigator.push(context, route) ;等方法,实际也是调用了Element的响应方法。

## RenderObject

  • 每个Element都对应一个RenderObject,我们可以通过Element.renderObject 来获取。并且RenderObject的主要职责是Layout和绘制,所有的RenderObject会组成一棵渲染树Render Tree。

  • RenderObject就是渲染树中的一个对象,它主要的作用是实现事件响应以及渲染管线中除过 build 的部分(build 部分由 element 实现),即包括:布局、绘制、层合成以及上屏。

  • RenderObject拥有一个parent和一个parentData 属性,parent指向渲染树中自己的父节点,而parentData是一个预留变量,在父组件的布局过程,会确定其所有子组件布局信息(如位置信息,即相对于父组件的偏移),
    而这些布局信息需要在布局阶段保存起来,因为布局信息在后续的绘制阶段还需要被使用(用于确定组件的绘制位置),而parentData属性的主要作用就是保存布局信息,比如在 Stack 布局中,RenderStack就会将子元素的偏移数据存储在子元素的parentData中(具体可以查看Positioned实现)。

  • RenderObject类本身实现了一套基础的布局和绘制协议,但是并没有定义子节点模型。Flutter框架提供了一个RenderBox和一个 RenderSliver类,它们都是继承自RenderObject,布局坐标系统采用笛卡尔坐标系,屏幕的(top, left)是原点。而 Flutter 基于这两个类分别实现了基于 RenderBox 的盒模型布局和基于 Sliver 的按需加载模型。

渲染流程

Flutter UI 框架的 执行 drawFrame() 并不是每次屏幕刷新都会触发,这是因为,如果 UI 在一段时间不变,那么每次屏幕刷新都重新走一遍渲染流程是不必要的。
因此,Flutter 在第一帧渲染结束后会采取一种主动请求 drawFrame() 的方式来实现只有当UI可能会改变时才会重新走渲染流程。
当新的 drawFrame() 到来时,调用到 WidgetsBinding 的 drawFrame() 方法
实际上关键的代码就两行:先重新构建(build),然后再调用父类的 drawFrame 方法。可以看到主要做了5件事:

  • 重新构建widget树。
  • 更新布局。
  • 更新“层合成”信息。
  • 重绘。
  • 上屏:将绘制的产物显示在屏幕上

我们称上面的5步为 rendering pipeline,中文翻译为 “渲染流水线” 或 “渲染管线”
setState() 的执行更新的流程为例可对整个更新流程有一个大概的印象。


	void drawFrame() {
	  buildOwner!.buildScope(renderViewElement!); //重新构建widget树    
	  pipelineOwner.flushLayout(); // 更新布局   
	  pipelineOwner.flushCompositingBits(); //更新合成信息   
	  pipelineOwner.flushPaint(); // 更新绘制  
	  if (sendFramesToEngine) {  
	    renderView.compositeFrame(); // 上屏,会将绘制出的bit数据发送给GPU  
	    pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.   
	    _firstFrameSent = true;   
	  }   
	}  

布局(Layout)过程

Layout(布局)过程主要是确定每一个组件的布局信息(大小和位置),Flutter 的布局过程如下:

  • 父节点向子节点传递约束(constraints)信息,限制子节点的最大和最小宽高。
  • 子节点根据约束信息确定自己的大小(size)。
  • 父节点根据特定布局规则(不同布局组件会有不同的布局算法)确定每一个子节点在父节点布局空间中的位置,用偏移 offset 表示。
  • 递归整个过程,确定出每一个节点的大小和位置。
  • 可以看到,组件的大小是由自身决定的,而组件的位置是由父组件决定的。

布局更新

简单来讲布局过程分以下几步:

  • 确定当前组件的布局边界。
  • 判断是否需要重新布局,如果没必要会直接返回,反之才需要重新布局。不需要布局时需要同时满足三个条件:
  • 当前组件没有被标记为需要重新布局。(是否被标记 markNeedsLayout())
  • 父组件传递的约束没有发生变化。
  • 当前组件的布局边界也没有发生变化时。
  • 调用 performLayout() 进行布局,因为 performLayout() 中又会调用子组件的 layout 方法,所以这时一个递归的过程,递归结束后整个组件树的布局也就完成了。
  • 请求重绘。

绘制

Flutter中和绘制相关的对象有三个,分别是Canvas、Layer 和 Scene:

  • Canvas:封装了Flutter Skia各种绘制指令,比如画线、画圆、画矩形等指令。
  • Layer:分为容器类和绘制类两种;暂时可以理解为是绘制产物的载体,比如调用 Canvas 的绘制 API 后,相应的绘制产物被保存在 PictureLayer.picture 对象中。
  • Scene:屏幕上将要要显示的元素。在上屏前,我们需要将Layer中保存的绘制产物关联到 Scene 上。

Flutter 绘制流程:

  • 构建一个 Canvas,用于绘制;同时还需要创建一个绘制指令记录器,因为绘制指令最终是要传递给 Skia 的,
    而 Canvas 可能会连续发起多条绘制指令,指令记录器用于收集 Canvas 在一段时间内所有的绘制指令,
    因此Canvas 构造函数第一个参数必须传递一个 PictureRecorder 实例。
  • Canvas 绘制完成后,通过 PictureRecorder 获取绘制产物,然后将其保存在 Layer 中。
  • 构建 Scene 对象,将 layer 的绘制产物和 Scene 关联起来。
  • 上屏;调用window.render API 将Scene上的绘制产物发送给GPU。

Flutter绘制组件树-第一次绘制

Flutter第一次绘制时,会从上到下开始递归的绘制子节点,每当遇到一个边界节点,则判断如果该边界节点的 layer 属性为空(类型为ContainerLayer),
就会创建一个新的 OffsetLayer 并赋值给它;如果不为空,则直接使用它。然后会将边界节点的 layer 传递给子节点。

如果子节点是非边界节点,且需要绘制,则会在第一次绘制时:

  • 创建一个Canvas 对象和一个 PictureLayer,然后将它们绑定,后续调用Canvas 绘制都会落到和其绑定的PictureLayer 上。
  • 接着将这个 PictureLayer 加入到边界节点的 layer 中。
  • 如果不是第一次绘制,则复用已有的 PictureLayer 和 Canvas 对象 。
  • 如果子节点是边界节点,则对子节点递归上述过程。当子树的递归完成后,就要将子节点的layer 添加到父级 Layer中。
  • 整个流程执行完后就生成了一棵Layer树。

Flutter绘制组件树-重绘:

当一个节点需要重绘时,我们得找到离它最近的第一个父级绘制边界节点,然后让它重绘即可,具体的步骤如下:

  • 会从当前节点一直往父级查找,直到找到一个绘制边界节点时终止查找,然后会将该绘制边界节点添加到其PiplineOwner的 _nodesNeedingPaint列表中(保存需要重绘的绘制边界节点)。
  • 在查找的过程中,会将自己到绘制边界节点路径上所有节点的_needsPaint属性置为true,表示需要重新绘制。
  • 请求新的 frame ,执行重绘流程。
    markNeedsRepaint 删减后的核心源码如下:
void markNeedsPaint() {
  if (_needsPaint) return;
  _needsPaint = true;
  if (isRepaintBoundary) { // 如果是当前节点是边界节点
      owner!._nodesNeedingPaint.add(this); 
      owner!.requestVisualUpdate(); // 请求新的frame,该方法最终会调用scheduleFrame()
  } else if (parent is RenderObject) { 
    final RenderObject parent = this.parent! as RenderObject;
    parent.markNeedsPaint(); // 递归调用父节点的markNeedsPaint
  } else {
    // 如果是根节点,直接请求新的 frame 即可
    if (owner != null)
      owner!.requestVisualUpdate();
  }
}
  • 以上整理结合源码,并参考《Flutter实战》一书。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部