FlutterWeb性能优化探索与实践
一、背景
1.1 关于FlutterWeb
时间回拨到 2018 年,Google 首次公开 FlutterWeb Beta 版,表露出要实现一份代码、多端运行的愿景。经过无数工程师两年多的努力,在今年年初(2021 年 3 月份),Flutter 2.0 正式对外发布,它将 FlutterWeb 功能并入了 Stable Channel,意味着 Google 更加坚定了多端复用的决心。


1.2 业务现状
美团外卖商家端以 App、PC 等多元化的形态为商家提供了订单管理、商品维护、顾客评价、外卖课堂等一系列服务,且 App、PC 双端业务功能基本对齐。此外,我们还在 PC 上特供了针对连锁商家的多店管理功能。同时,为满足平台运营诉求,部分业务具有外投 H5 场景,例如美团外卖商家课堂,它是一个以文章、视频等形式帮助商家学习外卖运营知识、了解行业发展和跟进经营策略的内容平台,具有较强的传播属性,因此我们提供了站外分享的能力。
二、挑战
不过,想要突破 FlutterWeb 页面加载的性能瓶颈,我们面临的挑战也是巨大的。这主要体现在 FlutterWeb 缺失静态资源的优化策略,以及复杂的架构设计和编译流程。下图展示了 Flutter 业务代码被转换成 Web 平台产物的流程,我们来具体进行分析:
- Framework、Flutter_Web_SDK (Flutter_Web_SDK 基于 HTML、Canvas,承载 HTML Render 模式的具体实现)等底层 SDK 是可被业务代码直接引入的,帮助我们快速开发出跨端应用;
- flutter_tools 是各平台(Android、iOS、Web)的编译入口,它接收 flutter build web 命令和参数并开始编译流程,同时等待处理结果回调,在回调中我们可对编译产物进行二次加工;
- frontend_server 负责将 Dart 转换为 AST,生成 kernel 中间产物 app.dill 文件(实际上各平台的编译过程都会生成这样的中间产物),并交由各平台 Compiler 进行转译;
- Dart2JS Compiler 是 Dart-SDK 中具体负责转译 JS 的模块,它将上述中间产物 app.dill 进行读取和解析,并注入 Math、List、Map 等 JS 工具方法,最终生产出 Web 平台所能执行的 JS 文件。
- 编译产物主要为 main.dart.js、index.html、images 等静态资源,FlutterWeb 对这些静态资源缺少常规 Web 项目中的优化手段,例如:文件 Hash 化、文件分片、CDN 支持等。
三、整体设计
如前文所述,为了实现逻辑、渲染跨平台,Flutter 的架构设计及编译流程都具有一定的复杂性。但由于各平台(Android、iOS、Web)的具体实现是解耦的,因此我们的思路是定位各模块(Dart-SDK、Framework、Flutter_Web_SDK、flutter_tools)的 Web 平台实现并寻求优化,整体设计图如下所示:
- SDK 瘦身 :我们分别对 FlutterWeb 所依赖的 Dart-SDK、Framework、Flutter_Web_SDK 进行了瘦身,并将这些精简版 SDK 集成合入 CI/CD(持续集成与部署)系统,为减小产物包体积奠定了基础;
- 编译优化 :此外,我们在 flutter_tools 中的编译流程做了干预,分别进行了 JS 文件分片、静态资源 Hash 化、资源文件上传 CDN 等优化,使得这些在常规 Web 应用中基础的性能优化手段得以在 FlutterWeb 中落地。同时加强了 FlutterWeb 特殊场景下的资源优化,如:字体图标精简、Runtime Manifest 隔离、Mobile/PC 分平台打包等;
- 加载优化 :在编译阶段进行静态资源优化后,我们在前端运行时,支持了资源预加载与按需加载,通过设定合理的加载时机,从而减小初始代码体积,提升页面首屏的渲染速度。
四、设计与实践
4.1 精简 SDK
4.1.1 包体积分析
工欲善其事,必先利其器,在开始做体积裁剪之前,我们需要一套类似于 webpack-bundle-analyzer 的包体积分析工具,便于直观地比较各个模块的体积占比,为优化性能提供帮助。 Dart2JS 官方提供了 –dump-info 命令选项来分析 JS 产物,但其表现差强人意,它并不能很好地分析各个模块的体积占比。这里更推荐使用 source-map-explorer ,它的原理是通过 sourcemap 文件进行反解,能清晰地反映出每个模块的占用大小,为 SDK 的精简提供了指引。下图展示了 FlutterWeb JS 产物的反解信息(截图仅包含 Framework 和 Flutter_Web_SDK):
4.1.2 SDK 裁剪
FlutterWeb 依赖的 SDK 主要包括 Dart-SDK、Framework 和 Flutter_Web_SDK,这些 SDK 对包体积的影响是巨大的,几乎贡献了初始化包的所有大小。虽然在 Release 模式下的编译流程中,Dart Compiler 会利用 Tree-Shaking 来剔除那些引入但未使用的 packages、classes、functions 等,很大程度上减少了包体积。但这些 SDK 中仍然存在一些能被进一步优化的代码。 以 Flutter Framework 为例,由于它是全平台公用的模块,因此不可避免地存在各平台的兼容逻辑(通常以 if-else、switch 等条件判断形式出现),而这部分代码是不能被 Tree-Shaking 剔除的,我们观察如下的代码:// FileName: flutter/lib/src/rendering/editable.dart
void _handleKeyEvent(RawKeyEvent keyEvent) {
if (kIsWeb) {
// On web platform, we should ignore the key.
return;
}
// Other codes ...
}
上述代码选自 Framework 中的 RenderEditable 类,当 kIsWeb 变量为真,表示当前应用运行在 Web 平台。受限于 Tree-Shaking 的机制原理,上述代码中,其它平台的兼容逻辑即注释 Other codes 的部分是无法被剔除的,但这部分代码,对 Web 平台来说却是 Dead Code(永远不可能被执行到的代码),是可以被进一步优化的。


