Flutter Web究竟有什么不同之处


Flutter Web究竟有什么不同之处

时间:2025-03-13  作者:Diven  阅读:0

 

 Flutter Web 稳定版本发布至今也有一年多了,经过这一年多的发展,今天就让我们来看看 Flutter Web 究竟有什么不同之处,本篇分享主要内容是目前 Flutter 下少有较为全面的 Web 内容。  

一、起源与实现

 说起 Flutter 的起源就很有意思,大家都知道早期 Flutter 最先支持的平台是 Android 和 iOS,至今最核心的维护平台依然是 Android 和 iOS,但是事实上 Flutter 其实起源于前端团队。 

Flutter 来源于前端 Chrome 团队,起初 Flutter 的创始人和整个团队几乎都是来自 Web,在 Flutter 负责人 ErIC 的相关访谈中,Eric 表示 Flutter 来自 Chrome 内部的一个实验,他们把一些乱七八糟的 Web 规范去掉后,在一些内部基准测试的性能居然能提升 20 倍,因此 Google 内部就开始立项,所以 Flutter 出现了。

 另外前端的同学应该知道,Dart 起初也是为了 Web 而生,事实上 Dart 诞生至今也有 10 年了,所以可以说 Flutter 其实充满了 Web 的基因。 但是作为从 Web 里诞生的框架,和 React Native/ Weex 不同的是,前者是先有了 Web 下的 React 和 Vue 实现之后才有的客户端支持,而对于 Flutter 则是反过来,先有客户端实现之后才支持 Web 平台,这里其实可以和 Weex 做个简单对照。 Weex 作为曾经闪耀过的跨平台框架,它同样支持 Android、iOS 和 Web 三个平台,在 Android 和 iOS 上 Weex 和 React Native 差异性不大,在 Web 上 Weex 则是删减版的 Vue 支持,而由于 API 和平台差异性的问题,Weex 在 Web 上的支持体验一直不是很好: 

 

因为 Weex 需要依赖平台控件实现渲染,导致一个 Text 控件需要兼顾 Android、iOS 和 Web 上原生平台接口的逻辑,从而出现各种由于耦合带来的兼容性问题。

 

而 Flutter 实现更为特别,通过 Skia 实现了独立的渲染引擎之后,在 Android 和 iOS 上控件几乎就与平台无关,所以 Flutter 上的控件可以做到独立且不同平台上渲染一致的效果。

但是回到 Web 上又有些特殊,首先 Web 平台完全是 html / js / css 的天下,并且 Web 平台需要同时兼顾 PC 和 Mobile 的不同环境,这就让 Flutter Web 成了 Flutter 所有平台里 "最另类又奇葩" 的落地。

 

 

首先 Flutter Web 和其他 Flutter 平台一样共用一套 Framework,理论上绝大多数的控件实现都是通用的,当然如果要说最不兼容的 API 对象,那肯定就是 Canvas 了,这其实和 Flutter Web 特殊的实现有关系,后面我们会聊到这个问题。

 

而由于 Web 的特殊场景,Flutter Web 在 "几经周折" 之后落地了两种不同的渲染逻辑: html 和 canvaskit,它们的不同之处在于: 

  • html
  • 好处: html 的实现更轻量级,渲染实现基本依赖于 Web 平台的各种 HTMLElement,特别是 Flutter Web 下定义的各种 实现,可以说它更贴近现在的 Web 环境,所以有时候我们也称呼它为 DomCanvas,当然随着 Flutter Web 的发展这个称呼也发生了一些变化,后续我们会详细讲到这个。
  • 问题: html 的问题也在于太过于贴近 Web 平台,这就和 Weex 一样,贴近平台也就是耦合于平台,事实上 DomCanvas 实现理念其实和 Flutter 并不贴切,也导致了 Flutter Web 的一些渲染效果在 html 模式下存在兼容问题,特别是 Canvas 的 API。 
  • canvaskit
  • 好处: canvaskit 的实现可以说是更贴近 Flutter 理念,因为它其实就是 Skia + WebAssembly 的实现逻辑,能和其他平台的实现更一致,性能更好,比如滚动列表的渲染流畅度更高等。
  • 问题: 很明显使用 WebAssembly 带来的 wasm 文件会导致体积增大不少,Web 场景下其实很讲究加载速度,而在这方面 wasm 能优化的空间很小,并且 WebAssembly 在兼容上也是相对较差,另外 skia 还需要自带字体库等问题都挺让人头痛。 

