Flutter 渲染原理分析及优化

1. Flutter 三棵树

Flutter 的自渲染离不开 Flutter 三棵树:

  • Widget:描述 UI 渲染的配置信息
  • Element:存放上下文,持有 Widget 和 RenderObject
  • RenderObject:实际渲染树中的对象

我们在debug断点某个Widget的时候可以得到如下的配置信息:

上图的 StatelessElement 就是 FirstRoute 这个Widget 对应的 Element,我们知道 Element 是持有 Widget 和 RenderObject,此处的 _widget 就是此 Element 持有的 Widget。有同学可能会问,怎么没有看到此 Element 持有的 RenderObject?因为 Widget 和 Element 是一一对应的,但是与 RenderObject 不是一一对应的。也即是说不是所有的 Element 都有对应的 RenderObject,只有 RenderObjectWidget 相关的 Widget 对应的 Element 才有与之对应的 RenderObject。上图中的Widget 是 继承自 StatelessWidget 的 FirstRoute,StatelessWidget 直接继承自 Widget,而不是 RenderObjectWidget ,所以不会转化为 RenderObject。将 FirstRoute 的 _child 展开如下图:

FirstRoute 的 child 是 Center,Center 间接继承 SingleChildRenderObjectWidget,SingleChildRenderObjectWidget 继承 RenderObjectWidget,所以 Center 最终会转化成 RenderObjectWidget 渲染到屏幕上。

再将 FirstRoute 的 _parent 展开如下图:

按照我们写的dart代码理解,FirstRoute 的 parent 应该是 MaterialApp,但观察上图我们发现其实是 Semantics,这其实也好理解,MaterialApp 里面封装了很多层 Widget 去做语义分析、手势处理、主题、动画等操作,经过一系列的准备工作之后才会到 MaterialApp。处于好奇,我想看看到底封装了多少层才到 MaterialApp,于是我就一层一层点开 _parent(说实话,点到一半我想放弃了🤣,但我坚信我一定能把 MaterialApp 揪出来🤨),如下图:

终于,皇天不负有心人!点开最后一个 StatefulElement 终于找到了 MaterialApp,如下图:

上面有讲到Widget 和 Element 树是一一对应的,但是与 RenderObject 不是一一对应的。我们具体来看一下他们之间的对应关系:

Widget Element RenderObject
StatelessWidget StatelessElement \
StatefulWidget StatefulElement \
ProxyWidget ProxyElement \
InheritedWidget InheritedElement \
SingleChildRenderObjectWidget SingleChildRenderObjectElement RenderObject
MultiChildRenderObjectWidget MultiChildRenderObjectElement RenderObject
RenderObjectWidget RenderObjectElement RenderObject

2. 三棵树转化流程

Flutter 运行中的一部分核心逻辑就是在处理这三棵树的转化,所有的界面交互和事件处理,最终都反应在这对三棵树的操作结果上。Flutter项目的启动代码一般是

void main() => runApp(MyApp());

runApp代码如下:

void runApp(Widget app) { 
    WidgetsFlutterBinding.ensureInitialized()
      ..scheduleAttachRootWidget(app)
      ..scheduleWarmUpFrame();
}

三行代码代表了Flutter APP 启动的三个主流程:

  1. binding初始化(ensureInitialized)
  2. 创建根 widget,绑定根节点(scheduleAttachRootWidget)
  3. 绘制热身帧(scheduleWarmUpFrame)

这里我们主要看下涉及到三棵树之间的转化的主要函数的作用。

scheduleAttachRootWidget

创建根 widget ,并且从根 widget 向子节点递归创建元素Element,对子节点为 RenderObjectWidget 的小部件创建 RenderObject 树节点,从而创建出 View 的渲染树,源码中使用 Timer.run 事件任务的方式来运行,目的是避免影响到微任务的执行。

attachRootWidget

将传入的Widget绑定到一个根节点并构建三棵树。

先是通过传入的 rootWidget 及 RenderView 实例化了一个RenderObjectToWidgetAdapter对象,而RenderObjectToWidgetAdapter是继承自RenderObjectWidget,即创建了Widget树的根节点。

attachToRenderTree