4.1.3 SDK 集成 CI/CD
为了提升构建效率,我们将 FlutterWeb 依赖的环境定制为 Docker 镜像,集成入 CI/CD(持续集成与部署)系统。SDK 裁剪后,我们需要更新 Docker 镜像,整个过程耗时较长且不够灵活。因此,我们将 Dart-SDK、Framework、Flutter_Web_SDK 按版本打包传至云端,在编译开始前读取 CI/CD 环境变量:sdk_version(SDK 版本号),远程拉取相应版本的 SDK 包,并替换当前 Docker 环境中的对应模块,基于以此方案实现 SDK 的灵活发布,具体流程图如下图所示:
4.2 JS 分片
FlutterWeb 编译之后默认会生成 main.dart.js 文件,它囊括了 SDK 代码以及业务逻辑,这样会引起以下问题:- 功能无法及时更新 :为了实现浏览器的缓存优化,我们的项目开启了对静态资源的强缓存,若 main.dart.js 产物不支持 Hash 命名,可能导致程序代码不能被及时更新;
- 无法使用 CDN :FlutterWeb 默认仅支持相对域名的资源加载方式,无法使用当前域名以外的 CDN 域名,导致无法享受 CDN 带来的优势;
- 首屏渲染性能不佳 :虽然我们进行了 SDK 瘦身,但 main.dart.js 文件依然维持在 0.7M 以上,单一文件加载、解析时间过长,势必会影响首屏的渲染时间。
4.2.1 Lazy Loading
Flutter 官方提供deferred as
关键字来实现 Widget 的懒加载,而 dart2js 在编译过程中可以将懒加载的 Widget 进行按需打包,这样的拆包机制叫做 Lazy Loading。借助 Lazy Loading,我们可以在路由表中使用 deferred 引入各个路由(页面),以此来达到业务代码拆离的目的,具体使用方法和效果如下所示:
// 使用方式
import 'pages/index/index.dart' deferred as IndexPageDefer;
{
'/index': (context) => FutureBuilder(
future: IndexPageDefer.loadLibrary(),
builder: (context, snapshot) => IndexPageDefer.Demo(),
)
... ...
}

