Flutter是如何使用Widgets、Elements和RenderObjects来实现如此令人惊艳的视觉效果的呢?
本文已经得到作者的允许,将其原文The Layer Cake翻译成中文。鉴于本人的英语能力以及表达能力有限,请英语水平足够的朋友前往原文地址去阅读=。=。
Widget。你的APP是个Widget、Text是个Widget,Widget周围的padding也是Widget,甚至recognise gestures(手势识别)也是一个Widget。但是这些并不是全部的事实。如果我告诉你Widget的确很棒,能够帮助你快速的构建出APP,但是我不使用任何一个Widget就能够完成App的构建你相信吗?让我们先来深入框架来看看如何做到这一切吧。
The Four Layers
也许你已经在一些类似于‘Flutter入门介绍’的文章中对Flutter有了比较大致的了解。但是你并没有能够真正的理解这些层级所代表的概念。也许你像我一样看着这张图看了20s却不知道怎么理解。不用担心,我会帮助你的。看下下面的这个图吧。
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改变了,只需要装载其的盒被系统重新计算即可。
3. The Widget library
Flutter Widgets框架
Widget库或许是最有意思的库。它是另外一个用来提供开箱即用的Widget的抽象层。这个库中所有的Widget都属于以下三种使用适当的RenderObject处理的Widget之一。
Layout例如Column和RowWidgets用来帮助我们轻松的处理其他Widget的布局。Painting例如Text和ImageWidgets允许我们展示(绘制)一些内容在屏幕上。Hit-Testing例如GestureDetector允许我们识别出不同的手势,例如点击和滑动。
大多数情况下我们会使用一些“基础”Widget来组成我们需要的Widget。例如我们使用GestureDetec来包裹Container,Container中包裹Button来处理按钮点击。这叫做组合而不是继承。
然而除了自己构建每个UI组件,Flutter团队还创建了两个包含常用的Material和Cupertino风格的Widgets的库。
4. The Material & Cupertino library
使用Material和Cupertino设计规范的Widgets库。
Flutter为了减少开发者的负担,创建了这个拥有Material和Cupertino风格的Widgets层。
Put it all Together
RenderObject是如何与Widgets连接起来的呢?Flutter是如何创建布局?Element又是什么呢?
已经说的够多了,让我们在实践中学习吧。考虑如下Widgets树。
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树比较完成。
Widget和老的Widget是否是同一个类型。
如果不是同一个类型,那就把Widget、Element、RenderObject分别从它们的树(包括它们的子树)上移除,然后创建新的对象。
如果是一个类型,那就仅仅修改RenderObject中的配置,然后继续向下遍历。
在我们的例子中,SimpleApp Widget是和原来一样的类型,它的配置也是和原来的SimpleAppRender一样的,所以什么都不会发生。下一个item在Widget树中是SimpleContainer Widget,它的类型和原来是一样的,但是它的颜色变化了,RenderObject的配置发生变化了。因为SimpleObject仍然需要一个SimpleContainerRender来渲染,Flutter只是更新了SimpleContainerRender的颜色属性,然后要求它重新渲染。其他的对象都保持不变。
Widgets。那些重量级的RenderObject则是保持不变,直到与其相对应类型的Widget从Widget树中被移除。那如果Widget的类型发生改变了会发生什么呢?
RenderObject树中的RenderObject类型进行对比。
SimpleButton的类型与Element树中相对应位置的Element的类型不同(实际上还是与RenderObject的类型进行比较),Flutter将会从各自的树上删除这个Element和相对应的SimpleTextRender。然后Flutter将会重建与SimpleButton相对应的Element和RenderObject。
RenderObject树已经被重建,并将会计算布局,然后绘制在屏幕上面。Flutter内部使用了很多优化方法和缓存策略来处理,所以你不需要手动处理这个。
Conclusion
现在你应该对Flutter为什么能以如此快的速度渲染复杂布局有了大致的了解了。我希望这篇文章能够帮助你更好的理解Flutter内部的设计理念。我的Twitter是 Frederik Schweiger,期待与你的交流。