attachToRenderTree 中通过 createElement() 创建了一个RenderObjectToWidgetElement 实例作为 element tree 的根节点,并绑定BuildOwner,通过 BuildOwner 构建需要构建的 element。这里会调用 BuildOwner 的 buildScope 方法,方法里面会调用传入的callback,也就是调用 element.mount(null, null);,而 mount 方法会循环创建子节点,并在创建的过程中将需要更新的数据标记为 dirty。 mount 方法里面会调用 updateChild 方法。

buildScope

image

该方法源码太多,就不贴了,大概逻辑是先调用传进来的 callback 去循环创建子节点,并在创建的过程中将需要更新的数据标记为 dirty,然后循环 _dirtyElements 数组,如果 Element 有 child 则会递归判断子元素,并进行子元素的 build ,创建新的 Element 或者修改 Element 或者创建 RenderObject。如果是首次渲染,则 _dirtyElements 是空的列表,因此首次渲染在该方法中是没有任何执行流程的。该方法的核心还是在第二次渲染或者 setState 后,有标记 dirty 的 Element 时才会起作用。

updateChild

image

这个方法在 Flutter 的 Widget 系统中是非常重要的存在,每次在 Widget 树中新增、修改、删除一个子 Widget 都会调用这个方法去更新配置信息。所有子节点的处理都是经过该方法,在该方法中 Flutter 会处理 Element(newWidget.createElement() Widget -> Element) 与 RenderObject(newChild.mount(this, newSlot) Element -> RenderObject) 的转化逻辑,通过 Element 树的中间状态来减少对 RenderObject 树的影响,从而提升性能。该方法的三个输入参数:Element child、Widget newWidget、dynamic newSlot :

  • child :当前节点的 Element 信息
  • newWidget:Widget 树的新节点
  • newSlot:新节点新位置

了解参数后,大概分析下方法的实现:

  1. 判断是否有新的 Widget 节点,如果没有,则将当前节点的 Element 直接销毁;

如果有,并且 Element 中也存在该节点,继续看下面逻辑

  1. 首先判断当前节点Element是否为空,为空则直接创建一个新的Element。不为空则继续判断当前节点 Element 中的旧 Widget 与新 Widget 是否一致,如果一致只是位置不同,则更新位置即可updateSlotForChild(child, newSlot);。其他情况下判断是否可更新当前节点Element 的 Widget,如果可以则更新child.update(newWidget);,如果不可以则销毁当前的 Element 节点deactivateChild(child);,并重新创建一个newChild = inflateWidget(newWidget, newSlot);。

在 child.update 函数逻辑里面,会根据当前节点的类型,调用不同的 update,具体如下四种:

  • StatelessElement#update
  • StatefulElement#update
  • MultiChildRenderObjectElement#update
  • SingleChildRenderObjectElement#update

上面四种类型的 update 方法都会递归调用会递归调用子节点,并循环返回到 updateChild 中。

有以下三个核心的函数会重新进入 updateChild 流程中,分别是 performRebuild、inflateWidget 和 markNeedsBuild,接下来我们看下这三个函数具体的作用:

  • performRebuild
  • inflateWidget
  • markNeedsBuild

performRebuild

在 StatelessElement#update 和 StatefulElement#update 中会调用 rebuild 方法,进而调用 performRebuild 方法,tatelessWidget 和 StatefulWidget 的 build 方法都是在此执行,执行完成后将作为该节点的子节点,并进入 updateChild 递归函数中。

inflateWidget

根据传入的新的 widget 和位置创建一个新的 Element 节点作为当前 Element 节点的子节点,然后继续调用新的 Element 节点的 mount 方法去循环子节点,继续调用 updateChild 重新进入子节点更新流程。这里的 mount 方法根据 Element 节点类型不同有不同的实现,当为 RenderObjectElement 的时候还会去创建 RenderObject(Element -> RenderObject)

markNeedsBuild

在 State#setState 方法里面调用;

把 Element 标记为 dirty 并调用 BuildOwner#scheduleBuildFor 将其加入到脏列表(BuildOwner#_dirtyElements)中,等待下一次 buildScope (在下一次WidgetsBinding.drawFrame 时被调用来更新 Widget 树)。buildScope 中会遍历 _dirtyElements 然后调用每个脏的 Element#rebuild 进而调用 performRebuild 进入 updateChild 流程。

3. 首次 build 流程

首次加载一个页面时,所有节点都不存在,此时的流程大部分情况下就是创建新的节点,方法调用流程如下图:

首次 build 流程

不管是首次加载还是再次加载,runApp 到 RenderObjectToWidgetElement#mount 逻辑都是一样的,在 _rebuild 中会调用 updateChild 更新Element节点,由于Element节点是不存在的,因此这时候就调用 inflateWidget 来创建 Element,然后调用 Element#mount,Element 实现类主要分为:ComponentElement 和 RenderObjectElement。

ComponentElement

ComponentElement 主要子类:StatelessElememt、StatefulElement、InheritedElement。

当 Element 为 ComponentElement 时,会调用 ComponentElement#mount ,在 ComponentElement#mount 中会创建 Element 并挂载到当前节点上,继续调用 _firstBuild 进行子组件的 build ,build 完成后则将 build 好的组件作为子组件,进入 updateChild 的子组件更新。

RenderObjectElement

RenderObjectElement的主要子类:SingleChildRenderObjectElement、MultiChildRenderObjectElement、RenderObjectToWidgetElement。

当 Element 为 RenderObjectElement 时,则会调用 RenderObjectElement#mount,在 RenderObjectElement#mount 中调用 createRenderObject 创建 RenderObject,并将该 RenderObject 挂载到当前节点的 RenderObject 树,最后同样会调用 updateChild 或者 inflateWidget 来递归创建 Element 子节点。

4. setState 流程

上面有提到过,在调用 State#setState 后,内部会调用 markNeedsBuild 把 Element 标记为 dirty 并调用 BuildOwner#scheduleBuildFor 将其加入到脏列表(BuildOwner#_dirtyElements)中,等待下一次 buildScope 。 buildScope 会调用 rebuild 然后进入 build 操作,从而进入 updateChild 的循环体系。整个流程图如下:

setState 流程

上面流程图中为了简化流程图省略了 updateChild 中的部分条件,这里说明一下:

  • 走绿色流程线的条件:除了图中标明的当Element节点不存在以外,还有当 Element 节点存在,Widget 类型相同,不是同一个 Widget,并且 Widget 不可更新时,依然是创建新的 Element 节点。
  • 走蓝色流程线的条件:当 Element 节点存在,Widget 类型相同,不是同一个 Widget,并且 Widget 可更新时,更新Element 节点中的 Widget 为新的 Widget。

由以上流程图可知:Flutter 中 setState 调用后会引起当前节点下的子节点递归 build,虽然不一定会造成 RenderObject 树的改变,但也会存在一些性能影响。

5. 优化

1. 针对调用 setState 会导致子 Widget rebuild 操作

  • 尽量使用 const Widget
  • 尽量减少 StatefulWidget 下的子 Widget ,也就是说 StatefulWidget 的粒度尽可能小

2. canUpdate 返回 false 导致创建新的 Elememt 节点

在 updateChild 的流程中会判断 Element节点的 Widget 是否可更新,这段判断的逻辑就在 Widget#canUpdate 里面,方法实现如下:

这里逻辑比较简单,主要判断新旧 Widget 的运行时类型和 key 是否都相同,都相同则可以直接更新 Elememt 节点里面的 oldWidget 为 newWidget,而不是直接创建新的 Elememt 节点,从而提升性能。优化空间如下:

  • 尽量减少 Widget 的 key 的变化
  • 如果需要频繁地对组件进行排序、删除或者新增处理时,最好要给 Widget 增加 key ,以提升性能

使用 key 时注意点:

由于 StatefulWidget 的 state 是保存在 Element 中,因此如果希望区分两个相同类名( runtimeType )的 Widget 时,必须携带不同的 key ,否则会无法区分新旧 Widget 的变化。特别是在一个列表数据,每个列表都是一个有状态类,如果需要切换列表中项目列表时,则必须设置 key,不然会导致顺序切换失效。

3. 使用 GlobalKey 复用 Element

在 updateChild 中的 inflateWidget 中会检查新传入的 Widget 的 key 是否为 GlobalKey ,如果是则表明 Element 存在,那么这时候直接复用,如果不存在则需要重新创建,这其实就是 Widget 缓存,可以减少组件的 build 成本,inflateWidget 代码如下:

使用 GlobalKey 会缓存 Widget,那么就会带来一个问题:内存损耗。所以尽量在复用性高且 build 业务复杂的 Widget 上使用 GlobalKey。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342