4.2.2 Runtime Manifest抽离
通过对业务代码的抽离,此时 main.dart.js 文件的构成主要包含 SDK 和 Runtime Manifest:

4.2.3 main.dart.js 切片
经过以上引入 Lazy Loading、Runtime Manifest 抽离,main.dart.js 文件的体积稳定在 0.7M 左右,浏览器对大体积单文件的加载,会有很沉重的网络负担,所以我们设计了切片方案,充分地利用浏览器对多文件并行加载的特性,提升文件的加载效率。 具体实现方案为:将 main.dart.js 在 flutter_tools 编译过程拆分成多份纯文本文件,前端通过 XHR 的方式并行加载并按顺序拼接成 JavaScript 代码置于 < script > 标签中,从而实现切片文件的并行加载。
4.3 预加载方案
如上一节所述,虽然我们做了很多工作来稳定 main.dart.js 的内容,但在 Flutter Tree-Shaking 的运行机制下,各个项目引用不同的 Framework Widget,就会导致每个项目生成的 main.dart.js 内容不一致。随着接入 FlutterWeb 的项目越来越多,每个业务的页面互访概率也越来越高,我们的期望是当访问 A 业务时,可以预先缓存 B 业务引用的 main.dart.js,这样当用户真正进入 B 业务时就可以节省加载资源的时间,下面为详细的技术方案。4.3.1 技术方案
我们把整体的技术方案分为编译、监听、运行三个阶段。- 编译阶段,在发布流水线上根据前期定制的匹配规则,筛选出符合条件的资源文件路径,生成云端 JSON 并上传;
- 监听阶段,在 DOMContentLoaded 之后,对网络资源、事件、DOM 变动进行监听,并对监听结果根据特定规则进行分析加权,得到一个首屏加载完成的状态标识;
- 运行阶段,在首屏加载完成之后对配置平台下发的云端 JSON 文件进行解析,对符合配置规则的资源进行 HTTP XHR 预加载,从而实现文件的预缓存功能。


- 第一部分:根据不同的发布环境,初始化线上/线下的配置平台,为配置文件的读写做好准备;
- 第二部分:下载并解析配置平台下发的资源组 JSON,筛选出符合配置规则的资源路径,更新 JSON 文件并发布到配置平台;
- 第三部分:通过发布流水线提供的 API,把 PROJECT_ID、发布环境注入HTML文件中,为运行阶段提供全局变量以便读取。

- 第一部分是监听 DOM 的变化。这部分主要是在页面发生 Ajax 请求之后,随着MV模式的变动,DOM 也会随之发生变化。我们使用浏览器提供的 MutationObserver API 对 DOM 变化进行收集,并筛选有效节点进行深度优先遍历,计算每个 DOM 的递归权重值,低于阈值我们就认为首屏已加载完成。
- 第二部分是监听资源的变化。我们利用浏览提供的 PerformanceObserver API,筛选出 img/script 类型的资源,在 3 秒内收集的资源没有增加时,我们认为首屏已加载完成。
- 第三部分是监听 Event 事件。当用户发生 click、wheel、touchmove 等交互行为时,我们就认为当前页面处于一个可交互的状态,即首屏加载已完成,这样会在后续进行资源的预缓存。

4.3.2 效果展示与数据对比
当有页面间互访问命中预缓存时,浏览器会以 200(Disk Cache)的方式返回数据,这样就节省了大量资源加载的时间,下图为命中缓存后资源加载情况:

4.4 分平台打包
如前文所述,美团外卖商家业务大部分都是双端对齐的。为了实现提效的最大化,我们对 FlutterWeb 的多平台适配能力进行加强,实现了 FlutterWeb 在 PC 侧的复用。 在 PC 适配过程中,我们不可避免地需要书写双端的兼容代码,如:为了实现在列表页面中对卡片组件的复用。为此我们开发了一个适配工具 ResponsiveSystem,分别传入 PC 和 App 的各端实现,内部会区分平台完成适配:// ResponsiveSystem 使用举例
Container(
child: ResponsiveSystem(
app: AppWidget(),
pc: PCWidget(),
),
)
上述代码能较方便的实现 PC 和 App 适配,但 AppWidget 或 PCWidget 在编译过程中都将无法被 Tree-Shaking 去除,因此会影响包体积大小。对此,我们将编译流程进行优化,设计分平台打包方案:

