Flutter Web 在美团外卖的实践 | 开发者说·DTalk
The following article is from 美团技术团队 Author 典胜 凌霄 海阔
在多形态业务场景下,如何保障多端体验的一致性,是前端技术领域一个比较受关注的方向。美团外卖前端技术团队基于 Flutter Web 探索跨端 (App\PC\H5) 的解决方案,真正实现 "Write Once & Run AnyWhere"。本文系该团队的实践经验总结,希望能对大家有所帮助或者启发。
一、背景
1.1 业务背景
1.1.1 保障多端体验一致性
由于端能力的不同,导致了业务在 App 和 Web 上存在较大的表现差异,例如: App 上自带动画转场,而在 Web 中的实现成本却较高,往往也就降级舍弃了这部分功能。此外,即使我们可利用公司内部的 Roo、MTDUI 等多端 UI 组件库来尽量抹平各端的 UI 差异,但由于组件库在各端的实现不尽相同,很难做到完美的一致性体验。
1.1.2 提升多端迭代效率
由于各端技术体系的不同,涉及多端的需求往往需要不同的开发、测试团队各自完成开发、联调、测试、上线等流程,占用资源巨大,在各团队不可并行支持的情况下,甚至可能导致整个业务交付周期被拉长。虽然 React Native、Flutter 等跨平台方案解决了一部分复用的问题,但显然在商家端业务场景下是远远不够的,我们的目标是要达到全平台 (Android、iOS、PC、H5) 复用,最大化地提升多端的迭代效率。
1.2 技术背景
1.2.1 Flutter 在美团外卖商家端的储备
MTFlutter 是美团外卖搭建起的公司级 Flutter 研发生态,它的架构图如下图所示:
1.2.2 Flutter Web 的支持
2018 年 Google 首次公开 Flutter Web Beta 版,旨在进一步实现一份代码、多端运行的愿景。目前,Flutter Web 已被正式合入 Master,期间经过无数工程师的努力,Flutter Web 已能提供与 Flutter Natvie 较统一的交互行为和视觉体验。
dart2js
https://dart.dev/tools/dart2js
二、面临的挑战
综上所述,我们基于 Flutter Web 探索跨端 (App\PC\H5) 解决方案,真正实 "Write Once & Run AnyWhere"。当然,面临挑战也是巨大的,主要体现在 Flutter 和 MTFlutter 现阶段对 Web 支持还不是很充足。
2.1 Flutter Web 现状
Google 官方目前对 Flutter Web 的工作主要还集中在 dart:ui (Web) 的对齐,工程化和性能相关的事项做的还比较少,例如:
Flutter Web 构建产物较简陋,只是简单的输出 main.dart.js (1.1 M,未 Gzip) 和 图片等静态资源,缺少 JS 拆包、文件 Hash、资源上传 CDN 等优化工作,极大影响了页面的加载性能。 由于 Flutter Web 自身实现了一套页面滚动机制,页面滚动过程中,会频繁计算位置信息,引起滚动区域内容被重新创建,最终导致页面滚动性能较差。
2.2 MTFlutter 现状
虽然 MTFlutter 做了诸多 Flutter Native 层面的定制与优化,但在 Flutter Web 上的建设才刚起步,具体表现在:
MTFlutter 现有的基础依赖如: Request (请求封装)、Router (路由)、埋点、容器桥、前端监控,尚未支持在 Web 中的实现。
MTFlutter 已实现了完整的 Flutter Module 的打包发布流程,但并不支持 Web 的构建与部署。
三、整体设计
扩展基础依赖 (如: Request、Router、埋点等) 在 Web 侧的支持。
完善工程化建设,例如: 静态资源优化、构建与部署自动化。
深入滚动性能与页面加载性能优化,使得 Flutter Web 能够满足基本的投产要求。
四、详细设计
4.1.1 Flutter Package 分平台编程
在 Flutter 中通过使用 Package 可以创建易于共享的模块化代码。官方强烈推荐使用 Package 形式管理各种工具方法。在官方定义中 Package 包含以下两种类别:
Dart Package: 用 Dart 编写的常规 Package,其中一些可能包含依赖于 Flutter 框架的特定功能,其使用范围仅限于 Flutter,例如 path。
Plugin Package: 用 Dart 编写 API 多个平台各自实现的特殊 Dart Package。Plugin Package 可以为 Android (使用 Kotlin 或 Java)、iOS (使用 Swift 或 Objective-)、Web、macOS、Windows 或 Linux 或其任意组合编写插件包。
path
https://pub.dev/packages/path
dart2js
https://dart.dev/tools/dart2js
dart:io https://api.dart.dev/stable/2.12.0/dart-io/dart-io-library.html
1. 代码级别分平台
针对代码级别的分平台,我们可以借助 Flutter SDK 提供的一个常量 kIsWeb。使用方法如下:
查看源码可知,kIsWeb 之所以能被用于判断 Web 平台,是利用了 JavaScript 不支持整型的特征,在 Web 环境下,Dart 的 double 和 int 由相同类型的对象支持,浮点数 "0.0" 等于整数 "0",对于在 AOT 或 VM 上运行的 Dart 代码却并非如此。
import 'package:flutter/foundation.dart';
if (kIsWeb) {
print('Web 端')
} else {
print('其他端');
}
2. 文件级别分平台
针对文件级别分平台,我们利用条件导入导出,其中条件导出具体用法如下:
// tool.dart
export 'src/tool_native.dart' // 兜底导出,即没有命中条件时导出的文件
if (dart.library.html) 'src/tool_web.dart'; // web 端导出的文件,该文件中可以使用 dart:html,也可以通过判断 dart.library.js 导出 Web 端文件。
// 引入 tool.dart
import 'package:tool/tool.dart';
void main() {
print('import tool');
}
package:js
https://pub.dev/packages/js
Federated Plugin
https://flutter.dev/docs/development/packages-and-plugins/developing-packages#federated-plugins
下图完整的展示了一个 Plugin 的整体架构:
4.1.2 基础依赖建设
整体来讲,MTFlutter 基础依赖都是使用 Plugin 的形式开发维护的。为处理依赖中的公共逻辑,提高 Plugin 的可扩展性,MTFlutter Plugin 在 Flutter Plugin 架构 (各平台原生实现层和 Plugin Interface 层) 之上又增加了公共逻辑处理层,最终暴露给用户是 Plugin API 层提供的接口。MTFlutter Plugin 架构图如下:
(1) 各平台实现能在 Web 侧对齐的场景,如埋点库
埋点库无论在 Native 端还是在 Web 端都是使用公司统一提供的 SDK,在 API 设计上具有天然的一致性,因此我们完全有能力在 Plugin Interface 层对齐所有接口,上层业务逻辑只需按需做些兼容处理即可。埋点库 Web 端扩展的整体设计思路如下:
在业务项目的 web/index.html 文件中直接引入 Script 脚本并且进行初始化 (注意: 引入 Script 的位置,需要放在 main.dart.js 前面)。
借助 package:js 库调用埋点 JS SDK,对齐 Flutter 埋点库的 API ,实现 Flutter Plugin 的 Web 端支持,详细架构图如下图所示:
package:js
https://pub.dev/packages/js
MTFlutter 路由库是 Native 底层维护的一套全新的路由体系,依靠原生支持提供了强大的定制化功能,而在 Web 端无法这些无法在各平台原生实现层达到 100% 支持。由于 MTFlutter Plugin 最终暴露的是 Plugin API,因此我们选择直接对齐 Plugin API 实现路由库在 Web 端的支持 (借助 Flutter Navigator、dart:html 用纯 Dart 语言完成了扩展),详细架构如下图所示:
4.2 性能优化
flutter build web
,导致我们无法直接进行更细粒度的个性化定制。如果想要让 Flutter Web 达到企业级应用的标准,我们需要更深层次的探索 Flutter SDK 的运行原理。下面我们列出目前遇到的性能问题及其解决方案。4.2.1 目前存在的性能问题
Google 官方对 Flutter Web 性能优化所做的事项还比较少,编译输出的页面存在较大的性能问题,主要体现在以下两方面:
首屏渲染时间长。即使使用了 FutureBuilder 把业务代码拆分成 xxx.part.js 之后,main.dart.js 体积依然维持在 1.1M。单一文件加载、解析时间过长,且静态资源缺少 CDN 化的支持,势必会影响首屏的渲染时间。
滚动性能较差。Flutter Web 自身实现了一套页面滚动机制,在页面滚动过程中,会频繁的创建 Canvas,最终导致滚动性能问题,甚至引起页面 Crash。
通过下图对浏览器网络监控情况的展示,可以清晰的反映出以上问题:
为了解决上述的性能问题,我们探索了 Flutter SDK 编译过程,总结出从 Flutter 业务代码到 Web 产物的整体流程,详细流程如下图所示:
4.2.2 加载性能优化
运行flutter build web
命令之后,我们得到的主要静态资源有: 主文件 main.dart.js (1.1M),各页面的业务代码 xxx.part.js (使用 FutureBuilder 后)、图片文件。直接应用这些资源到项目中,会遇到以下问题:
功能无法及时更新: 浏览器对同名文件的缓存,可能导致程序代码不被及时更新或者出现执行错乱。
首屏渲染性能差: main.dart.js 文件过大,单一文件加载、解析时间过长,势必会影响首屏的渲染时间。
无法使用 CDN: Flutter 仅支持相对路径的加载方式,无法使用当前域名以外的 CDN 域名,导致无法享受 CDN 带来的优势。
为此,在加载部分我们对 Flutter SDK 增加了如下三方面的优化,以达到线上运行的标准,优化步骤如下图所示:
遍历产物目录,并建立 ResourceMap。 分别计算每个文件的 Hash 值。 为新文件命名为 name-[hash].xxx。 修改新文件名在对应文件中的引用关系。
2. 大文件分片
Flutter Web 编译之后会生成 main.dart.js 这一主文件,体积为1.1M (Gzip 之后约 400K),这给页面的加载性能带来很大的影响。为此,我们对代码进行分片,借助浏览器对多文件并行加载的特性,可以有效提升页面的加载性能。
main.dart.js
在 Dart 侧拆分成多份纯文本文件,前端通过 XHR 的方式并行加载并按顺序拼接成 Javascript 代码置于 <script> 标签中,从而实现分片文件的并行加载。图片处理: 经过对源码的大量阅读及梳理,我们发现图片请求的 URL 首先会读取
meta
标签中assetBase
值进行 URL 路径拼接,根据拼接好的 URL 来获取资源。目前,在项目web/index.html
模板文件中并没有meta
标签,于是就会根据相对路径进行请求。解决方案是在编译过程中,根据请求环境增加meta
标签并把content
设置为 CDN 路径。JavaScript 处理: 为了解决图片资源文件的加载问题,我们虽然增加了
assetBase
的meta
标签,但发现xxx.part.js
文件依然使用当前域名进行加载,可见 Javascript 资源的加载和图片资源加载的逻辑不尽相同。对main.dart.js
源码分析,我们发现请求xxx.part.js
的域名取决于包含main.dart.js
内容的Script
标签的src
属性。通过对js_helper.dart
的动态编译,我们把读取src
属性修改为读取window.assetBase
这一全局变量 (meta
标签中assetBase
值加工后的变量) 来实现xxx.part.js
文件的 CDN 加载。
4.2.3 滚动性能优化
当页面出现可滚动区域时,每次页面滚动会创建大量的 Canvas。使用 Safari 的 Canvas 分析工具,我们发现问题的根本原因是页面滚动的过程中,Flutter 会频繁的创建滚动区域的 Canvas,每次创建的 Canvas 内存都在 10~70M 不等,滚动的内容越多,内存的占用就会越大,这样滚动几帧之后,内存的占用就会超过浏览器的阈值。
4.3 构建与部署
4.3.1 Docker 镜像定制
由于 MTFlutter Web 环境安装步骤较固定,且整个安装过程耗时较长 ( > 80s )。因此将其定制为 Docker 镜像并集成至 Talos,Flutter Web 编译阶段便能免去安装流程,有效提升构建效率。Docker 镜像定制和发布的详细流程见官方文档,本文不再赘述。其中用于定制 Flutter Web 镜像的 Dockerfile 文件如下:
FROM $BaseImage \# 继承基础镜像
RUN apt-get update
RUN apt-get install rubygems -y
RUN gem install flutter-cli
RUN flutter-cli install
ENV PATH="/$User/.flutter_sdk/bin:${PATH}"
ENV PUB\_HOSTED\_URL="https://xxx.com" \# 私有pub服务
ENV FLUTTER\_STORAGE\_BASE_URL="https://storage.flutter-io.cn"
RUN ~/.flutter_sdk/bin/flutter config --enable-web
官方文档 https://www.docker.com/
4.3.2 持续交付与部署
可以看到,流水线中已经免去了 MTFlutter Web 环境的安装流程,现有流水线中重要节点介绍如下:
Flutter-Web-Build 利用 Docker 内置的 MTFlutter 进行 Web 编译。
Flutter-Web-Publish 负责将编译产物上传美团资源存储服务器。
五、成果展示
5.1 效果展示
我们在美团外卖商家学院 (一个以文章、视频等形式帮助商家学习外卖运营知识、了解行业发展和平台策略的平台,它有很强的传播属性,具有外部投放的场景) 率先落地了 Flutter Web,现以商家学院视频内容页为例,对比 Flutter Native 和 Flutter Web 的展现效果:
可以看出,两者的交互、视觉体验是高度一致的,既保证了业务在 App 内接近 Native 的体验,又极大提高了 Web 与 Flutter Native 的体验一致性。
5.2 页面加载性能
如前文所述,我们实施了一系列针对 Flutter Web 的资源优化手段,使得页面加载性能有较大提升,其中页面完全加载时间大致由 1300ms (TP50) 降到了 580ms (TP50),更多的性能指标数据见下图:
5.4 业务迭代效率
六、总结与展望
综上所述,美团外卖商家端多元的业务形态和足够的技术 "储备",使得基于 Flutter 实现多端复用成为了可能。而 Flutter Web 在美团外卖商家学院业务中也取得了阶段性的成果,实现了 App、H5 侧的体验一致性,为后续推动更多业务线实现 App-Web 一体化打下了坚实的基础。
可以预见的是,基于 Flutter Web 实现的多端复用,势必会有效缩短项目交付周期。但由于我们对页面加载性能、滚动性能做的仍不够完美,不足以应对更加复杂的业务场景,因此我们依然还有许多工作:
页面滚动性能优化:由于 Flutter 与 Web 的布局差异,使得 dart:ui (Web) 也受 Flutter Native 的布局约束,如何打破这样的约束,是解决滚动性能问题的关键。 页面加载性能优化:当前的页面加载性能仍有较大优化空间,需要对 Flutter 进行编译干预与优化 (如按需分离 main.dart.js),减小资源包大小,有效提升页面加载性能。 Flutter Web 基建: 完善并优化开发、调试、编译、构建、部署链路,使得新老项目能快速接入 Flutter Web。 Flutter Web 在 PC 侧的复用: 与 UED 团队共同制订 PC 与 App 适配规范,同时基于 Dart2js 和 dart:ui (Web) 的强大能力,实现逻辑的抽象,完成组件、模块的适配,达到提效最大化; 跟进 Flutter 官方动向: Flutter 2.0 的发布,稳定了对 Web 的支持,同时默认采用 Canvaskit 编译模式,此模式下对页面滚动性能有较大提升。但由于 canvaskit.wasm 文件过于庞大 (2.5M),降低了加载性能,因此目前仍不建议在 Web 侧直接使用 Canvaskit。不过官方承诺会在 2021 年对性能进行整体优化,还是值得期待的,我们也将保持跟进和沟通。
长按右侧二维码
查看更多开发者精彩分享
"开发者说·DTalk" 面向中国开发者们征集 Google