美团外卖商家端基于 FlutterWeb 的技术探索已久,目前在多个业务中落地了App、PC、H5的多端复用,有效提升了产研的整体效率。在这过程中,性能问题是我们面临的最大挑战,本文结合实际业务场景进行思考,介绍美团外卖商家端在 FlutterWeb 性能优化上所进行的探索和实践,希望对大家能有所帮助或启发。
一、背景
1.1 关于FlutterWeb
时间回拨到 2018 年,Google 首次公开 FlutterWeb Beta 版,表露出要实现一份代码、多端运行的愿景。经过无数工程师两年多的努力,在今年年初(2021 年 3 月份),Flutter 2.0 正式对外发布,它将 FlutterWeb 功能并入了 Stable Channel,意味着 Google 更加坚定了多端复用的决心。Flutter 的跨端能力在 Web 侧主要体现在应用层 Framework 是公用的,意味着在 FlutterWeb 中可以直接使用 Widgets、Gestures 等组件来实现逻辑跨端。而关于渲染跨端,FlutterWeb 提供了两种模式来对齐 Engine 层的渲染能力:Canvaskit Render 和 HTML Render。
Canvaskit Render 模式底层基于 Skia 的 WebAssembly 版本,而上层使用 WebGL 进行渲染,因此能较好地保证一致性和滚动性能,但糟糕的兼容性(WebAssembly 从 Chrome 57 版本才开始支持)是需要面对的问题。此外 Skia 的 WebAssembly 文件大小达到了 2.5M,且 Skia 自绘引擎需要字体库支持,这意味着需要依赖超大的中文字体文件,对页面加载性能影响较大,因此目前并不推荐在 Web 中直接使用 Canvaskit Render(官方也建议将 Canvaskit Render 模式用于桌面应用)。
HTML Render 模式利用 HTML + Canvas 对齐了 Engine 层的渲染能力,因此兼容性表现优秀。另外,MTFlutterWeb 对滚动性能已有过探索和实践,目前能够应对大部分业务场景。而关于加载性能,此模式下的初始包为 1.2M,是 Canvaskit Render 模式产物体积的 1/2,且可对编译流程进行干预,控制输出产物,因此优化空间较大。基于以上原因,美团外卖技术团队选择在 HTML Render 模式下对 FlutterWeb 页面的性能进行优化探索。
1.2 业务现状
美团外卖商家端以 App、PC 等多元化的形态为商家提供了订单管理、商品维护、顾客评价、外卖课堂等一系列服务,且 App、PC 双端业务功能基本对齐。此外,我们还在 PC 上特供了针对连锁商家的多店管理功能。同时,为满足平台运营诉求,部分业务具有外投 H5 场景,例如美团外卖商家课堂,它是一个以文章、视频等形式帮助商家学习外卖运营知识、了解行业发展和跟进经营策略的内容平台,具有较强的传播属性,因此提供了站外分享的能力。
为了实现多端(App、PC、H5)复用,提升研发效率,我们于 2021 年年初开始着手 MTFlutterWeb 研发体系的建设。目前,我们基于 MTFlutterWeb 完成提效的业务超过了 9 个,在 App 中,能够基于 FlutterNative 提供高性能的服务;在 PC 端和 Mobile 浏览器中,利用 FlutterWeb 做到了低成本适配,提升了产研的整体效率。然而,加载性能问题是 MTFlutterWeb 应用推广的最大障碍。这里以美团外卖商家课堂业务为例,在项目之初页面完全加载时间 TP90 线达到了 6s 左右,距离指标基线值(页面完全加载时间 TP90 线不高于 3s)有些差距,用户访问体验有很大的提升空间,因此 FlutterWeb 页面加载性能优化,是我们亟需解决的问题。
二、挑战
想要突破 FlutterWeb 页面加载的性能瓶颈,面临的挑战也是巨大的。这主要体现在 FlutterWeb 缺失静态资源的优化策略,以及复杂的架构设计和编译流程。Flutter 业务代码被转换成 Web 平台产物的流程包括:Framework、Flutter_Web_SDK 等底层 SDK 可被业务代码直接引入;flutter_tools 是各平台的编译入口,负责接收命令并开始编译流程;frontend_server 负责将 Dart 转换为 AST,生成 kernel 中间产物 app.dill 文件;Dart2JS Compiler 是 Dart-SDK 中具体负责转译 JS 的模块,它将中间产物进行读取和解析,并注入 JS 工具方法,最终生产出 Web 平台所能执行的 JS 文件;编译产物主要为 main.dart.js、index.html、images 等静态资源,FlutterWeb 对这些静态资源缺少常规 Web 项目中的优化手段,例如文件 Hash 化、文件分片、CDN 支持等。可以看出,要完成对 FlutterWeb 编译产物的优化,就需要干预 FlutterWeb 的众多编译模块。而为了提升整体的编译效率,大部分模块都被提前编译成了 snapshot 文件,这又加大了对 FlutterWeb 编译流程进行干预的难度。
三、整体设计
如前文所述,为了实现逻辑、渲染跨平台,Flutter 的架构设计及编译流程都具有一定的复杂性。但由于各平台的具体实现是解耦的,因此思路是定位各模块的 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 包体积分析
在开始做体积裁剪之前,需要一套包体积分析工具,便于直观地比较各个模块的体积占比,为优化性能提供帮助。Dart2JS 官方提供了 --dump-info 命令选项来分析 JS 产物,但其表现差强人意,并不能很好地分析各个模块的体积占比。这里更推荐使用 source-map-explorer,它的原理是通过 sourcemap 文件进行反解,能清晰地反映出每个模块的占用大小,为 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 为例,由于它是全平台公用的模块,因此不可避免地存在各平台的兼容逻辑,而这部分代码是不能被 Tree-Shaking 剔除的,对 Web 平台来说是 Dead Code,是可以被进一步优化的。
此外,FlutterWeb 依赖的这些 SDK 中包含了一些使用频率较低的功能,例如蓝牙、USB、WebRTC、陀螺仪等功能的支持。为此,我们提供了对这些长尾功能的定制能力,将未被启用长尾的功能进行裁剪。基于这样的思路,我们深入 Dart-SDK、Framework 和 Flutter_Web_SDK 各个击破,最终将 JS Bundle 产物体积由 1.2M 精简至 0.7M,为 FlutterWeb 页面性能优化打下了坚实的基础。
4.1.3 SDK 集成 CI/CD
为了提升构建效率,我们将 FlutterWeb 依赖的环境定制为 Docker 镜像,集成入 CI/CD 系统。SDK 裁剪后,需要更新 Docker 镜像,整个过程耗时较长且不够灵活。因此,我们将 Dart-SDK、Framework、Flutter_Web_SDK 按版本打包传至云端,在编译开始前读取 CI/CD 环境变量:sdk_version,远程拉取相应版本的 SDK 包,并替换当前 Docker 环境中的对应模块,基于此方案实现 SDK 的灵活发布。
4.2 JS 分片
FlutterWeb 编译之后默认会生成 main.dart.js 文件,它囊括了 SDK 代码以及业务逻辑,这样会引起以下问题:功能无法及时更新,因为若产物不支持 Hash 命名,可能导致程序代码不能被及时更新;无法使用 CDN,FlutterWeb 默认仅支持相对域名的资源加载方式,无法使用当前域名以外的 CDN 域名;首屏渲染性能不佳,单一文件加载、解析时间过长,势必会影响首屏的渲染时间。
针对文件 Hash 化和 CDN 加载的支持,在 flutter_tools 编译流程中对静态资源进行二次处理:遍历静态资源产物,增加文件 Hash,并更新资源的引用;同时通过定制 Dart-SDK,修改了 main.dart.js、字体等静态资源的加载逻辑,使其支持 CDN 资源加载。下面重点介绍 main.dart.js 分片相关的一些优化策略。
4.2.1 Lazy Loading
Flutter 官方提供 deferred as 关键字来实现 Widget 的懒加载,而 dart2js 在编译过程中可以将懒加载的 Widget 进行按需打包,这样的拆包机制叫做 Lazy Loading。借助 Lazy Loading,可以在路由表中使用 deferred 引入各个路由,以此来达到业务代码拆离的目的。使用 Lazy Loading 后,业务页面的代码会被拆分到了多个 PartJS 中。这样看似解决了业务代码与 SDK 耦合的问题,但在实际操作过程中,每次业务代码的变动,仍然会导致编译后的 main.dart.js 随之发生变化。经过定位与跟踪,发现这个变化的部分是 PartJS 的加载逻辑和映射关系,称为 Runtime Manifest。因此,需要设计一套方案对 Runtime Manifest 进行抽离,来保证业务代码的修改对 main.dart.js 的影响达到最低。
4.2.2 Runtime Manifest抽离
通过对业务代码的抽离,此时 main.dart.js 文件的构成主要包含 SDK 和 Runtime Manifest。为了将 Runtime Manifest 进行抽离,借鉴常规 Web 项目的编译思路,将 SDK、Utils、三方包等基础依赖进行抽离并赋予一个稳定的 Hash 值,同时将 Runtime Manifest 注入到 HTML 文件中。我们深入分析了 FlutterWeb 中 Runtime Manifest 的生成逻辑和 PartJS 的加载逻辑,定制出解决方案:在 Runtime Manifest 的生成逻辑中,对 Runtime Manifest 代码块进行了标记,之后在 flutter_tools 中将标记的 Runtime Manifest 代码块抽离并写入 HTML 文件中(以 JS 常量形式存在)。而在 PartJS 的加载流程中,将 manifest 信息的读取方式改为了 JS 常量的获取。按照这样的拆分方式,业务代码的变更只会改变 Runtime Manifest 信息,而不会影响到 main.dart.js 公共包。
4.2.3 main.dart.js 切片
经过以上引入 Lazy Loading、Runtime Manifest 抽离,main.dart.js 文件的体积稳定在 0.7M 左右,浏览器对大体积单文件的加载,会有很沉重的网络负担,所以设计了切片方案,充分地利用浏览器对多文件并行加载的特性,提升文件的加载效率。具体实现方案为:将 main.dart.js 在 flutter_tools 编译过程拆分成多份纯文本文件,前端通过 XHR 的方式并行加载并按顺序拼接成 JavaScript 代码置于 标签中,从而实现切片文件的并行加载。
4.3 预加载方案
随着接入 FlutterWeb 的项目越来越多,每个业务的页面互访概率也越来越高,期望是当访问 A 业务时,可以预先缓存 B 业务引用的 main.dart.js,这样当用户真正进入 B 业务时就可以节省加载资源的时间。
4.3.1 技术方案
把整体的技术方案分为编译、监听、运行三个阶段。编译阶段,在发布流水线上根据前期定制的匹配规则,筛选出符合条件的资源文件路径,生成云端 JSON 并上传;监听阶段,在 DOMContentLoaded 之后,对网络资源、事件、DOM 变动进行监听,并对监听结果根据特定规则进行分析加权,得到一个首屏加载完成的状态标识;运行阶段,在首屏加载完成之后对配置平台下发的云端 JSON 文件进行解析,对符合配置规则的资源进行 HTTP XHR 预加载,从而实现文件的预缓存功能。
编译阶段会扩展现有的发布流水线,在 flutter build 之后增加 prefetch build 作业,对产物目录进行遍历和筛选,得到所需资源进而生成云端 JSON。监听阶段使用 MutationObserver API 对 DOM 变化进行收集,并筛选有效节点进行深度优先遍历,计算每个 DOM 的递归权重值,低于阈值就认为首屏已加载完成;同时利用 PerformanceObserver API 筛选资源变化,并在用户交互时判断首屏加载完成。运行阶段下载编译阶段生成的云端 JSON,解析出需要进行预缓存资源的 CDN 路径,最后通过 HTTP XHR 进行缓存资源请求,利用浏览器本身的缓存策略,把其他业务的资源文件写入。
4.3.2 效果展示与数据对比
当有页面间互访问命中预缓存时,浏览器会以 200(Disk Cache)的方式返回数据,这样就节省了大量资源加载的时间。目前,美团外卖商家端业务已有 10+ 个页面接入了预缓存功能,资源加载 90 线平均值由 400ms 下降到 350ms,降低了 12.5%;50 线平均值由 114ms 下降到 100ms,降低了 12%。随着项目接入接入越来越多,预缓存的效果也会越发的明显。
4.4 分平台打包
美团外卖商家业务大部分都是双端对齐的。为了实现提效的最大化,对 FlutterWeb 的多平台适配能力进行加强,实现了 FlutterWeb 在 PC 侧的复用。在 PC 适配过程中,需要书写双端的兼容代码,为此开发了一个适配工具 ResponsiveSystem,分别传入 PC 和 App 的各端实现,内部会区分平台完成适配。但 AppWidget 或 PCWidget 在编译过程中都将无法被 Tree-Shaking 去除,因此会影响包体积大小。对此,将编译流程进行优化,设计分平台打包方案:修改 flutter-cli,使其支持 --responsiveSystem 命令行参数;在 flutter_tools 中的 AST 分析阶段增加额外的处理:ResponsiveSystem 关键字的匹配,同时结合编译平台来进行 AST 节点的改写;去除无用 AST 节点后,生成各个平台的代码快照;根据代码快照编译生成 PC 和 App 两套 JS 产物,并进行资源隔离。而对于 images、fonts 等公用资源,将其打入 common 目录。通过这样的方式,去除了各自平台的无用代码,避免了 PC 适配过程中引起的包体积问题。以美团外卖商家课堂业务为例,接入分平台打包后,单平台代码体积减小 100KB 左右。
4.5 图标字体精简
当访问 FlutterWeb 页面时,即使在业务代码中并未使用 Icon 图标,也会加载一个 920KB 的图标字体文件:MaterialIcons-Regular.woff。通过探究,发现是 Flutter Framework 中一些系统 UI 组件使用到了 Icon 图标导致,且 Flutter 为了便于开发者使用,提供了全量的 Icon 图标字体文件。Flutter 官方提供的 --tree-shake-icons 命令选项是将业务使用到的 Icon 与 Flutter 内部维护的一个缩小版字体文件进行合并,能一定程度上减小字体文件大小。而我们需要的是只打包业务使用的 Icon,所以对官方 tree-shake-icons 进行了优化,设计了 Icon 的按需打包方案:扫描全部业务代码以及依赖的 Plugins、Packages、Flutter Framework,分析出所有用到的 Icon;把扫描到的所有 Icon 与 material/icons.dart 进行对比,得到精简后的图标编码列表;使用 FontTools 工具把列表生成字体文件 .woff,此时的字体文件仅包含真正使用到的 Icon。通过以上的方案,解决了字体文件过大带来的包体积问题,以美团外卖课堂业务为例,字体文件从 920KB 精简为 11.6kB。
五、总结与展望
综上所述,基于 HTML Render 模式对 FlutterWeb 性能优化进行了探索和实践,主要包括 SDK 的精简,静态资源产物优化和前端资源加载优化。最终使得 JS 产物由 1.2M 减少至 0.7M(非业务代码),页面完全加载时间 TP90 线由 6s 降到了 3s,这样的结果已能满足美团外卖商家端的大部分业务要求。而未来的规划将聚焦于以下3个方向:降低 Web 端适配成本,目标是将适配成本降低到 10% 以下;构建 FlutterWeb 容灾体系,FlutterWeb 作为兜底方案,能提升整体业务的加载成功率,并提供“免安装更新”的能力;性能优化的持续推进,例如将 Framework 及三方包代码进行抽离,可进一步提升页面加载性能。