1

Flutter 知识体系

image

Flutter 渲染过程

Flutter 的展示过程分为四个阶段:布局、绘制、合成和渲染。
布局、绘制在RenderObject 中完成

### 布局

Flutter 采用深度优先机制遍历渲染对象树,决定渲染对象树中各渲染对象在屏幕上的位置和尺寸。在布局过程中,渲染对象树中的每个渲染对象都会接收父对象的布局约束参数,决定自己的大小,然后父对象按照控件逻辑决定各个子对象的位置,完成布局过程。
image

为了防止因子节点发生变化而导致整个控件树重新布局,Flutter 加入了一个机制——布局边界(Relayout Boundary),可以在某些节点自动或手动地设置布局边界,当边界内的任何对象发生重新布局时,不会影响边界外的对象,反之亦然。

image

绘制

布局完成后,渲染对象树中的每个节点都有了明确的尺寸和位置。Flutter 会把所有的渲染对象绘制到不同的图层上。与布局过程一样,绘制过程也是深度优先遍历,而且总是先绘制自身,再绘制子节点。

以下图为例:节点 1 在绘制完自身后,会再绘制节点 2,然后绘制它的子节点 3、4 和 5,最后绘制节点 6。

image

可以看到,由于一些其他原因(比如,视图手动合并)导致 2 的子节点 5 与它的兄弟节点 6 处于了同一层,这样会导致当节点 2 需要重绘的时候,与其无关的节点 6 也会被重绘,带来性能损耗。

为了解决这一问题,Flutter 提出了与布局边界对应的机制——重绘边界(Repaint Boundary)。在重绘边界内,Flutter 会强制切换新的图层,这样就可以避免边界内外的互相影响,避免无关内容置于同一图层引起不必要的重绘。

image

重绘边界的一个典型场景是 Scrollview。ScrollView 滚动的时候需要刷新视图内容,从而触发内容重绘。而当滚动内容重绘时,一般情况下其他内容是不需要重绘的,这时候重绘边界就派上用场了。~~~~

合成和渲染

终端设备的页面越来越复杂,因此 Flutter 的渲染树层级通常很多,直接交付给渲染引擎进行多图层渲染,可能会出现大量渲染内容的重复绘制,所以还需要先进行一次图层合成,即将所有的图层根据大小、层级、透明度等规则计算出最终的显示效果,将相同的图层归类合并,简化渲染树,提高渲染效率。

合并完成后,Flutter 会将几何图层数据交由 Skia 引擎加工成二维图像数据,最终交由 GPU 进行渲染,完成界面的展示。


StatefulWidget 与 StatelessWidget 的接口设计,为什么会有这样的区别呢?
这是因为 Widget 需要依据数据才能完成构建,而对于 StatefulWidget 来说,其依赖的数据在 Widget 生命周期中可能会频繁地发生变化。由 State 创建 Widget,以数据驱动视图更新,而不是直接操作 UI 更新视觉属性,代码表达可以更精炼,逻辑也可以更清晰。

好了,今天关于 StatelessWidget 与 StatefulWidget 的介绍,我们就到这里了。我们一起来回顾下今天的主要知识点。

首先,我带你了解了 Flutter 基于声明式的 UI 编程范式,并通过阅读两个典型 Widget(Text 与 Image)源码的方式,与你一起学习了 StatelessWidget 与 StatefulWidget 的基本设计思路。

由于 Widget 采用由父到子、自顶向下的方式进行构建,因此在自定义组件时,我们可以根据父 Widget 是否能通过初始化参数完全控制其 UI 展示效果的基本原则,来判断究竟是继承 StatelessWidget 还是 StatefulWidget。

然后,针对 StatefulWidget 的“万金油”误区,我带你重新回顾了 Widget 的 UI 更新机制。尽管 Flutter 会通过 Element 层去最大程度降低对真实渲染视图的修改,但大量的 Widget 销毁重建无法避免,因此避免 StatefulWidget 的滥用,是最简单、直接地提升应用渲染性能的手段。

需要注意的是,除了我们主动地通过 State 刷新 UI 之外,在一些特殊场景下,Widget 的 build 方法有可能会执行多次。因此,我们不应该在这个方法内部,放置太多有耗时的操作。而关于这个 build 方法在哪些场景下会执行,以及为什么会执行多次,我会在下一篇文章“提到生命周期,我们是在说什么?”中,与你一起详细分析。


