Flutter是如何使用Widgets、Elements和RenderObjects来实现如此令人惊艳的视觉效果的呢?
本文已经得到作者的允许,将其原文The Layer Cake翻译成中文。鉴于本人的英语能力以及表达能力有限,请英语水平足够的朋友前往原文地址去阅读=。=。
Flutter是一个优秀的UI框架,它能够帮助我们快速的构建出漂亮的用户界面。只需要很少的代码和热重载,你的APP就能够拥有高达120fps的流畅性。但是,你是否想过Flutter是如何做到这一切的呢?Flutter使用了什么样的魔法来实现这一切的呢?或者说Flutter内部是如何工作的呢?我们将会探索这一切,请拿杯茶或者咖啡然后继续阅读下去吧。
也许你已经听过Flutter中一切皆为
Widget
。你的APP是个Widget
、Text是个Widget
,Widget
周围的padding
也是Widget
,甚至recognise gestures
(手势识别)也是一个Widget
。但是这些并不是全部的事实。如果我告诉你Widget
的确很棒,能够帮助你快速的构建出APP,但是我不使用任何一个Widget
就能够完成App的构建你相信吗?让我们先来深入框架来看看如何做到这一切吧。
The Four Layers
也许你已经在一些类似于‘Flutter入门介绍’的文章中对Flutter有了比较大致的了解。但是你并没有能够真正的理解这些层级所代表的概念。也许你像我一样看着这张图看了20s却不知道怎么理解。不用担心,我会帮助你的。看下下面的这个图吧。
Flutter framework由许多抽象的层级组成。在这些层级的最顶端是我们经常用到的
Material
和Cupertino
Widgets。我们大多数情况下使用的就是这两类Widget。在Widget层下面,你会发现
Rendering
层。Rendering
层简化了布局和绘制过程。它是dart:ui
的的抽象化。dart:ui
是框架的最底层,它负责处理与Engine
层的交流沟通。简而言之,等级越高的层越容易使用,但是等级越低的层,暴露出来的api越多,越能够增加自定义功能。
1. The dart:ui library
dart:ui library
暴露出最底层的服务,这些服务被用来引导Application,例如用来驱动输入、绘制文字、布局和渲染子系统。
所以你可以仅仅通过使用实例化dart:ui
库中的类(例如Canvas
、Paint
和TextField
)来构建一个Flutter App。但是如果你对于直接在canvas
上绘制比较熟悉,就会知道使用这些底层api绘制一个图案是既难又繁琐的。
接下来考虑一些不是绘制的东西吧,例如布局和命中测试。
这些意味着什么呢?
这意味着你必须手动的计算所有在你布局中使用的坐标。然后混合一些绘制和命中测试来捕获用户的输入。对每一帧进行上述操作并追踪它们。这个方法对于那些比较简单的APP,比如一个在蓝色区域内展示文字这种比较适用。如果对于那些比较复杂的APP或者简单的游戏来说可够你受的了。更不用说产品经理最喜爱的动画、滚动和一些酷炫的UI效果了。用我多年的开发经验告诉你,这些是开发者无穷无尽的梦魇。
2. The Rendering library
Flutter的
Rendering tree
(渲染树)。RenderObject
的层级结构被Flutter Widgets库使用来实现其布局和后台的绘制。通常来说,尽管你可能会使用RenderBox
来在你的应用中实现自定义的效果,但是大多数情况下我们唯一与RenderObject
的交互就是在调试布局信息的时候。
Rendering library
是dart:ui library
上第一个抽象层。它替你做了所有繁重的数学计算工作(例如跟踪需要不断计算的坐标)。它使用RenderObjects
来处理这些工作。你可以把RenderObjects
想象成一个汽车的发动机,它承担了所有把你的APP展示到屏幕的工作。Rendering tree
中的所有RenderObjects
都会被Flutter分层和绘制。为了优化这个复杂的过程,Flutter使用了一个智能算法来缓存这些实例化很耗费性能的对象从而实现在性能最优化。
大多数情况,你会发现Flutter使用RenderBox
而不是RenderObject
。这是因为项目的构建者发现使用一个简单和盒布局约束就能够成功的构建出有效稳定的UI。想象一下所有的Widget都被放置在它们的盒中。这个盒中的相关参数都计算好了,然后被放置到其他已经整理好的盒中间。所以如果在你的布局中仅有一个Widget改变了,只需要装载其的盒被系统重新计算即可。
- The Widget library
Flutter Widgets框架
Widget库或许是最有意思的库。它是另外一个用来提供开箱即用的Widget的抽象层。这个库中所有的Widget都属于以下三种使用适当的RenderObject
处理的Widget之一。
-
Layout
例如Column
和Row
Widgets用来帮助我们轻松的处理其他Widget的布局。 -
Painting
例如Text
和Image
Widgets允许我们展示(绘制)一些内容在屏幕上。 -
Hit-Testing
例如GestureDetector
允许我们识别出不同的手势,例如点击和滑动。
大多数情况下我们会使用一些“基础”Widget来组成我们需要的Widget。例如我们使用GestureDetec
来包裹Container
,Container
中包裹Button来处理按钮点击。这叫做组合而不是继承。
然而除了自己构建每个UI组件,Flutter团队还创建了两个包含常用的Material
和Cupertino
风格的Widgets的库。
- The Material & Cupertino library
使用Material和Cupertino设计规范的Widgets库。
Flutter是一切皆抽象,尽一切可能来减轻开发者的负担。
这是第四等级的包含预先构建好的Material
和Cupertino
风格的Widgets层。
Put it all Together
RenderObject
是如何与Widgets
连接起来的呢?Flutter是如何创建布局?Element又是什么呢?
已经说的够多了,让我们在实践中学习吧。考虑如下Widgets树。
我们构建的这个APP是非常简单的。它由三个
Stateless Widget
组成:SimpleApp
、SimpleContainer
、SimpleText
。所以如果我们调用Flutter的runApp()方法会发生什么呢?当
runApp()
被调用时,第一时间会在后台发生以下事件。
- Flutter会构建包含这三个Widget的Widgets树。
- Flutter遍历Widget树,然后根据其中的Widget调用
createElement()
来创建相应的Element对象,最后将这些对象组建成Element
树。 - 第三个树被创建,这个树中包含了与Widget对应的
Element
通过createRenderObject()
创建的RenderObject。
下图是Flutter经过这三个步骤后的状态:
Flutter创建了三个不同的树,一个对应着
Widget
,一个对应着Element
,一个对应着RenderObject
。每一个Element
中都有着相对应的Widget
和RenderObject
的引用。
那什么又是RenderObject
呢?
RenderObject
中包含了所有用来渲染实例Widget
的逻辑。它负责layout
、painting
和hit-testing
。它的生成十分耗费性能,所以我们应该尽可能的缓存它。我们把它在内存中尽可能的保存更长的时间,甚至回收利用它们(因为它们的实例化真的很耗费资源)。这个时候Element
就登场了。Element
是存在于可变Widget
树和不可变RenderObject
树之间的桥梁。Element
擅长比较两个Object
,在Flutter里面就是Widget
和RenderObject
。它的作用是配置好Widget
在树中的位置,并且保持对于相对应的RenderObject
和Widget
的引用。
为什么使用三个树而不是一个树呢?
简而言之是为了性能。当Widget
树改变的时候,Flutter使用Element
树来比较新的Widget
树和原来的RenderObject
树。如果某一个位置的Widget
和RenderObject
类型不一致,才需要重新创建RenderObject
。如果其他位置的Widget
和RenderObject
类型一致,则只需要修改RenderObject
的配置,不用进行耗费性能的RenderObject
的实例化工作了。因为Widget
是非常轻量级的,实例化耗费的性能很少,所以它是描述APP的状态(也就是configuration)的最好工具。重量级的RenderObject
(创建十分耗费性能)则需要尽可能少的创建,并尽可能的复用。就像Simon所说:整个Flutter APP就像是一个RecycleView。
然而,在框架中,Element
是被抽离开来的,所以你不需要经常和它们打交道。每个Widget的build(BuildContext context)
方法中传递的context
就是实现了BuildContext
接口的Element
,这也就是为什么相同类别的单个Widget
不同的原因。
Computer the Next Frame
因为Widget
是不可变的,当某个Widget
的配置改变的时候,整个Widget
树都需要被重建。例如当我们改变一个Container
的颜色为红色的时候,框架就会触发一个重建整个Widget
树的动作。然后在Element
的帮助下,Flutter比较新的Widget
树中的第一个Widget
类型和RenderObject
树中第一个RenderObject
的类型。接下来比较Widget
树中第二个Widget
和RenderObject
树中第二个RenderObject
的类型,以此类推,直到Widget
树和RendObject
树比较完成。
Flutter遵循一个最基本的原则:判断新的
Widget
和老的Widget
是否是同一个类型。如果不是同一个类型,那就把
Widget
、Element
、RenderObject
分别从它们的树(包括它们的子树)上移除,然后创建新的对象。如果是一个类型,那就仅仅修改
RenderObject
中的配置,然后继续向下遍历。在我们的例子中,
SimpleApp Widget
是和原来一样的类型,它的配置也是和原来的SimpleAppRender
一样的,所以什么都不会发生。下一个item在Widget树中是SimpleContainer Widget
,它的类型和原来是一样的,但是它的颜色变化了,RenderObject
的配置发生变化了。因为SimpleObject
仍然需要一个SimpleContainerRender
来渲染,Flutter只是更新了SimpleContainerRender
的颜色属性,然后要求它重新渲染。其他的对象都保持不变。这个过程是非常快的,因为Flutter非常擅长创建那些轻量级的
Widgets
。那些重量级的RenderObject
则是保持不变,直到与其相对应类型的Widget
从Widget
树中被移除。那如果Widget的类型发生改变了会发生什么呢?和原来一样,Flutter会对Widget树的顶端向下遍历,与
RenderObject
树中的RenderObject
类型进行对比。因为
SimpleButton
的类型与Element
树中相对应位置的Element
的类型不同(实际上还是与RenderObject
的类型进行比较),Flutter将会从各自的树上删除这个Element
和相对应的SimpleTextRender
。然后Flutter将会重建与SimpleButton
相对应的Element
和RenderObject
。然后完成了,新的
RenderObject
树已经被重建,并将会计算布局,然后绘制在屏幕上面。Flutter内部使用了很多优化方法和缓存策略来处理,所以你不需要手动处理这个。
Conclusion
现在你应该对Flutter为什么能以如此快的速度渲染复杂布局有了大致的了解了。我希望这篇文章能够帮助你更好的理解Flutter内部的设计理念。我的Twitter是 Frederik Schweiger,期待与你的交流。