美团外卖商家端业务形态
美团外卖商家端业务围绕数百万商家,在 PC 和 App 上分别提供了交易履约、运营、广告、营销等一系列功能,且经常有外投 H5 的场景(如外卖学院、商家社区、营销活动等)。在这种多形态的业务场景下,如何保障多端体验的一致性,以及如何提升多端迭代的效率,一直是商家端产研关注的重点。
由于端能力的不同,导致了业务在 App 和 Web 上存在较大的表现差异,例如:App 上自带动画转场,而在 Web 中的实现成本却较高,往往也就降级舍弃了这部分功能。此外,即使我们可利用公司内部的 Roo、MTDUI 等多端 UI 组件库来尽量抹平各端的 UI 差异,但由于组件库在各端的实现不尽相同,很难做到完美的一致性体验。
由于各端技术体系的不同,涉及多端的需求往往需要不同的开发、测试团队各自完成开发、联调、测试、上线等流程,占用资源巨大,在各团队不可并行支持的情况下,甚至可能导致整个业务交付周期被拉长。虽然 React Native、Flutter 等跨平台方案解决了一部分复用的问题,但显然在商家端业务场景下是远远不够的,我们的目标是要达到全平台(Android、iOS、PC、H5)复用,最大化地提升多端的迭代效率。
MTFlutter 是美团外卖搭建起的公司级 Flutter 研发生态,它的架构图如下图所示:
MTFlutter 架构图
如图所示,MTFlutter 已涵盖研发、调试、测试、发布、线上运维及工程管理整套闭环,同时落地了动态化解决方案,支撑了公司多个业务发展。在大前端融合的趋势下,美团外卖商家端持续在探索更优的多端复用方案,通过 MTFlutter 生态的建设,目前 Flutter 技术栈已覆盖商家端 App 中 90%以上的业务,同时具备 Flutter 开发能力的同学也达到 90% 以上。因此,在有足够技术“储备”的前提下,我们能够基于 Flutter 做全平台(Android、iOS、PC、H5)复用的探索。
2018 年 Google 首次公开 Flutter Web Beta 版,旨在进一步实现一份代码、多端运行的愿景。目前,Flutter Web 已被正式合入 Master,期间经过无数工程师的努力,Flutter Web 已能提供与 Flutter Natvie 较统一的交互行为和视觉体验。
Flutter Native VS Flutter Web
如上图可知,Flutter Web 与 Flutter Native 的整体架构相似,二者共用 Framework 层(绿色部分),提供了包括动画、手势、基础 Widget 类,以及大部分应用所需的 Material/Cupertino 主题 Widget 集合。区别在于:Flutter Web 重写了 dart:ui 层(黄色部分),利用 DOM、Canvas 对齐了 Flutter Native 的 UI 渲染能力,使得 Flutter 编写的 UI 能够在现代浏览器上正常展示。
此外,得益于 dart2js 这个早已成熟的工具,Dart 逻辑能够很容易的转换为 JavaScript,进而在 Web 中被正常运行。
综上所述,我们基于 Flutter Web 探索跨端(App\PC\H5)解决方案,真正实现“Write Once & Run AnyWhere”。当然,面临挑战也是巨大的,主要体现在 Flutter 和 MTFlutter 现阶段对 Web 支持还不是很充足。
Google 官方目前对 Flutter Web 的工作主要还集中在 dart:ui(Web)的对齐,工程化和性能相关的事项做的还比较少,例如:
虽然 MTFlutter 做了诸多 Flutter Native 层面的定制与优化,但在 Flutter Web 上的建设才刚起步,具体表现在:
MTFlutter 架构图
上图为 MTFlutter + Web 架构图,由图可知 Flutter Web 页面要满足投产要求,还有大量的工作(上图黄色部分所示),主要包括:
企业级应用的基础开发依赖(如:请求库、路由库、埋点库等),要重新在 Flutter 中用 Dart 搭建一套,时间成本、兼容性、风险等都是不可控的。而 MTFlutter 是基于原有 Native 基础依赖开发的 Plugin,因此并不支持 Web 端。此章节将展开介绍如何丝滑无感地扩展 MTFlutter 基础依赖在 Web 端的实现。
在 Flutter 中通过使用 Package 可以创建易于共享的模块化代码。官方强烈推荐使用 Package 形式管理各种工具方法。在官方定义中 Package 包含以下两种类别:
下面分别对这两种类型 Package 中如何分平台编程进行介绍。
(1) Dart Package
Dart Package 是纯 Dart 编写,因此大部分代码均可由 dart2js 直接编译出 Web 平台可运行的代码,但某些涉及 Native 能力的库(如 dart:io)是无法被转译的,因此需要有对平台进行兼容的方法,下面介绍两种在 Dart Package 中分平台编程的方案。
代码级别分平台
针对代码级别的分平台,我们可以借助 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('其他端');
}
文件级别分平台
针对文件级别分平台,我们利用条件导入导出,其中条件导出具体用法如下:
// 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');
}
条件导入和条件导出类似,仅需将 export 改为 import 即可。在业务开发中这也是一种非常实用的分平台编程方法。
(2) Plugin Package
Plugin Package(下文简称为 Plugin)在 Android 和 iOS 平台都是通过 MethodChannel 实现在 UI 层和 Platform 层传递消息从而达到特定平台支持的,官方文档中也全方位介绍了在 Android 和 iOS 平台的具体实现方法及例子,Web 平台的实现却介绍的较少。总结起来,Web 平台和 Native 平台实现方式的不同主要集中在下面两点。
首先,Web Plugin 推荐的方式不是以其平台特有的 JS 语言实现,而是通过 Dart Library 或 Package 实现,对于已有现成可用的 JS SDK 或需要大量使用 JS 实现功能的情况下,官方提供了 package:js 包调用 Javascript,从而实现与 Javascript 的交互。
其次,Web Plugin 不是通过注册 MethodChannel 传递消息的,Flutter 内部可直接调用通过官方指定形式(Federated Plugin )编写的 Flutter Web Plugin 类。
下图完整的展示了一个 Plugin 的整体架构:
Flutter Plugin 架构图
整体来讲,MTFlutter 基础依赖都是使用 Plugin 的形式开发维护的。为处理依赖中的公共逻辑,提高 Plugin 的可扩展性,MTFlutter Plugin 在 Flutter Plugin 架构(各平台原生实现层和 Plugin Interface 层)之上又增加了公共逻辑处理层,最终暴露给用户是 Plugin API 层提供的接口。MTFlutter Plugin 架构图如下:
MTFlutter Plugin 架构图
在细节实现上,由于项目中各种依赖的类型之间存在着差异,因此在依赖处理上也略有不同,下面介绍拥有不同特点的依赖所对应解决方案。
(1)各平台实现能在 Web 侧对齐的场景,如埋点库
埋点库无论在 Native 端还是在 Web 端都是使用公司统一提供的 SDK,在 API 设计上具有天然的一致性,因此我们完全有能力在 Plugin Interface 层对齐所有接口,上层业务逻辑只需按需做些兼容处理即可。埋点库 Web 端扩展的整体设计思路如下:
埋点库架构图
(2)各平台实现在 Web 侧无法对齐的场景,如路由库
MTFlutter 路由库是 Native 底层维护的一套全新的路由体系,依靠原生支持提供了强大的定制化功能,而在 Web 端无法这些无法在各平台原生实现层达到 100% 支持。由于 MTFlutter Plugin 最终暴露的是 Plugin API,因此我们选择直接对齐 Plugin API 实现路由库在 Web 端的支持(借助 Flutter Navigator、dart:html 用纯 Dart 语言完成了扩展),详细架构如下图所示:
路由库架构图
(3)Web 端需要通过大量 JS 实现功能的依赖库,如请求库
由于在现有的 Web 请求中统一封装着大量的业务处理逻辑(如拦截器、异常上报等),如果用 Dart 重新实现一遍,成本还是较高的。想复用原有基于 Axios ( JS 请求库) 封装的请求库就相当于让 Plugin 的 Web 平台实现使用 JS 语言。Dart 和 JS 交互是通过 package:js 进行接口调用,因此我们在公共逻辑处理层用 Dart 对齐了相应的 API,详细架构图如下图所示:
请求库架构图
常规的 Web 项目中,为了保证页面有更好的加载和渲染性能,在静态资源文件的处理方面,我们需要做很多的工作,例如:资源文件 Hash 化、CDN 化、按需加载处理等,这些可以通过 Webpack、Rollup 等构建工具进行预处理。
但在 Flutter Web 中,这些预处理的操作目前官方还不支持,原因是 Flutter 暴露给我们的命令只有一个 <span style="font-size: 15px;">flutter build web
,导致我们无法直接进行更细粒度的个性化定制。如果想要让 Flutter Web 达到企业级应用的标准,我们需要更深层次的探索 Flutter SDK 的运行原理。下面我们列出目前遇到的性能问题及其解决方案。
Google 官方对 Flutter Web 性能优化所做的事项还比较少,编译输出的页面存在较大的性能问题,主要体现在以下两方面:
通过下图对浏览器网络监控情况的展示,可以清晰的反映出以上问题:
浏览器网络监控
页面滚动过程中,内存的占用情况
为了解决上述的性能问题,我们探索了 Flutter SDK 编译过程,总结出从 Flutter 业务代码到 Web 产物的整体流程,详细流程如下图所示:
编译流程
从流程中我们可以看到,Flutter 在 Web 端目前只支持 Dart-->JS 的转换,以及 UI 层的对齐,在工程化和性能优化方面做的工作并不多。
因此,我们必须解决以上的性能问题,才能保证我们的业务可以正常的交付。通过对编译流程的仔细分析与梳理,我们在 AOT 产物生成之前对 Flutter SDK 进行定制,分别进行加载性能优化和内存性能优化,下面分别介绍这两部分的内容。
Flutter SDK 进行定制后的流程
运行 <span style="font-size: 15px;">flutter build web
命令之后,我们得到的主要静态资源有:主文件 main.dart.js(1.1M),各页面的业务代码 xxx.part.js(使用 FutureBuilder 后)、图片文件。直接应用这些资源到项目中,会遇到以下问题:
为此,在加载部分我们对 Flutter SDK 增加了如下三方面的优化,以达到线上运行的标准,优化步骤如下图所示:
优化步骤
资源文件 Hash 化
除了 web/index.html 文件之外,我们要对所有的引用到文件进行 Hash 化。对 build_system/web.dart 的修改按以下步骤进行:
大文件分片
Flutter Web 编译之后会生成 main.dart.js 这一主文件,体积为1.1M(Gzip 之后约 400K),这给页面的加载性能带来很大的影响。为此,我们对代码进行分片,借助浏览器对多文件并行加载的特性,可以有效提升页面的加载性能。
具体实施步骤是:将 <span style="font-size: 15px;">main.dart.js
在 Dart 侧拆分成多份纯文本文件,前端通过 XHR 的方式并行加载并按顺序拼接成 Javascript 代码置于