Widget 渲染过程

在进行 App 开发时,我们往往会关注的一个问题是:如何结构化地组织视图数据,提供给渲染引擎,最终完成界面显示。
通常情况下,不同的 UI 框架中会以不同的方式去处理这一问题,但无一例外地都会用到视图树(View Tree)的概念。而 Flutter 将视图树的概念进行了扩展,把视图数据的组织和渲染抽象为三部分,即 Widget,Element 和 RenderObject。

Widget(可重建 不可变化)

Widget 是 Flutter 世界里对视图的一种结构化描述,你可以把它看作是前端中的“控件”或“组件”。Widget 是控件实现的基本逻辑单位,里面存储的是有关视图渲染的配置信息,包括布局、渲染属性、事件响应信息等。
在页面渲染上,Flutter 将“Simple is best”这一理念做到了极致。为什么这么说呢?Flutter 将 Widget 设计成不可变的,所以当视图渲染的配置信息发生变化时,Flutter 会选择重建 Widget 树的方式进行数据更新,以数据驱动 UI 构建的方式简单高效。
但,这样做的缺点是,因为涉及到大量对象的销毁和重建,所以会对垃圾回收造成压力。不过,Widget 本身并不涉及实际渲染位图,所以它只是一份轻量级的数据结构,重建的成本很低。
另外,由于 Widget 的不可变性,可以以较低成本进行渲染节点复用,因此在一个真实的渲染树中可能存在不同的 Widget 对应同一个渲染节点的情况,这无疑又降低了重建 UI 的成本。

Element

Element 是 Widget 的一个实例化对象,它承载了视图构建的上下文数据,是连接结构化的配置信息到完成最终渲染的桥梁。
Flutter 渲染过程,可以分为这么三步:

  • 首先,通过 Widget 树生成对应的 Element 树;
  • 然后,创建相应的 RenderObject 并关联到 Element.renderObject 属性上;
  • 最后,构建成 RenderObject 树,以完成最终的渲染。

可以看到,Element 同时持有 Widget 和 RenderObject。而无论是 Widget 还是 Element,其实都不负责最后的渲染,只负责发号施令,真正去干活儿的只有 RenderObject。那你可能会问,既然都是发号施令,那为什么需要增加中间的这层 Element 树呢?直接由 Widget 命令 RenderObject 去干活儿不好吗?

RenderObject

从其名字,我们就可以很直观地知道,RenderObject 是主要负责实现视图渲染的对象。
在前面的第 4 篇文章“Flutter 区别于其他方案的关键技术是什么?”中,我们提到,Flutter 通过控件树(Widget 树)中的每个控件(Widget)创建不同类型的渲染对象,组成渲染对象树。

而渲染对象树在 Flutter 的展示过程分为四个阶段,即布局、绘制、合成和渲染。 其中,布局和绘制在 RenderObject 中完成,Flutter 采用深度优先机制遍历渲染对象树,确定树中各个对象的位置和尺寸,并把它们绘制到不同的图层上。绘制完毕后,合成和渲染的工作则交给 Skia 搞定。

Flutter 通过引入 Widget、Element 与 RenderObject 这三个概念,把原本从视图数据到视图渲染的复杂构建过程拆分得更简单、直接,在易于集中治理的同时,保证了较高的渲染效率。

三颗树

接下来,我以下面的界面示例为例,与你说明 Widget、Element 与 RenderObject 在渲染过程中的关系。在下面的例子中,一个 Row 容器放置了 4 个子 Widget,左边是 Image,而右边则是一个 Column 容器下排布的两个 Text。
image

那么,在 Flutter 遍历完 Widget 树,创建了各个子 Widget 对应的 Element 的同时,也创建了与之关联的、负责实际布局和绘制的 RenderObject。

image

总结

