理解Flutter UI系统

Flutter自发布以来,受到越来越多的开发者的关注和青睐,作为一款优质的跨平台技术,使用Flutter开发的项目也越来越多。对于应用开发者来说,理解UI的渲染原理,尽可能的在开发过程中减少ui渲染带来的性能消耗,这对于一款优秀的应用是至关重要的。网易也尝试使用Flutter跨平台开发且上线发布了一款全新应用,名为咕噜短视频,因此本文主要讲解在实践开发过程中对Flutter UI系统的理解。

本文讲解目录:

  • 三棵树(what?)
  • 渲染时机(When?)
  • 渲染过程(How?)
    • 渲染机制
    • Framework的渲染过程
    • 树的更新规则
    • 举例

三棵树:

与原生不同,对于原生UI,通常一个activity,对应一个view树,而Flutter应用中,存在三棵树,分别为Widget树, Element树,RenderObject树。
要理解Flutter的渲染原理,首先要了解这三棵树:

  • 在Flutter中几乎所有的对象都是一个Widget,Widget的功能是“描述一个UI元素的配置数据”,就是说,Widget其实并不是表示最终绘制在设备屏幕上的显示元素,而它只是描述显示元素的一个配置数据;

  • Flutter中真正代表屏幕上显示元素的类是Element,Element同时持有Widget和RenderObject;

  • UI组件最终的Layout、Paint都是通过RenderObject来完成的;

这三者的关系:根据Widget生成Element,然后创建相应的RenderObject并关联到Element.renderObject属性上,最后再通过RenderObject来完成布局排列和绘制。


image

  • Element是通过Widget生成的,UI树的每一个Element节点都会对应一个Widget对象;
  • 而一个Widget对象可以对应多个Element对象。这很好理解,根据同一份配置(Widget),可以创建多个实例(Element),Element就是Widget在UI树具体位置的一个实例化对象;
  • RenderObject通过Element创建,大多数Element只有唯一的renderObject,但还有一些Element会有多个子节点,如继承自RenderObjectElement的一些类,比如MultiChildRenderObjectElement。最终所有Element的RenderObject构成一棵树,我们称之为”Render Tree“即”渲染树“。

其实对于开发者来说,大多数情况下只需要关注Widget树就行,Flutter Framework已经将对Widget树的操作映射到了Element树上,这样可以极大的降低复杂度,提高开发效率。

渲染时机

在Flutter应用中,触发渲染(树的更新)主要有以下几种时机:

  • 在Futter启动时runApp(Widget app)全局刷新;
  • 开发者主动调用setState()方法: 将该子树做StatefullWidget的一个子widget,并创建对应的State类实例,通过调用state.setState() 触发该子树的刷新,
  • InheritedWidget
  • 热重载


    image

  • 以上触发通知Flutter framework状态发生改变;
  • Framework通知Engine渲染;
  • Engine等下一个Vsync(垂直同步信号)到来后,触发Framework开始执行渲染操作(UI线程),生成LayerTree传递给Engine;
  • Engine的GPU线程进行合成和光栅化等操作后展示到屏幕上;


    image

渲染过程

渲染机制

之所以说Flutter能够达到可以媲美甚至超越原生的体验,主要在于其拥有高性能的图形渲染能力,首先对比下Flutter和原生Android及其他跨平台框架(如RN)的渲染机制,如下图:


image

  • Android原生App在绘图的时候,首先调用Android Framework的java代码,然后调用Skia(c/++)绘图引擎, 最终生成CPU/GPU指令在设备完成渲染;
  • Flutter在绘图的时候,首页调用Flutter Framework的Dart代码,然后直接调用Skia(c/++)绘图引擎, 最终生成CPU/GPU指令在设备渲染;
  • 其他跨平台框架(如RN)首先调用其框架的javaScript代码,然后调用Android Framework的java代码,比原生的多了一层,显然RN肯定不如原生。

由此可见,Flutter和Android原生,只要Flutter Framework的Dart代码的效率可以媲美Android Framework的java代码,就可以理解为Flutter app达到媲美原生的性能体验。

另外Flutter使用的Skia渲染引擎,是Flutter sdk的一部分,会随着Flutter sdk升级而升级,而原生的skia渲染引擎则需要跟随Android操作系统的升级而得以升级,因此对于skia引擎的性能提升,会更及时的影响到Flutter App上。

