Hummingbird: Web 里的 Flutter
作者 / Yegor Jbanov, Flutter 开发工程师, Google
相信关注 Flutter Live 的朋友们已经知道了我们正在将 Flutter 带进 Web。在本文中我们会和您分享一下我们的工作历程,以及当前的项目进展。另外,您还会看到一些使用以及实现的细节以及在 Web 页面中和其他代码 / 元件进行互操作的说明,请耐心阅读到最后。
让我们来快速回顾一下 Flutter 的架构。Flutter 是一个多层系统,您可以在更高的层级上用很少的代码就开发出很丰富的内容,而如果您需要控制更多的系统行为,您也可以深入到较低的层去进行控制,但相应的,复杂度也会更高一些。当较高层的代码无法满足开发者的需求时,开发者们随时可以深入到较低的层中去完成自己的开发目标,如下图,开发者可以访问 Flutter Engine 上方的所有层。
△ Flutter 架构
正如大家所看到的,Flutter Engine 是 Flutter 中最低级别的库,dart:ui。它不涉及任何 widget、物理效果、动画或布局 (文本布局除外),而只负责将一个个 picture 类放到屏幕上,并将它们绘制成像素。在 dart:ui 上直接编写应用是很困难的,这就是我们需要在此之上创建更多层代码的理由。
Flutter 官方文档: Picture 类
https://docs.flutter.io/flutter/dart-ui/Picture-class.html
我们将位于 dart:ui 之上的一切统称 “框架 (Framework)”,它下面的一切都叫做 “引擎 (Engine)”。框架完全使用 Dart 编程语言编写。引擎的绝大部分使用 C++ 编写,专属 Android 的部分用 Java 编写,专属 iOS 的部分则用 Objective-C 编写。而 dart:ui 中的一些基本类和方法是用 Dart 编写,主要用作 Dart 和 C++ 之间的桥梁。
Flutter 还提供插件系统。插件是可以直接访问 OEM 库和第三方库的代码,这些库在移动设备的生态系统中已经累积了很长时间,为开发提供了更多的可能性。要为 Android 创建插件,您可以使用 Java 或 Kotlin 进行编写,iOS 插件则使用 Objective-C 或 Swift 编写。
Hello, Web
Web 平台发展至今已有数十年了,包括了为数众多的技术和规范。我们可以使用一些总括性术语来描述大量相关功能,如 HTML、CSS、SVG、JavaScript、WebGL 等。为了在 Web 上运行 Flutter,我们需要:
编译 Dart 代码: Dart 是 Flutter 的开发语言,我们需要在 Web 上运行 Dart 代码。
确定要在 Web 上运行的 Flutter 子集: 在 Web 上运行所有 Flutter 代码并不实际,也谈不上有用,毕竟其中一些代码是专属某个平台的,例如 Android 和 iOS。
选择支持的 Web 功能子集: 随着时间的推移,Web 平台会累积一些彼此重叠的功能。例如,如果想绘制图形,您用 HTML + CSS、SVG、Canvas 和 WebGL 都可以做到。
自 Dart 诞生以来,它一直支持编译为 JavaScript。今天很多重要的应用都是从 Dart 编译为 JavaScript,并且在生产环境中运行良好。因此,Flutter 的编译策略同样依存于这套机制。
在启动这个项目时,我们面临着 UI 渲染方式的几种选项。但我们很快意识到,我们想要支持的 Flutter 层级和代码同时也决定了我们会支持哪些 Web 技术。为此我们制作了三个原型:
仅有 widget: 这个原型实现了 Flutter 的 widget 框架,并提供了一组核心布局 widget,作为构建自定义 widget 的基础。关于布局和定位,它有赖于 Web 内置的功能,例如 flexbox、grid 布局、通过 overflow:scroll 实现的浏览器滚动布局等。
Widget + 自定义布局: 此原型包括 Flutter 的布局系统 (基于 RenderObject),但将渲染对象直接映射为 HTML 元素。
Flutter Web Engine: 这个原型保留了 dart:ui 之上的所有层,并提供了一个可以在浏览器中运行的 dart:ui 实现。
Flutter 最有价值的功能之一是它可以跨平台移植。虽然您可以编写专属某个平台的代码 (有时我们甚至会鼓励您这样做),但也可以共享那些在不同平台上都能保持一致的代码。这样一来,您只需用一个代码库即可编写面向多个平台的应用。
在三个原型开发完成后,我们尝试将几个样本应用移植到 Web,我们发现 1 号和 2 号原型无法为 Flutter 开发者提供足够的可移植性。因此,我们决定使用基于 Flutter Web Engine 的 3 号原型。这样一来,平台之间的框架级代码就可以重复使用了,如下图:
△ Web 版 Flutter (即 Hummingbird) 架构
现在我们明确了我们需要选择需要的 Web 技术,并在其上实现我们需要的整套 dart:ui API。
接下来是 widget。Flutter 是逐帧渲染 UI 的,在每帧内 Flutter 会构建 widget,执行布局,最后在屏幕上渲染它们,自然我们的下一个问题是:
构建 Widget
Widget 的构建机制并不依赖于应用运行的环境。该过程只是将内存中的对象实例化,跟踪其状态,并在状态变化时将最精简的变化信息发送给更低层级,然后更低层级的代码会处理布局和绘图的细节。将此部分移植到 Web 上非常简单,自 Dart 团队在 dart2js 中实现了对 super-mixin 的支持之后,编译器就可以将所有 widget 和 widget 框架编译为 JavaScript,在这个过程中几乎没有出现什么问题。
布局系统
布局系统就比较麻烦了,最大的挑战来自文本布局。至于布局系统中的其他内容,如 Center, Row, Column, Stack, Scrollable, Padding, Wrap 等,均由框架直接实现,因此无需修改即可编译到 Web。
说回到文本布局,在 Flutter 中,您可以通过创建 Paragraph 对象并调用其 layout() 方法来处理文本布局。但是由于 Web 缺少原生的文本布局 API,所以我们用来测量文本布局各个属性的技巧是: 让浏览器先生成文本并完成布局,然后从 DOM 元素中读回相关属性。
在布局文本时,Flutter 会测量 Paragraph 的高度 (height)、宽度 (width)、最大内宽 (maximum intrinsic width)、最小内宽 (minimum intrinsic width)、字母基线 (alphabetic baseline) 以及降部基线 (ideographic baseline)。这些属性如下图所示:
△ 字母基线是指字母书写时的对齐线,而上图中 Howdy 的 “y” 向下超出了字母基线,超出的部分称作 “降部”。
Flutter 官方文档: Paragraph 类
https://docs.flutter.io/flutter/dart-ui/Paragraph-class.html
想要测量这些属性,我们就要首先在 HTML DOM 中放置一个文本段落,然后读取相关的数据。例如,为了获取元素的宽度和高度,我们会调用 offsetWidth 和 offsetHeight。为了测量基线,我们将段落放置在一个使用 flex row 进行布局的元素中,在段落旁边,我们会放置另一个名为 “probe (探测器)” 的元素。因为 probe 与文本的基线对齐,所以调用 getBoundingClientRect 就可以得到基线数据。我们也使用了类似的技巧来测量最小和最大内宽。
绘制
最后,我们来谈一谈 widget 的绘制。我们在这方面进行了最多的探索,它现在仍然是我们研究中最活跃的课题之一。毕竟这整个框架说到底,是要让我们的每一个 widget 都在屏幕上变成像素。在浏览器中,这意味着它们必须映射为 HTML / CSS、Canvas、SVG 和 WebGL 这些元素的某种组合。
我们暂时先不考虑 WebGL,主要是因为它的层级较低,并且要求我们重新实现浏览器已经可以做的事情,例如文本布局和栅格化 2D 图形。此外,我们还没有弄清楚可访问性、文本选择操作,以及那些非 Flutter 组件应该如何与 WebGL 协同工作。
我们的早期原型之一为每个 RenderObject 生成了一个 HTML 元素。我们获得的结果看起来确实不错,但后来却证明,API 的变化太大了 (即开发者最担心的 API breaking change),这让我们必须和 Flutter 同步维持巨大的代码增量更新,所以我们放弃了这个想法。
目前我们正在同时探索两种实现方法:
HTML + CSS + Canvas
CSS Paint API
HTML + CSS + Canvas
通过这种方法,我们将框架生成的图片分类为使用 HTML + CSS 表达的图片,以及使用 Canvas 2D 表达的图片。然后输出为结合了 HTML、CSS 和 2D canvas 的 HTML DOM。
我们会优先使用 HTML + CSS,因为它由浏览器的显示列表 (display list*) 支持。这意味着我们可以让浏览器的渲染引擎来对图片的栅格化进行优化。这也意味着我们可以应用任意图形变换,尤其是旋转和缩放,而不必担心像素化的处理。我们将此画布实现称为 DomCanvas。
* Display list,指用于显示图形的绘图指令集。
如果我们遇到无法使用 HTML + CSS 渲染的图形,我们会退回到 Canvas 来进行渲染处理。Canvas 2D 支持几乎所有的 Flutter 绘图指令。如果将 Flutter 的 Canvas 与 Web 的 CanvasRenderingContext2D 进行比较,您会发现许多相似之处。在 Canvas 上渲染图形效率很高,因为它不会创建需要维护的可变树节点,如 HTML DOM 或 SVG。
CanvasRenderingContext2D:
https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D
2D canvas 的一个挑战是,浏览器将其作为位图处理,即存储宽度 x 高度像素的内存缓冲区。因此,缩放 canvas 会导致像素问题 (即出现 “马赛克效果”)。如果缩放操作导致需要调整一张图片的大小,我们就需要调整 canvas 的大小。我们发现实例化 / 分配 canvas 时占用资源过多,调整它们的尺寸时也存在类似的问题。此外,当将多个 canvas 合成到同一页面上时,浏览器必须执行栅格合成处理,这也在我们的资源优化过程中造成了显著的问题。栅格合成处理与显示列表的工作方式并不同——比如您可以将多个显示列表在同一个内存缓冲区中统一处理。我们把 Canvas 2D 支持的 canvas 称作 BitmapCanvas,目前我们正在研究让 BitmapCanvas 更高效运作的方法。
我们使用 HTML 原生功能来实现 Flutter 的透明度、形变、位移、遮罩以及其他图层内容。例如,带透明度的图层变为了具有 opacity CSS 属性的 <flt-opacity> 元素,形变则使用了带 transform CSS 属性的 <flt-transform> 元素,clip rect 使用带 overflow: hidden 的 <flt-clip-rect>。
Flutter Layer 类:
https://docs.flutter.io/flutter/rendering/Layer-class.html
完成所有操作后,框架将作为 HTML 对象树呈现在页面上,其中 DomCanvas 和 BitmapCanvas 将作为其叶节点。如下图:
作为对比,Flutter Engine 中的等效 Flutter 层级树 (flow layer) 如下所示:
在结构上它们非常相似。最大的区别是,在 Web 环境下我们必须根据内容选择不同的图形实现方法。
HTML + CSS + Canvas 适用于所有现代浏览器。不过,我们并没有停留于此:
CSS Paint API
CSS Paint 是一个新的 Web API,它属于 Houdini 的一部分。Houdini 由多家浏览器供应商共同提供支持,旨在向开发者开放 CSS 引擎的特定内容。特别是,CSS Paint API 允许开发者在这些元素请求绘制时将自定义图形绘制成 HTML 元素。例如,您可以将元素 background 属性的绘制工作分配给自定义的 CSS 绘制器。
了解 Houdini:
https://developers.google.com/web/updates/2016/05/houdini
它与 canvas 非常相似,但存在以下重要区别:
这个绘制工作不是由 JavaScript 主导完成的,而是由一个叫做 paint worklet 的东西完成的。它有点像 web worker,因为它有自己的内存空间。在提交 DOM 更改之后, paint worklet 就会在浏览器的绘制阶段执行绘制工作。
CSS paint 由浏览器 display list 支持,而不是位图 (栅格图)。这就让我们实现了两全其美的效果——2D canvas 般的绘制效率,并且没有像素化问题。
目前 CSS paint 不支持绘制文本。
在撰写本文时,Chrome 和 Opera 是唯二支持 CSS Paint 操作的浏览器。不过,其他浏览器也正在实现这些功能,只是进度不一。
了解主流浏览器支持 Houdini 的进度:
https://ishoudinireadyyet.com/
我们在 Flutter 的 Web 版中对 CSS Paint API 进行了实验性的支持,并且已经取得了良好的结果,特别是在性能表现方面。我们将 paint 命令序列化 / 转义为自定义的 CSS 属性,paint worklet 会读取这些命令并执行它们。在文本渲染时,我们则使用普通的 <p> 和 <span> HTML 元素。
我们当前的序列化机制不是特别高效,它只是一个转换为 JSON 的嵌套树状结构,但是 Houdini 项目的目标就包括添加对类型化数组的支持。一旦这个支持得以实现,我们会将绘制命令编码为类型化数组,而不是 JSON 字符串。类型化数组支持 Transferable 接口,这意味着它们可以通过引用直接从主 isolate 传递到 paint worklet,这个过程就不会涉及内存内容的复制,资源开销会更小。
互操作进展
从 Flutter 调用 Dart 库
Flutter Web 应用可以完全访问目前在 Web 上运行的一切现有 Dart 库。
从 Flutter 调用 JavaScript 库
Flutter Web 应用完全支持 Dart 的 JS-interop 软件包: package:js 和 dart:js。
在 Flutter Web 应用中使用 CSS
目前,Flutter 可以完全处理网页内容的正确渲染并保证性能。例如,我们只使用遵循某些性能指南的一小部分 CSS (如 https://csstriggers.com/ 所列)。在页面内容中任意加入其他 CSS 可能会导致 Flutter 产生不可预测的表现。
在 Flutter for Web 应用中避免使用 CSS 的另一个原因是,Flutter 需要在渲染框架中的所有内容时了解所有布局属性。CSS 在里面就充当了黑盒子的角色。例如,如果要显示可滚动的 widget 列表,则必须实例化并为所有 widget 生成 HTML ,并赋上必要的 CSS 属性 (例如,flex-direction row 和 overflow:scroll)。然后浏览器将所有内容渲染到屏幕上。应用自身的代码不参与布局过程。
最后,为了保持 Flutter 代码的跨平台可移植性,我们避免使用 CSS,这样我们就能够在 Android 和 iOS 设备本地运行相同的代码。
将 Flutter 嵌入现有的 Web 应用中
我们还没有为此添加适当的支持,但我们打算在将来进行探索。我们正在考虑的几种方法包括 <iframe> 和 shadow DOM。
在 Flutter 中嵌入非 Flutter 组件
在 Flutter Web 应用中嵌入非 Flutter 组件,这种操作我们还没有进行支持——包括自定义元素、React 组件、Angular 组件的支持——但我们打算在将来考虑这种操作。一种可能的途径是,使用 PlatformView 将外来内容放入 Flutter Web 应用中。需要考虑的一个重要方面是,外来内容可能会对应用的性能和正确性产生哪些影响。
了解 addPlatformView 方法:
https://master-docs-flutter-io.firebaseapp.com/flutter/dart-ui/SceneBuilder/addPlatformView.html
因为非 Flutter 组件可能包含 CSS,如上所述,它同样可能会导致问题。这也需要我们进行进一步的研究。
可移植性
我们的目标是尽可能多地将框架移植到 Web 上。但是,这并不意味着任何 Flutter 应用都能无需更改代码就在 Web 上运行。Flutter Web 应用仍然是一个 Web 应用,它在浏览器中被沙箱化,只能执行 Web 浏览器允许的操作。例如,如果您的 Flutter 应用使用到了没有 Web 实现的本地插件 (例如 ARCore),它自然无法在 Web 上运行。同样,它也不会直接访问文件系统或更底层的网络权限。
当前项目的进展
我们构建了足够完整的 Web 引擎来渲染大部分 Flutter Gallery。我们还没有移植 Cupertino widget,但所有 Material widget,Material Theming,以及 Shrine 和 Contact Profile 演示应用都已经能够在 Web 上运行,请参看如下视频:
如何获得源代码?
我们计划在近期开源这个项目,并且很乐意与开源社区分享。该项目最初是作为 Google 内部源代码库的一个探索项目而启动的。在我们的代码稳定后,我们打算将开发工作转移到 GitHub。所以在现阶段,如果您在 github.com/flutter 下看到与 Web 相关的 pull 请求,请不要感到惊讶。
希望这篇文章能帮助您了解我们正在努力解决的问题: 让 Flutter 在 Web 上运行良好。我们很希望在这项工程中聆听您的想法和创意,欢迎在评论区和我们进行交流分享。
另外,请关注 Google I/O 2019 !
推荐阅读