好了,今天关于 Widget 的设计思路和基本原理的介绍,我们就先进行到这里。接下来,我们一起回顾下今天的主要内容吧。
首先,我与你介绍了 Widget 渲染过程,学习了在 Flutter 中视图数据的组织和渲染抽象的三个核心概念,即 Widget、 Element 和 RenderObject。
其中,Widget 是 Flutter 世界里对视图的一种结构化描述,里面存储的是有关视图渲染的配置信息;Element 则是 Widget 的一个实例化对象,将 Widget 树的变化做了抽象,能够做到只将真正需要修改的部分同步到真实的 Render Object 树中,最大程度地优化了从结构化的配置信息到完成最终渲染的过程;而 RenderObject,则负责实现视图的最终呈现,通过布局、绘制完成界面的展示。
最后,在对 Flutter Widget 渲染过程有了一定认识后,我带你阅读了 RenderObjectWidget 的代码,理解 Widget、Element 与 RenderObject 这三个对象之间是如何互相配合,实现图形渲染工作的。
熟悉了 Widget、Element 与 RenderObject 这三个概念,相信你已经对组件的渲染过程有了一个清晰而完整的认识。这样,我们后续再学习常用的组件和布局时,就能够从不同的视角去思考框架设计的合理性了。
不过在日常开发学习中,绝大多数情况下,我们只需要了解各种 Widget 特性及使用方法,而无需关心 Element 及 RenderObject。因为 Flutter 已经帮我们做了大量优化工作,因此我们只需要在上层代码完成各类 Widget 的组装配置,其他的事情完全交给 Flutter 就可以了。


生命周期

image

State 的生命周期可以分为 3 个阶段:创建(插入视图树)、更新(在视图树中存在)、销毁(从视图树中移除)

创建

State 初始化时会依次执行 :构造方法 -> initState -> didChangeDependencies -> build,随后完成页面渲染。
我们来看一下初始化过程中每个方法的意义。

  • 构造方法是 State 生命周期的起点,Flutter 会通过调用 StatefulWidget.createState() 来创建一个 State。我们可以通过构造方法,来接收父 Widget 传递的初始化 UI 配置数据。这些配置数据,决定了 Widget 最初的呈现效果。
  • initState,会在 State 对象被插入视图树的时候调用。这个函数在 State 的生命周期中只会被调用一次,所以我们可以在这里做一些初始化工作,比如为状态变量设定默认值。
  • didChangeDependencies 则用来专门处理 State 对象依赖关系变化,会在 initState() 调用结束后,被 Flutter 调用。
  • build,作用是构建视图。经过以上步骤,Framework 认为 State 已经准备好了,于是调用 build。我们需要在这个函数中,根据父 Widget 传递过来的初始化配置数据,以及 State 的当前状态,创建一个 Widget 然后返回。

更新

Widget 的状态更新,主要由 3 个方法触发:setState、didchangeDependencies 与 didUpdateWidget。

接下来,我和你分析下这三个方法分别会在什么场景下调用。

  • setState:我们最熟悉的方法之一。当状态数据发生变化时,我们总是通过调用这个方法告诉 Flutter:“我这儿的数据变啦,请使用更新后的数据重建 UI!”
  • didChangeDependencies:State 对象的依赖关系发生变化后,Flutter 会回调这个方法,随后触发组件构建。哪些情况下 State 对象的依赖关系会发生变化呢?典型的场景是,系统语言 Locale 或应用主题改变时,系统会通知 State 执行 didChangeDependencies 回调方法。
  • didUpdateWidget:当 Widget 的配置发生变化时,比如,父 Widget 触发重建(即父 Widget 的状态发生变化时),热重载时,系统会调用这个函数。

一旦这三个方法被调用,Flutter 随后就会销毁老 Widget,并调用 build 方法重建 Widget。

销毁

组件销毁相对比较简单。比如组件被移除,或是页面销毁的时候,系统会调用 deactivate 和 dispose 这两个方法,来移除或销毁组件。
接下来,我们一起看一下它们的具体调用机制:

  • 当组件的可见状态发生变化时,deactivate 函数会被调用,这时 State 会被暂时从视图树中移除。值得注意的是,页面切换时,由于 State 对象在视图树中的位置发生了变化,需要先暂时移除后再重新添加,重新触发组件构建,因此这个函数也会被调用。
  • 当 State 被永久地从视图树中移除时,Flutter 会调用 dispose 函数。而一旦到这个阶段,组件就要被销毁了,所以我们可以在这里进行最终的资源释放、移除监听、清理环境,等等。

如图 2 所示,左边部分展示了当父 Widget 状态发生变化时,父子双方共同的生命周期;而中间和右边部分则描述了页面切换时,两个关联的 Widget 的生命周期函数是如何响应的。

