要了解跨平台框架,首先要知道,大部分的移动端跨平台框架都是“单页面”应用。
什么是“单页面”应用?
也就是对于原生 Android 和 iOS 而言,整个跨平台 UI 默认都是运行在一个 Activity
/ ViewController
上面,默认情况下只会有一个 Activity
/ ViewController
, Flutter、 ReactNative 、Weex 、Ionic 默认情况下都是如此,所以一般情况下框架的路由和原生的路由是没有直接关系。
跨平台应用默认情况下作为单页面应用,他们的路由堆栈是和原生层存在不兼容的隔离。
当然这里面重复用了一个词:“默认”,也就是其实可以支持自定义混合堆栈的,比如官方的 FlutterEngineGroup
,第三方框架 flutter_boost
、 mix_stack
、flutter_thrio
等等。
端上的开发无外乎三件事,“数据获取”,“状态管理”,“页面渲染”。
而在跨端领域的竟争,是 “虚拟机”,“渲染引擎”,“原生交互”,“开发环境” 的竟争
framework层中的每一个组件均是可选的和可以代替的。从上图可知,Flutter系统总共可以分为三层。上层的框架(Framework),中层的引擎(Engine),以及底层的嵌入层(Embedder)。
框架(Framework):Flutter framework 框架层是纯dart语言实现的一个响应式框架,由许多抽象的层级组成。在这些层级的最顶端是我们经常用到的 Material 和 Cupertino Widgets。我们大多数情况下使用的就是这两类 Widget。比如:UI/文本/图片/按钮等基础 Widgets。 在 Widget 层下面,你会发现 Rendering 层。Rendering层简化了布局和绘制过程。它是 dart:ui 的的抽象化。dart:ui 是框架的最底层,它负责处理与 Engine 层的交流沟通。 此部分的核心代码是: flutter 仓库下的flutter package,以及 sky_engine 仓库下的 io, async, ui (dart:ui 库提供了 Flutter 框架和引擎之间的接口)等package。
Flutter框架相对较小;许多开发者可能会用到的更高级别的功能都是以包的形式实现的,包括像摄像头和 webview 这样的平台插件,以及像字符、http 和动画这样的平台无关的功能,这些都是建立在核心Dart和Flutter库的基础上的。其中一些包来自更广泛的生态系统,涵盖应用内支付、苹果认证和动画等服务。
dart:ui library暴露出最底层的服务,这些服务被用来引导Application,例如用来驱动输入、绘制文字、布局和渲染子系统。
所以你可以仅仅通过使用实例化dart:ui库中的类(例如Canvas、Paint和TextField)来构建一个 Flutter App。但是如果你对于直接在 canvas 上绘制比较熟悉,就会知道使用这些底层 api 绘制一个图案是既难又繁琐的。 接下来考虑一些不是绘制的东西吧,例如布局和命中测试。 这些意味着什么呢? 这意味着你必须手动的计算所有在你布局中使用的坐标,然后混合一些绘制和命中测试来捕获用户的输入。对每一帧进行上述操作并追踪它们。这个方法对于那些比较简单的APP,比如一个在蓝色区域内展示文字这种比较适用。如果对于那些比较复杂的APP或者简单的游戏来说可够你受的了。更不用说产品经理最喜爱的动画、滚动和一些酷炫的UI效果了。
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 改变了,只需要装载其的盒被系统重新计算即可。
Flutter Widgets框架
Widget 库或许是最有意思的库。它是另外一个用来提供开箱即用的 Widget 的抽象层。这个库中所有的 Widget 都属于以下三种使用适当的 RenderObject 处理的 Widget 之一。
大多数情况下我们会使用一些“基础” Widget 来组成我们需要的 Widget。例如我们使用 GestureDetector 来包裹Container,Container 中包裹 Button 来处理按钮点击。这叫做组合而不是继承。 然而除了自己构建每个UI组件,Flutter团队还创建了两个包含常用的 Material 和 Cupertino 风格的 Widgets 的库。
使用 Material 和 Cupertino 设计规范的Widgets库。
Flutter 为了减少开发者的负担,创建了这个拥有 Material 和 Cupertino 风格的 Widgets 层。
嵌入层(Embedder):平台嵌入层是用于呈现所有 Flutter 内容的原生系统应用,它充当着宿主操作系统和 Flutter 之间的粘合剂的角色。当启动一个 Flutter 应用时,嵌入层会提供一个入口,初始化 Flutter 引擎,获取 UI 和栅格化线程,创建 Flutter 可以写入的纹理。嵌入层同时负责管理应用的生命周期,包括输入的操作(例如鼠标、键盘和触控)、窗口大小的变化、线程管理和平台消息的传递。 Flutter 拥有 Android、iOS、Windows、macOS 和 Linux 的平台嵌入层,
Flutter 的界面构建、布局、合成和绘制全都由 Flutter 自己完成,而不是转换为对应平台系统的原生组件。获取纹理和联动应用底层的生命周期的方法,不可避免地会根据平台特性而改变。 Flutter 引擎本身是与平台无关的,它提供了一个稳定的 ABI(应用二进制接口),包含一个 平台嵌入层,可以通过其方法设置并使用 Flutter。
每一个平台都有各自的一套 API 和限制。以下是一些关于平台简短的说明:
从架构图可以看出,Flutter 的平台相关层很低,平台(如iOS)只是提供一个画布,剩余的所有渲染相关的逻辑都在Flutter内部,Flutter 从头到尾重写一套跨平台的UI框架,包括UI控件、渲染逻辑甚至开发语言。渲染引擎依靠跨平台的Skia图形库来实现,依赖系统的只有图形绘制相关的接口,可以在最大程度上保证不同平台、不同设备的体验一致性,逻辑处理使用支持AOT的Dart语言,执行效率也比JavaScript高得多。
在渲染层面 Flutter 和其他跨平台框架存在较大差异,但是理论基本一样,先是构建一颗平台无关性的虚拟树 (Virtual Dom Tree),然后通过各自不同的实现自已渲染或交给原生进行渲染。
原生 Android ,是原生代码经过 skia 最后到 GPU 完成渲染绘制,Android 原生系统本身自带了 skia;
Flutter ,Dart 代码里的控件经过 skia 最后到 GPU 完成渲染绘制,这里在 Andriod 上使用的系统的 skia ,而在 iOS 上使用的是打包到项目里的 skia ;
ReactNative/Weex 等类似的项目,它们是运行在各自的 JS 引擎里面,最后通过映射为原生的控件,利用原生的渲染能力进行渲染;
例如:在 iOS 上调试好的样式,在 Android 上出现了异常;在 Android 上生效的样式,在 iOS 上没有支持;在 iOS 平台的控件效果,在 Android 上出现了不一样的展示,比如下拉刷新,Appbar等;
既然 Flutter 是一个跨平台的框架,那么它如何提供与原生平台框架相当的性能?它是如何 从widget 层级结构转换成屏幕上绘制的实际像素?需要经历那些步骤?
让我们从安卓原生应用的角度开始思考。当你在编写绘制的内容时,你需要调用 Android 框架的 Java 代码。 Android 的系统库提供了可以将自身绘制到 Canvas 对象的组件,接下来 Android 就可以使用由 C/C++ 编写的 Skia 图像引擎,调用 CPU 和 GPU 完成在设备上的绘制。
通常来说,跨平台框架都会在 Android 和 iOS 的 UI 底层库上创建一层抽象,该抽象层尝试抹平各个系统之间的差异。这时,应用程序的代码常常使用 JavaScript 等解释型语言来进行编写,这些代码会与基于 Java 的 Android 和基于 Objective-C 的 iOS 系统进行交互,最终显示 UI 界面。所有的流程都增加了显著的开销,在 UI 和应用逻辑有繁杂的交互时更为如此。
Flutter 通过绕过系统 UI 组件库,使用自己的 widget 内容集,削减了抽象层的开销。用于绘制 Flutter 图像内容的 Dart 代码被编译为机器码,并使用 Skia 进行渲染。 Flutter 同时也嵌入了自己的 Skia 副本,让开发者能在设备未更新到最新的系统时,也能跟进升级自己的应用,保证稳定性并提升性能。
首先观察以下的代码片段,它代表了一个简单的 widget 结构:
Container(
color: Colors.blue,
child: Row(
children: [
Image.network('https://www.example.com/1.png'),
const Text('A'),
],
),
);
当 Flutter 需要绘制这段代码片段时,框架会调用 build() 方法,返回一棵基于当前应用状态来绘制 UI 的 widget 子树。在这个过程中,build() 方法可能会在必要时,根据状态引入新的 widget。在上面的例子中,Container 的 color 和 child 就是典型的例子。我们可以查看 Container 的 源代码,你会看到当 color 属性不为空时,ColoredBox 会被加入用于颜色布局。
if (color != null)
current = ColoredBox(color: color!, child: current);
与之对应的,Image 和 Text 在构建过程中也会引入 RawImage 和 RichText。如此一来,最终生成的 widget 结构比代码表示的层级更深,在该场景中如下图
这就是为什么在使用 Dart DevTools 的 Flutter inspector 调试 widget 树结构时,会发现实际的结构比你原本代码中的结构层级更深。
在构建的阶段,Flutter 会将代码中描述的 widgets 转换成对应的 Element 树,每一个 Widget 都有一个对应的 Element。每一个 Element 代表了树状层级结构中特定位置的 widget 实例。目前有两种 Element 的基本类型:
RenderObjectElement 是底层 RenderObject 与对应的 widget 之间的桥梁,我们晚些会介绍它。
任何 widget 都可以通过其 BuildContext 引用到 Element,它是该 widget 在树中的位置的句柄。类似 Theme.of(context) 方法调用中的 context,它作为 build() 方法的参数被传递。
由于 widgets 以及它上下节点的关系都是不可变的,因此,对 widget 树做的任何操作(例如将 Text(‘A’) 替换成 Text(‘B’))都会返回一个新的 widget 对象集合。但这并不意味着底层呈现的内容必须要重新构建。 Element 树每一帧之间都是持久化的,因此起着至关重要的性能作用, Flutter 依靠该优势,实现了一种好似 widget 树被完全抛弃,而缓存了底层表示的机制。 Flutter 可以根据发生变化的 widget,来重建需要重新配置的 Element 树的部分。
很少有应用只绘制单个 widget。因此,有效地排布 widget 的结构及在渲染完成前决定每个 Element 的大小和位置,是所有 UI 框架的重点之一。
在渲染树中,每个节点的基类都是 RenderObject,该基类为布局和绘制定义了一个抽象模型。这是再平凡不过的事情:它并不总是一个固定的大小,甚至不遵循笛卡尔坐标规律(根据该 极坐标系的示例 所示)。每一个 RenderObject 都了解其父节点的信息,但对于其子节点,除了如何 访问 和获得他们的布局约束,并没有更多的信息。这样的设计让 RenderObject 拥有高效的抽象能力,能够处理各种各样的使用场景。
在构建阶段,Flutter 会为 Element 树中的每个 RenderObjectElement 创建或更新其对应的一个从 RenderObject 继承的对象。 RenderObject 实际上是原语:渲染文字的 RenderParagraph、渲染图片的 RenderImage 以及在绘制子节点内容前应用变换的 RenderTransform 是更为上层的实现。
大部分的 Flutter widget 是由一个继承了 RenderBox 的子类的对象渲染的,它们呈现出的 RenderObject 会在二维笛卡尔空间中拥有固定的大小。 RenderBox 提供了 盒子限制模型,为每个 widget 关联了渲染的最小和最大的宽度和高度。
在进行布局的时候,Flutter 会以 DFS(深度优先遍历)方式遍历渲染树,并 将限制以自上而下的方式 从父节点传递给子节点。子节点若要确定自己的大小,则 必须 遵循父节点传递的限制。子节点的响应方式是在父节点建立的约束内 将大小以自下而上的方式 传递给父节点。
在遍历完一次树后,每个对象都通过父级约束而拥有了明确的大小,随时可以通过调用 paint() 进行渲染。
盒子限制模型十分强大,它的对象布局的时间复杂度是 O(n):
父节点可以通过设定最大和最小的尺寸限制,决定其子节点对象的大小。例如,在一个手机应用中,最高层级的渲染对象将会限制其子节点的大小为屏幕的尺寸。(子节点可以选择如何占用空间。例如,它们可能在设定的限制中以居中的方式布局。)
父节点可以决定子节点的宽度,而让子节点灵活地自适应布局高度(或决定高度而自适应宽度)。现实中有一种例子就是流式布局的文本,它们常常会填充横向限制,再根据文字内容的多少决定高度。
这样的盒子约束模型,同样也适用于子节点对象需要知道有多少可用空间渲染其内容的场景,通过使用 LayoutBuilder widget,子节点可以得到从上层传递下来的约束,并合理利用该约束对象,使用方法如下:
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 600) {
return const OneColumnLayout();
} else {
return const TwoColumnLayout();
}
},
);
}
更多有关约束和布局系统的信息,及可参考的例子,可以在 深入理解 Flutter 布局约束 文章中查看。
所有 RenderObject 的根节点是 RenderView,代表了渲染树的总体输出。当平台需要渲染新的一帧内容时(例如一个 vsync 信号或者一个纹理的更新完成),会调用一次 compositeFrame() 方法,它是 RenderView 的一部分。该方法会创建一个 SceneBuilder 来触发当前画面的更新。当画面更新完毕,RenderView 会将合成的画面传递给 dart:ui 中的 Window.render() 方法,控制 GPU 进行渲染。
有关渲染流程的合成和栅格化阶段的更多细节,将不在本篇深入文章中讨论,但可以在 关于 Flutter 渲染流程的讨论 中了解更多。
Flutter 是如何创建布局?RenderObject 又是如何与 Widgets 连接起来的呢?Element 又是什么呢?我们接下来看个简单的例子,简单了解它们之间的关系。
我们构建的这个 APP 是非常简单的。它由三个 Stateless Widget 组成:SimpleApp、SimpleContainer、SimpleText。所以如果我们调用 Flutter 的 runApp() 方法会发生什么呢? 当 runApp() 被调用时,第一时间会在后台发生以下事件。
Flutter创建了三个不同的树,一个对应着 Widget,一个对应着 Element,一个对应着 RenderObject。每一个Element中都有着相对应的 Widget 和 RenderObject 的引用。
Flutter内一切都是Widget 。Widget 是非常轻量级的,实例化耗费的性能很少,所以它是描述 APP 的状态(也就是 configuration)的最好工具
那什么是 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 不同的原因。
因为 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 会对 Widget 树的顶端向下遍历,与 RenderObject 树中的 RenderObject 类型进行对比。
因为 SimpleButton 的类型与 Element 树中相对应位置的 Element 的类型不同(实际上还是与 RenderObject 的类型进行比较),Flutter 将会从各自的树上删除这个 Element 和相对应的 SimpleTextRender。然后 Flutter 将会重建与 SimpleButton 相对应的 Element 和 RenderObject。
RenderObject 树已经被重建,并将会计算布局,然后绘制在屏幕上面。
Flutter 运行之前都需要先执行 flutter pub get
来先同步下载第三方代码,下载的第三方代码一般存在于(Mac) /Users/你的用户名/.pub-cache
目录下 。
下载依赖成功后,可以直接通过 flutter run
或者 IDE 工具点击运行来启动 Flutter 项目,这个过程会需要原生工程的一些网络同步工作,比如:
如果需要在项目同步过程中查看进度:
同步的插件中,如果是 Plugin 带有原生平台的代码逻辑,那么可以在项目根目录下看到一个叫做 .flutter_plugins 和 .flutter-plugins-dependencies 的文件,它们是 git ignore 的文件,Android 和 iOS 中会根据这个文件对本地路径的插件进行引用,后面 Flutter 运行时会根据这个路径动态添加依赖。
默认情况下 Flutter 在 debug 下是 JIT 的运行模式所以运行效率会比较低,速度相对较慢,但是可以 hotload。
在 release 下是 AOT 模式,运行速度会快很多,同时 Flutter 在模拟器上一般默认会使用 CPU 运行,在真机上会使用 GPU 运行,所以性能表现也不同。
另外 iOS 14 真机上 debug 运行,断后链接后再次启动是无法运行的。
如果项目存在缓存问题,可以直接执行 flutter clean 来清理缓存。
Flutter 的为什么不支持热更新?
ReactNative 和 Weex 是通过将 JS 代码里的控件转化为原生控件进行渲染,所以本质上 JS 代码部分都只是文本而已,利用 code-push 推送文本内容本质上并不会违法平台要求。
而 Flutter 打包后的文件是二进制文件,推送二进制文件明显是不符合平台要求的。
release 打包后的 Android 会生成 app.so 和 flutter.so 两个动态库;
iOS 会生成 App.framework 和 Flutter.framework 两个文件。
响应式编程也叫做声明式编程,这是现在前端开发的主流,当然对于客户端开发的一种趋势,比如 Jetpack Compose 、SwiftUI 。
Jetpack Compose 和 Flutter 的在某些表层上看真的很相似。
响应式简单来说其实就是你不需要手动更新界面,只需要把界面通过代码“声明”好,然后把数据和界面的关系接好,数据更新了界面自然就更新了。
从代码层面看,对于原生开发而言,没有 xml 的布局,没有 storyboard,布局完全由代码完成,所见即所得,同时也不会需要操作界面“对象”去进行赋值和更新,你所需要做的就是配置数据和界面的关系。
响应式开发比数据绑定或者 MVVM 不同的地方是,它每次都是重新构建和调整整个渲染树,而不是简单的对 UI 进行 visibility 操作。
Widget 是 Flutter 里的基础概念,也是我们写代码最直接接触的对象,Flutter 内一切皆 Widget ,Widget 是不可变的(immutable),每个 Widget 状态都代表了一帧。
所以 Widget 作为一个 immutable 对象,它不可能是真正工作的 UI 对象,在 Flutter 里真正的 View 级别对象是 Element 和 RenderObject , 其中 Element 的抽象对象就是我们经常用到的 BuildContext。
Flutter 中 Widget 更多只是配置文件的地位,用于描述界面的配置代码,
Flutter 作为响应式开发框架,本质上它其实不再追求什么 MVC 、MVP、MVVVM 的设计模式,它更多是对界面状态的管理。
就是要抛弃以前在原生平台上,需要拿到 View 的对象,然后做对其进行 UI 设置这种思路。
Flutter 上更多需要管理数据的流向,比如:
因为对于界面来说,它只需要根据数据进行变化即可,我们不需要获取它去单独设置,所以 Flutter 中有各种数据管理和共享的框架,比较流行的有 provider 、 getx 、 flutter_redex 、flutter_mobx 等等。