默认情况下 Flutter Web 在打包渲染时会把 html 和 canvaskit 都打包进去,然后在 PC 端使用 canvaskit 模式,在 mobile 端使用 html 模式,当然您也可以在打包时通过 flutter build web --web-renderer html --release 之类的配置强行指定渲染模式。

 

既然这里我们讲到了 Flutter Web 的打包构建,那就让我们先从构建打包角度开始来深入介绍 Flutter Web。  

二、构建和优化

 

Flutter Web 虽说是和其他平台共用一个 framework,但是它在 dart 层开始就有一套自己特殊的 engine 实现,并且这套实现是独立于 framework 的一套特殊代码。

 

所以在 Flutter Web 打包时,会把默认的  /flutter/bin/cache/lib/_engine 变成了 flutter/bin/cache/flutter_web_sdk/lib/_engine 的相关实现,这是因为 Flutter Web 在 framework 之下的 engine 需要一套特殊的 API。

 

下图右侧构建是指定 web 的打包路径,和左边默认时的对比。

 

同样下图所示,可以看到 web sdk 里会有如 html、canvaskit 这样不同的实现,甚至会有一个特殊的 text 目录,这是因为在 web 上对于文本的支持是个十分复杂的问题。

那到这里我们知道了在 _engine 层面,Flutter Web 有着自己一套独立的实现,那构建之后的产物是什么样的情况呢?

 

如下图所示是 GSY 的一个简单的开源示例项目,在部署到服务器后可以看到,默认情况下在不做任何处理时,在 PC 端打开后会使用 canvaskit 渲染,主要会有: 
  • 2.3 MB 的 main.dart.js
  • 2.8 MB 的 canvaskit.wasm
  • 1.5 MB 的 MaterialIcons-Regular.otf
  • 284 kB 的 CupertinoIcons.ttf
 

 

可以看到这些文件占据了 Flutter Web 编译后产物的大部分体积,并且从大小上看确实让人有些无法接受,因为示例项目的代码量并不大,结构也不复杂,这样的体积肯定十分影响加载速度。

 

所以我们首先考虑在 html 和 canvaskit 两种渲染模式中先选定一种,出于实用性考虑,结合前面的对比情况,选用 html 渲染模式在兼容性和可优化上会更友好,所以这里优化的第一步就是先指定 html 模式作为渲染引擎。

 

开始优化

首先可以看到 CupertinoIcons.ttf 这个矢量图标文件,虽然默认创建项目时会通过 cupertino_icons 被添加到项目里,但是由于我们不需要使用,所以可以在 yaml 文件里去除。

 

之后通过运行 flutter build web --release --web-renderer html 后,可以看到使用 html 模式加载后的产物很干净,而需要优化的体积现在主要在 main.dart.js 和 MaterialIcons-Regular.otf 上。

 

 

虽然在项目中我们会使用到 MaterialIcons 的一些矢量图标,但是每次加载都要全量加载一个 1.5 MB 的字体库文件显然并不符合逻辑,所以在 Flutter 里官方提供了 --tree-shake-icons 的命令帮助我们优化这部分的内容。

 

但是不幸的是,如下图所示,在当前的 2.10 版本下该配置运行会有 bug,而不幸中的万幸是,在原生平台的编译中 shake-icons 行为是可以正常执行。

 

 

所以我们可以先运行 flutter build apk,然后通过如下命令,将 Android 上已经 shake-icons 的 MaterialIcons-Regular.otf 资源复制到已经编译好的 web/ 目录下。

  •  
cp -r ./build/app/intermediates/flutter/release/flutter_assets/ ./build/web/assets
 

再次打包后可以看到,经过优化后 MaterialIcons-Regular.otf 资源如今只剩下 3.2 kB,那接下来就是考虑针对 2.2 MB 的 main.dart.js 进行优化处理。

 

 

要优化 main.dart.js,我们就要讲到 Flutter 里的 deferred-components,在 Flutter 里可以通过把控件定义为 "deferred component" 来实现控件的懒加载,而这个行为在 Flutter Web 上被编译之后就会变成多个 *part.js 文本,原理上就是对 main.dart.js 进行拆包。

 