image

图 2 几种常见场景下 State 生命周期图

我准备了一张表格,从功能,调用时机和调用次数的维度总结了这些方法,帮助你去理解、记忆。


图 3 State 生命周期中的方法调用对比

另外,我强烈建议你打开自己的 IDE,在应用模板中增加以上回调函数并添加打印代码,多运行几次看看各个函数的执行顺序,从而加深对 State 生命周期的印象。毕竟,实践出真知。


单线程模型怎么保证UI运行流畅?

Event Loop 机制
首先,我们需要建立这样一个概念,那就是Dart 是单线程的。那单线程意味着什么呢?这意味着 Dart 代码是有序的,按照在 main 函数出现的次序一个接一个地执行,不会被其他代码中断。另外,作为支持 Flutter 这个 UI 框架的关键技术,Dart 当然也支持异步。需要注意的是,单线程和异步并不冲突。
那为什么单线程也可以异步?
这里有一个大前提,那就是我们的 App 绝大多数时间都在等待。比如,等用户点击、等网络请求返回、等文件 IO 结果,等等。而这些等待行为并不是阻塞的。比如说,网络请求,Socket 本身提供了 select 模型可以异步查询;而文件 IO,操作系统也提供了基于事件的回调机制。
所以,基于这些特点,单线程模型可以在等待的过程中做别的事情,等真正需要响应结果了,再去做对应的处理。因为等待过程并不是阻塞的,所以给我们的感觉就像是同时在做多件事情一样。但其实始终只有一个线程在处理你的事情。
等待这个行为是通过 Event Loop 驱动的。事件队列 Event Queue 会把其他平行世界(比如 Socket)完成的,需要主线程响应的事件放入其中。像其他语言一样,Dart 也有一个巨大的事件循环,在不断的轮询事件队列,取出事件(比如,键盘事件、I\O 事件、网络事件等),在主线程同步执行其回调函数,如下图所示:

异步任务我们用的最多的还是优先级更低的 Event Queue。比如,I/O、绘制、定时器这些异步事件,都是通过事件队列驱动主线程执行的。
Dart 为 Event Queue 的任务建立提供了一层封装,叫作 Future

**如果 Future 执行体已经执行完毕了,但你又拿着这个 Future 的引用,往里面加了一个 then 方法体,这时 Dart 会如何处理呢?面对这种情况,Dart 会将后续加入的 then 方法体放入微任务队列,尽快执行。**

await 与 async 只对调用上下文的函数有效,并不向上传递。

Isolate

尽管 Dart 是基于单线程模型的,但为了进一步利用多核 CPU,将 CPU 密集型运算进行隔离,Dart 也提供了多线程机制,即 Isolate。在 Isolate 中,资源隔离做得非常好,每个 Isolate 都有自己的 Event Loop 与 Queue,Isolate 之间不共享任何资源,只能依靠消息机制通信,因此也就没有资源抢占问题

总结

好了,今天关于 Dart 的异步与并发机制、实现原理的分享就到这里了,我们来简单回顾一下主要内容。
Dart 是单线程的,但通过事件循环可以实现异步。而 Future 是异步任务的封装,借助于 await 与 async,我们可以通过事件循环实现非阻塞的同步等待;Isolate 是 Dart 中的多线程,可以实现并发,有自己的事件循环与 Queue,独占资源。Isolate 之间可以通过消息机制进行单向通信,这些传递的消息通过对方的事件循环驱动对方进行异步处理。

在 UI 编程过程中,异步和多线程是两个相伴相生的名词,也是很容易混淆的概念。对于异步方法调用而言,代码不需要等待结果的返回,而是通过其他手段(比如通知、回调、事件循环或多线程)在后续的某个时刻主动(或被动)地接收执行结果。

因此,从辩证关系上来看,异步与多线程并不是一个同等关系:异步是目的,多线程只是我们实现异步的一个手段之一。而在 Flutter 中,借助于 UI 框架提供的事件循环,我们可以不用阻塞的同时等待多个异步任务,因此并不需要开多线程。我们一定要记住这一点。

我把今天分享所涉及到的知识点打包到了GitHub中,你可以下载下来,反复运行几次,加深理解。


已注销
4 声望0 粉丝

« 上一篇
Flutter Excption
下一篇 »
五笔简明教程