Flutter Framework的渲染过程

下面看下Flutter Framework的渲染过程:


image

  • 对于Android原生绘制过程,view树由上到下遍历每一个view,view进行Measure(测量)—Layout(布局)—Draw(绘制);
  • iOS的原生绘制过程,UIView树由上到下遍历每一个UIView,UIView进行Constraint—Layout—Display;
  • 与之对应,Flutter中的UI节点进行Build(构建)—Layout(布局)—Paint(绘制)
    Layout和Paint,都是RenderObject来完成,这样看来RenderObject更像是原生的View,它们有着长寿命和状态的UI单元,而这个Build不是RenderObject的责任,那具体是如何构建的呢?当更新ui的时候,这些树的更新规则又是如何呢?

首先Flutter是通过Element这个纽带将Widget和RenderObject关联起来,Element对理解整个Flutter UI框架是至关重要的,所以下面我们先了解下Element。


image

Element分为两个大类:ComponentElement和RenderObjectElement

  • 对于开发者常用到的Widget中的StatefulWidget和StatelessWidget,对应的Element分别为StatefulElement和StatelessElement,他们拥有共同父类为ComponentElement,他们只是起到组合作用,并没有对应的的RenderObject。
  • 而RenderObjectElement拥有比如SingleChildRenderObjectElement,MultiChildRenderObjectElement等多种类型的子类,他们分别拥有一个或者多个RenderObject。

树的更新规则:

下面拿setState()触发更新为例,理解具体更新过程和规则

setState()方法的执行为_element.markNeedsBuild(),即设置对应的element为dirty,添加到dirtyElements list中,当WidgetsBinding.drawFrame调用buildScope,这些标脏的element将执行rebuild()树重建,即执行ComponentElement.performRebuild(),也就是对应的StatefullWidget执行build(),重建新的Widget
element重建过程中会对上一帧的Element树从上到下做遍历,对于每一个节点,执行Element.updateChild(Element child, Widget newWidget, dynamic newSlot);

注意在更新过程中,Widget树是不可改变的,所以如果要发生改变,就得扔掉之前的Widget树,重新构建新的Widget树;Widget只是一个配置数据结构,创建是非常轻量的,另外Flutter团队对widget的创建和销毁做了优化,性能影响可以忽略,虽然widget树是重建的,但对于Element树和RenderObject的树不一定是要跟着重建的,renderObject涉及到layout、paint等复杂操作,是一个真正渲染的对象,对整个Render Tree重新创建开销就比较大了。

下面根据代码理解Element树和RenderObject的树如何复用和更新的:
其中在Framework里,刚也提到的更新的核心代码为Element.updateChild(Element child, Widget newWidget, dynamic newSlot)这个方法,返回类型为Element, 第一个参数child为上一帧该节点对应的Element,第二个参数为新建的newWidget,第三个参数为新的插槽。
Element.updateChild(Element child, Widget newWidget, dynamic newSlot)具体更新流程图如下:

image