举个例子,首先我们定义一个普通的 Flutter 控件,按照正常的控件进行实现就可以。


import 'package:flutter/widgets.dart';class DeferredBox extends StatelessWidget { DeferredBox() {} @override Widget build(BuildContext context) { return Container( height: 30, width: 30, color: Colors.blue, ); }}
 

在需要的地方 import 对应控件然后添加 deferred as box 关键字,之后在适当时机通过 box.loadLibrary() 加载控件,最后通过 box.DeferredBox() 渲染。


import 'box.dart' deferred as box;class MainPage extends StatefulWidget { @override _MainPageState createState() => _MainPageState();}class _MainPageState extends State<MainPage> { @override void initState() { super.initState(); } @override Widget build(BuildContext context) { return FutureBuilder<void>( future: box.loadLibrary(), builder: (BuildContext context, AsyncSnapshot<void> snapshot) { if (snapshot.connectionState == ConnectionState.done) { if (snapshot.hasError) { return Text('Error: ${snapshot.error}'); } return box.DeferredBox(); } return CircularProgressIndicator(); }, ); }}
 

当然,这里还需要额外在 ymal 文件里添加 deferred-components 来制定对应的 libraries 路径。


deferred-components: - name: crane libraries: - package:gsy_flutter_demo/widget/box.dart
 

回归到上面的 GSY 示例项目中,通过相对极端的分包实现,这里把 GSY 示例里的每个页面都变成一个独立的懒加载页面,然后在页面跳转时再加载显示,最终打包部署后如下图所示: 

 

 

可以看到拆分之后 main.dart.js 从 2.2 MB 变成了 1.6 MB,而其他内容通过 deferred components 变成了各个 part.js 的独立文件,并且只在点击时才动态下载对应的 part.js 文件,但是此时的 main.dart.js 依旧不小,而官方提供的能力上已经没有太多优化的余地。

 

在这里可以通过前端的 source-map-explorer 工具去分析这个文件,首先在编译时要添加 --source-maps 命令,这样在打包时会生成 main.dart.js 的 source map 文件,然后就执行 source-map-explorer main.dart.js --no-border-checks  生成对应的分析图: 

 

 

这里只展示能够被 mapped 的部分,可以看到 700k 几乎就是 Flutter Web 整个 framewok + engine + vm 的大小,而这部分内容其实可以优化的空间并不大,尽管会有一些如 kIsWeb 的冗余代码,但是其实可以调整的内容并不多,大概有 36 处可以调整和删减的地方,实质上打包时 Flutter Web 也都有相应的优化压缩处理,所以这部分收益并不高。

 

 

另外,如下图所示是两种不同 web rendder 构建后代码上的差异,可以看到 html 和 canvaskit 单独构建后的 engine 代码结构差异性还是很大的。

 

 

而如果您在编译时默认的 auto 模式,就会看到 html 和 canvaskit 的代码都会打包进去,所以相对的 main.dart.js 也会增加一些。

 

 

那还有什么可以优化的地方吗?还是有的,通过外部手段,例如通过在部署时开启 gzip 或者 brotli 压缩,如下图所示,开始 gzip 后大概可以让 main.dart.js 下降到 400k 左右。

 

 

另外也有在 index.html 里增加 loading 效果来做等待加载过程的展示,例如: 


<html><head> <meta charset="UTF-8"> <title>gsy_flutter_demotitle> <style> .loading { display: flex; justify-content: center; align-items: center; margin: 0; position: ABSolute; top: 50%; left: 50%; -ms-transform: translate(-50%, -50%); transform: translate(-50%, -50%); } .loader { border: 16px solid #f3f3f3; border-radius: 50%; border: 15px solid ; border-top: 16px solid blue; border-right: 16px solid white; border-bottom: 16px solid blue; border-left: 16px solid white; width: 120px; height: 120px; -webkit-animation: spin 2s linear infinite; animation: spin 2s linear infinite; } @-webkit-keyframes spin { 0% { -webkit-transform: rotate(0deg); } 100% { -webkit-transform: rotate(360deg); } } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }style>head><body> <div class="loading"> <div class="loader">div> div> <script src="main.dart.js" type="application/javascript">script>