- 修改 flutter-cli,使其支持 –responsiveSystem 命令行参数;
- 我们在 flutter_tools 中的 AST 分析阶段增加了额外的处理:ResponsiveSystem 关键字的匹配,同时结合编译平台(PC 或 Mobile)来进行 AST 节点的改写;
- 去除无用 AST 节点后,生成各个平台的代码快照(每份快照仅包含单独平台代码);
- 根据代码快照编译生成 PC 和 App 两套 JS 产物,并进行资源隔离。而对于 images、fonts 等公用资源,我们将其打入 common 目录。

4.5 图标字体精简
当访问 FlutterWeb 页面时,即使在业务代码中并未使用 Icon 图标,也会加载一个 920KB 的图标字体文件:MaterialIcons-Regular.woff。通过探究,我们发现是 Flutter Framework 中一些系统 UI 组件(如:CalendarDatePicker、PaginatedDataTable、PopupMenuButton 等)使用到了 Icon 图标导致,且 Flutter 为了便于开发者使用,提供了全量的 Icon 图标字体文件。 Flutter 官方提供的--tree-shake-icons
命令选项是将业务使用到的 Icon 与 Flutter 内部维护的一个缩小版字体文件(大约 690KB)进行合并,能一定程度上减小字体文件大小。而我们需要的是只打包业务使用的 Icon,所以我们对官方 tree-shake-icons
进行了优化,设计了 Icon 的按需打包方案:

- 扫描全部业务代码以及依赖的 Plugins、Packages、Flutter Framework,分析出所有用到的 Icon;
- 把扫描到的所有 Icon 与 material/icons.dart(该文件包含 Flutter Icon 的 unicode 编码集合)进行对比,得到精简后的图标编码列表:iconStrList;
- 使用 FontTools 工具把 iconStrList 生成字体文件 .woff,此时的字体文件仅包含真正使用到的 Icon。

五、总结与展望
综上所述,我们基于 HTML Render 模式对 FlutterWeb 性能优化进行了探索和实践,主要包括 SDK(Dart-SDK、Framework、Flutter_Web_SDK)的精简,静态资源产物优化(例如:JS 分片、文件 Hash、字体图标文件精简、分平台打包等)和前端资源加载优化(预加载与按需请求)。 最终使得 JS 产物由 1.2M 减少至 0.7M(非业务代码),页面完全加载时间 TP90 线由 6s 降到了 3s ,这样的结果已能满足美团外卖商家端的大部分业务要求。而未来的规划将聚焦于以下3个方向:- 降低 Web 端适配成本 :目前已有 9+ 个业务借助 MTFlutterWeb 实现多端复用,但在 Web 侧(尤其是 PC 侧)的适配效率依然有优化空间,目标是将适配成本降低到 10% 以下(目前大约是 20% );
- 构建 FlutterWeb 容灾体系 :Flutter 动态化包有一定的加载失败概率,而 FlutterWeb 作为兜底方案,能提升整体业务的加载成功率。此外 FlutterWeb 可以提供“免安装更新”的能力,降低 FlutterNative 老旧历史版本的维护成本;
- 性能优化的持续推进 :性能优化的阶段性成果为 MTFlutterWeb 的应用推广巩固了基础,但依然是有进一步优化空间的,例如:目前我们仅将业务代码和 Runtime Manifest 进行了拆离,而 Framework 及 三方包在一定程度上也影响到了浏览器缓存的命中率,将这部分代码进行抽离,可进一步提升页面加载性能。
本文是原创文章,采用 CC BY-NC-ND 4.0 协议,完整转载请注明来自 程序员小航
评论
匿名评论
隐私政策
你无需删除空行,直接评论以获取最佳展示效果