根据上面流程图,可以看出更新具体过程:

  • 如果newWidget== null, child !=null, 则deactivateChild(child),删除子树,返回null,流程结束, 如果child ==null ,则直接返回null, 流程结束(图中标志5号路径)
  • 如果newWidget!= null,child == null, 则inflateWidget(newWidget, newSlot), 新建Element,mount新子树,返回新Element, 流程结束(图中标志1号路径)
  • 如果newWidget!= null,child != null, 则继续如下判断:
  • child.widget == newWidget,则无需重建,返回child,流程结束(图中标志4号路径)
  • 如果child.widget != newWidget ,则需要执行Widget.canUpdate(child.widget, newWidget)检测是否需要更新
  • Widget.canUpdate(child.widget, newWidget) 为ture, 则执行child.update(newWidget),返回child, 流程结束((图中标志3号路径)
  • Widget.canUpdate(child.widget, newWidget)为false,则deactivateChild(child),删除子树,inflateWidget(newWidget, newSlot), 新建Element,mount新子树,返回新Element,流程结束(图中标志2号路径)
    总结如下图:

    image

在以上更新过程中,有几个关键方法:
Widget.canUpdate(child.widget, newWidget)
该方法为检测是否需要更新
是根据widget的runtimeType 和 key 是否相同,只有两者都相同,才会执行child.update(newWidget)更新,否则deactivateChild(child),删除子树,mount新子树。根据这个原理,当我们需要强制更新一个Widget时,可以通过指定不同的Key来避免复用。

child.update(newWidget)
节点更新,如果child有子节点,则如上步骤递归执行更新操作,
具体执行过程如下:

image

如果是child为ComponentElement,则执行ComponentElement.update(),即执行ComponentElement.performRebuild(),对应于的StatefullWidget或者StatelessWidget 执行build(), 生成新的widget, 然后执行核心代码updateChild(Element child, Widget newWidget, dynamic newSlot),同上面的流程进行子树更新。
如果child是RenderOjectElement,则执行RenderOjectElement.update(),即调用该element所对应Widget的updateRenderObject(this,renderObject)方法进行更新操作。

inflateWidget(newWidget, newSlot)
返回为新的Element,执行过程如下:

image

先执行newWiget.createElement(),生成新的Element为newChild,然后newChild.mount(this,newSlot)
对于mount操作,ComponentElement执行_firstBuild,其实_firstBuild调用的是rebuild,即ComponentElement的preformRebuild(),对应与StatefullWidget或者StatelessWidget的build,构建Widget树,然后同样执行核心代码updateChild(),最终返回新Element。
而对于RenderObjectElement的mount操作,首先调用element所对应Widget的createRenderObject方法创建与element相关联的RenderObject对象,然后调用element.attachRenderObject方法将element.renderObject添加到渲染树中插槽指定的位置。

deactivateChild(child)
在这里对Element的生命周期要有所理解,在上述mount过程中,插入到渲染树的element就处于“active”状态,处于“active”状态后就可以显示在屏幕上了(可以隐藏),
在上述五个路径中有两个路径是要移除element的,当有祖先Element决定要移除element 时,这时该祖先Element就会调用deactivateChild 方法来移除它,移除后element.renderObject也会被从渲染树中移除,然后Framework会调用element.deactivate 方法,这时element状态变为“inactive”状态,“inactive”态的element将不会再显示到屏幕。

注意:为了避免在一次动画执行过程中反复创建、移除某个特定element,“inactive”态的element在当前动画最后一帧结束前都会保留,如果在动画执行结束后它还未能重新变成“active”状态,Framework就会调用其unmount方法将其彻底移除,这时element的状态为defunct,它将永远不会再被插入到树中。

举例说明

最后举个简单的例子说明渲染过程中树的更新规则,使读者更容易理解。
有如下布局,一个容器(Container widget)里有一个横向布局(Row widget),该横向布局有两个子widget,图片Image 和文本Text,代码如下:

1
2
3
4
5
6
7
8
     Container(
      child: Row(
        children: <Widget>[
          Image.network('http://cms-bucket.ws.126.net/2018/08/13/078ea9f65d954410b62a52ac773875a1.jpeg'),
          Text("A"),
        ],
      ),
    )

布局很简单,上述布局的三棵树的对应关系:


image

上述三棵树分别在每一个ui节点上有标号,方便描述
W1, W3,W4, W5就是上述代码中的Widget ,Container Widget其实是依靠DecoratedBox Widget实现的,因此W1下有W2,同理W6和W7。
上述Widget树和Element树是一一对应的, Container和Text是一个StatelessWidget,Image是StatefullWidget
W2, 3,6,7对应的Element为RenderObjectElement, 对应的RenderObject分别为R2,3,6,7

当setState的时候Text 由A 变成B中的过程中,Widget树重建,上一帧的Element树从E1开始深度遍历每一个子节点,对于每一个节点,执行Element.updateChild(Element child, Widget newWidget, dynamic newSlot);按照上述讲解的更新规则这样遍历到最后,只有E7对应的RenderObject(RenderParagraph)需要更新,调用该element所对应Widget的updateRenderObject(this,renderObject)方法进行更新操作,对其重新进行布局和绘制即可。
当然在实际开发中,如果Text 由A 变成B中是不会在顶层的Widget中执行setState更新的,因变化的只有Text,其他Widget不需要重建,考虑性能问题,会抽离出变化的组件为一个Widget, 后续发文会讲性能优化等相关问题,欢迎继续关注!