作者:马坤乐(坤吾)
Flutter for Web(FFW)从 2021 年发布至今,在国内外互联网公司已经得到较多的应用。作为 Flutter 技术在 Web 领域的有力扩充,FFW 可以让熟悉 Flutter 的客户端同学直接上手写 H5,复用 App 端代码高效支撑业务需求;在 App 侧 FFW 也可作为 Flutter 动态下发的兜底方案。总的来说在业务和技术上 FFW 都具有相当的价值。
然而在使用 FFW 时有一个明显的问题:其编译产物 main.dart.js
较大,初始的 Hello world 工程编译后产物 js 大小为 1.2 MB,添加业务代码后 js 的大小还会继续增加。在阿里卖家的内容外投业务中,3 个页面的工程 js 大小为 2.0 MB,js 文件过大直接的影响就是页面首次首屏加载的速度。针对 js 的大小有较多优化方法,本文主要记录 main.dart.js
分片优化方案的实现。
1.方案总览
页面 js 加载速度提升一般从两个角度考虑:
- 减少 js 文件大小
- 提升 js 加载效率
对应到 js 分片方案,主要通过如下两点提升加载速度:
按需加载:在工程中存在多个页面时,不论打开哪个页面都需要加载完整的main.dart.js
,而这里包含了很多不需要的页面代码。如果将各个页面的代码拆分只加载当前页面所需要的代码,则可减少 js 文件体积,而且当其他页面越多逻辑越复杂时,其提升的效果越明显。
并行加载:将 js 分片后会生成多个大小不一的 js 文件,在带宽充足的情况下如果使用并行加载则可以节省较小的分片加载时间。
注:js 文件压缩在线上部署的时候会自动处理,这里不做处理。
2. 工程实践
通过按需和并行加载提升加载速度,首先需要完成 js 的分片。分片和按需加载操作通常是绑定的,如在前端 Vue 开发中,可使用 webpack 的 code splitting 工具在定义好各类库的使用关系后实现文件分割和按需加载,类似的在 flutter 中则可使用 延迟加载组件 功能。
2.1 延迟加载组件
Flutter 为 App 设计的延迟组件加载功能同样适用于 FFW。在 dart 代码中通过关键字 deffered as
引入相关代码库并在使用时加载即可实现延迟加载功能。在官方的示例中可以通过如下的方式实现 box.dart
的延迟加载。
// box.dart
import 'package:flutter/material.dart';
/// 一个正常方式编写的 widget,后面会被延迟加载
class DeferredBox extends StatelessWidget {
const DeferredBox({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
height: 30,
width: 30,
color: Colors.blue,
);
}
}
在需要使用 box.dart
的地方通过 deferred as
关键字引入 box.dart
/// some_widget.dart
import 'package:flutter/material.dart';
/// 1. deferred as 引入
import 'box.dart' deferred as box;
class SomeWidget extends StatefulWidget {
const SomeWidget({Key? key}) : super(key: key);
@override
State<SomeWidget> createState() => _SomeWidgetState();
}
之后调用延迟加载库的加载方法,加载完成后使用即可
/// some_widget.dart
class _SomeWidgetState extends State<SomeWidget> {
late Future<void> _libraryFuture;
@override
void initState() {
/// 2. 使用时加载延迟加载库
_libraryFuture = box.loadLibrary();
super.initState();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<void>(
future: _libraryFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasError) {return Text('Error: ${snapshot.error}');}
/// 3. 延迟加载库加载完成后使用
return box.DeferredBox();
}
return const CircularProgressIndicator();
},
);
}
}
经过上述操作后,在 FFW 中编译后可生成类似如下的两个 js 文件:
├── [1.2M] main.dart.js /// FFW 引擎和主工程内容
├── [616B] main.dart.js_1.part.js /// 存放 box.dart 对应的内容
在多页面的工程中使用延迟组件加载即可完成多页面的分片,可进行接下来的改造工作。
2.2 延迟加载改造
在阿里卖家 FFW 工程中,为了尽可能的做到只加载必须内容,我们从路由跳转位置将各页面改造为延迟加载方式。
2.2.1 主工程代码
/// main.dart
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'AliSupplier Headline',
debugShowCheckedModeBanner: false,
onGenerateRoute: RouteConfiguration.onGenerateRoute,
onGenerateInitialRoutes: (settings) {
return [RouteConfiguration.onGenerateRoute(RouteSettings(name: settings))];
},
);
}
}
2.2.2 原路由代码
/// routes.dart
import 'package:alisupplier_content/business/distribution/page/sellerapp_page.dart';
import 'package:alisupplier_content/business/webmain/page/web_news_detail_page.dart';
import 'package:alisupplier_content/debug/page/debug_main_page.dart';
/// 路由和页面 builder 的 map
static Map<String, RouteWidgetBuilder?> builders = {
'/debug': (context, params) {
return DebugMainPage(title: 'Debug');
},
'/web_news_detail': (context, params) {
return WebNewsDetailPage(
courseCode: params?['courseCode'] ?? params?['c'] ?? '',
sourceId: params?['sourceId'] ?? params?['s'] ?? '',
);
},
'/sellerapp': (context, params) {
return SellerAppPage(
url: params?['url'] ?? '',
sourceId: params?['sourceId'] ?? params?['s'] ?? '',
);
},
};
/// routes.dart
class RouteConfiguration {
static Route<dynamic> onGenerateRoute(RouteSettings settings) {
return NoAnimationMaterialPageRoute(
settings: settings,
builder: (context) {
var uri = Uri.parse(settings.name ?? '');
/// 根据 path 找页面的 builder
var route = builders[uri.path];
if (route != null) {
return route(context, uri.queryParameters);
} else {
/// 404 页面
return CommonPageNotFound(routeSettings: settings);
}
},
);
}
}
2.2.3 改造代码
创建 DeferredLoaderWidget
执行各页面加载操作
/// routes.dart
class RouteConfiguration {
static Route<dynamic> onGenerateRoute(RouteSettings settings) {
return NoAnimationMaterialPageRoute(
settings: settings,
builder: (context) {
/// 承担路由和加载工作
return DeferredLoaderWidget(
settings: settings,
);
},
);
}
}
在 DeferredLoaderWidget
中将各页面通过 deferred as
方式引入
/// deferred_loader_widget.dart, 新添加的文件
import '../../business/distribution/page/sellerapp_page.dart' deferred as sellerapp;
import '../../business/webmain/page/web_news_detail_page.dart' deferred as web_news_detail;
import '../../debug/page/debug_main_page.dart' deferred as debug;
import '../../ability/common/page/common_page_not_found.dart' deferred as pageNotFound;
import 'package:flutter/material.dart';
typedef WidgetConstructer = Widget Function(Map? params);
/// 分包加载: library 加载 map
/// <页面地址,library加载方法>
var _loadLibraryMap = {
'/sellerapp': sellerapp.loadLibrary,
'/web_news_detail': web_news_detail.loadLibrary,
'/debug': debug.loadLibrary,
};
/// 分包加载: 页面 widget 创建方法 map
/// <页面地址,widget 创建方法>
var _constructorMap = {
'/sellerapp': () => sellerapp.widgetConstructor,
'/web_news_detail': () => web_news_detail.widgetConstructor,
'/debug': () => debug.widgetConstructor,
};
之后在需要的时候对页面进行加载,在 _DeferredLoaderWidgetState.initState
中执行加载操作:
/// deferred_loader_widget.dart
@override
void initState() {
super.initState();
/// 路由解析
Uri uri = Uri.parse(widget.settings.name ?? '');
path = uri.path;
params = uri.queryParameters;
/// 根据 path 找到 libraryLoad 方法
Future Function()? loadLibrary = _loadLibraryMap[path];
/// 未找到时使用 404 页面 loadLibrary
if (loadLibrary == null) {
loadLibrary = pageNotFound.loadLibrary;
params = {'settings': widget.settings};
}
loadFuture = loadLibrary.call();
}
DeferredLoaderWidgetState.build
中进行 widget 的创建:
/// deferred_loader_widget.dart
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: loadFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasError) {
return Text('页面加载失败,请重试');
}
var constructor = _constructorMap[path];
if (constructor == null) {
/// 页面未找到
constructor = () => pageNotFound.widgetConstructor;
}
return constructor().call(params);
} else {
return Container();
}
},
);
}
其中对于每个页面在其头部定义构造统一的构造方法,以 sellerapp
为例:
/// sellerapp_page.dart
/// 页面构造方法
WidgetConstructer widgetConstructor = (params) {
return SellerAppPage(
url: params?['url'] ?? '',
sourceId: params?['sourceId'] ?? params?['s'] ?? '',
);
};
详情可见代码库:http://gitlab.alibaba-inc.com/algernon/alisupplier_content_web
在进行延迟加载改造时有两个需要注意的点:
- 各页面构造方法封装一定要写到各页面的 dart 文件中,这样才能通过
deferred as
命名引用到 - 各页面的
widgetConstructor
需要在相应的 library load 之后才能实际调用,在此之前引用的值会在使用时无效,如将deferred_loader_widget
中_constructorMap
进行如下修改:
则运行时会得到如下的报错信息
2.2.4 分片效果
改造完成后即可进行编译调试,查看 js 分片和按需加载的效果。
产物对比
查看编译产物发现 main.dart.js
被拆分成了一个较小的 main.dart.js
和诸多小的 main.dart.js_xx.part.js
页面加载对比
在浏览器中查看页面 js 加载发现资讯页和下载页总的 js 大小均有减少,下载页因压缩问题传输 js 会比分包前稍大,但总大小有所减少,另外因为分包实现了部分的并行加载,总体耗时有所减少:
在实验室环境经过多次测试后取平均时间,发现下载页耗时减少 15%,资讯页加载总加载耗时减少 9%。由于下载页 js 减少更多结果符合预期。
2.3 并行加载
经过延迟加载改造后,产物 js 分成了多个包,相关页面加载耗时也有所减少,但是在加载中发现一个问题,main.dart.js
和其他分片的 js 不是同时加载的:
main.dart.js_xx.part.js
是在 main.dart.js
加载完成之后过了相当一段时间才开始加载,这浪费了很多的加载时间,如果所有的分片 js 都在 main.dart.js
加载时同时加载,则加载耗时基本只会和 main.dart.js
加载耗时相同。
2.3.1 分片加载原理
为了让所有分片 js 同时加载,首先观察分片的加载过程。打开页面后检查页面发现情况如下,页面内被注入了分片 js 的加载代码:
在 main.dart.js
中查找相关分片的文件名,可发现如下内容:
猜测 main.dart.js
内部包含的各页面所需 js 分片信息的相关字段含义如下:
- deferredPartUris: 分片文件的列表
- deferredLibraryParts: 每个组件所需分片在列表中的 index
考虑如果能将 main.dart.js
中注入分片的时间提前到 main.dart.js
加载时,则可实现理想的并行加载效果。由于 main.dart.js
还未加载相关注入的代码不可用,则只能在 index.html
中添加分片的加载代码。
2.3.2 并行加载实现
有了实现的思路,接下来就是进行操作和验证。我们使用构建脚本中解析延迟组件信息,并将解析处理后的信息写入 index.html
中的方案来实现 js 分片的并行加载。
首先在 index.html
中增加加载 js 分片的代码:
<!-- ffw 分包并行加载,根据页面 path 并行加载相关的 part.js,不用等到 ffw 执行时自己去加载 -->
<script id="flutterJsPatchLoad">
// 使用脚本替换内容
var deferredLibraryParts = {};
// 使用脚本替换内容
var deferredPartUris = [];
// 使用脚本替换内容
var base = "";
// 根据页面路径加载所需 js 分片,为了方便要求 DeferredLoaderWidget 中 _loadLibraryMap key 的名称
// 和延迟组件的名称相同
var hash = window.location.hash.substring(2);
var path = hash.split('?')[0];
if (deferredLibraryParts[path]) {
for (var index in deferredLibraryParts[path]) {
loadScript(deferredPartUris[index])
}
}
function loadScript(url) {
var script = document.createElement("script");
script.type = "text/javascript";
script.src = base + url;
document.body.appendChild(script);
}
</script>
之后在构建脚本中解析组件信息,并替换到 deferredLibraryParts
和 deferredPartUris
中,同时在线上发布时将分片 js 的 base 路径替换为实际的 cdn 地址:
# 从 main.dart.js 中获取 js 分包信息,写入 index.html 中预加载部分的变量中
def write_js_patch_info():
# 从 main.dart.js 获取两个参数:deferredLibraryParts、deferredPartUris
# 这个阶段在本地编译时执行
parts = reg_find_file_content('./build/web/main.dart.js', r'deferredLibraryParts:{(.*?)},')[0]
uris = reg_find_file_content('./build/web/main.dart.js', r'deferredPartUris:\[(.*?)\],')[0]
str_replace_file_content('./build/web/index.html', r'deferredLibraryParts = {}', r'deferredLibraryParts = {' + parts + r'}')
str_replace_file_content('./build/web/index.html', r'deferredPartUris = []', r'deferredPartUris = [{}]'.format(uris))
# 修改 index.html 中的 base 为实际的cdn地址
def change_base(version, publish_env):
str_replace_file_content('./build/web/index.html', r'base = ""', r'base = "{}"'.format(get_base(version, publish_env)))
构建过程中经过脚本的替换,index.html
内容更新如下:
<!-- ffw 分包并行加载,根据页面 path 并行加载相关的 part.js,不用等到 ffw 执行时自己去加载 -->
<script id="flutterJsPatchLoad">
// 使用脚本替换内容
var deferredLibraryParts = {sellerapp:[0,1,2,3],web_news_detail:[0,4,1,5,2,6],debug:[0,4,1,7,5,8],pageNotFound:[0,4,7,9]};
// 使用脚本替换内容
var deferredPartUris = ["main.dart.js_3.part.js","main.dart.js_9.part.js","main.dart.js_7.part.js","main.dart.js_6.part.js","main.dart.js_4.part.js","main.dart.js_11.part.js","main.dart.js_10.part.js","main.dart.js_2.part.js","main.dart.js_12.part.js","main.dart.js_1.part.js"];
// 使用脚本替换内容
var base = "https://g.alicdn.com/algernon/alisupplier_content_web/2.0.5/";
// 根据页面路径加载所需 js 分片,为了方便要求 DeferredLoaderWidget 中 _loadLibraryMap key 的名称
// 和延迟组件的名称相同
var hash = window.location.hash.substring(2);
var path = hash.split('?')[0];
if (deferredLibraryParts[path]) {
for (var index in deferredLibraryParts[path]) {
loadScript(deferredPartUris[index])
}
}
function loadScript(url) {
var script = document.createElement("script");
script.type = "text/javascript";
script.src = base + url;
document.body.appendChild(script);
}
</script>
构建部署完成后测试加载过程如下,发现各分片 js 加载完成时间接近,基本与 main.dart.js
加载完成时间相同:
同时检查页面发现,FFW 没有再额外注入分片 js 的加载代码,至此分片 js 并行加载达到了理想的效果。
2.3.3 异常说明
在实际使用中发现 deferredLibraryParts
中包含的信息与实际所需分片可能不完全相同,如在 main.dart.js
中资讯页面的deferredLibraryParts
加载信息为 0,4,1,5,2,6
6 个分片,但在实际打开页面的时候发现还会加载 index 为 7
的分片:
简单的解析 deferredLibraryParts
不够精确,要做到更精确还需深入分析 main.dart.js
代码,这里目前采用人工修正的方式处理。
2.3.4 并行效果
经过并行加载改造后,资讯页面总加载耗时进一步减少,加载耗时由 -9% 变为 -15%。下载页则提升不明显,考虑原因为下载页多图片资源占比稍大,IO资源在非并行的状态下已经得到了较为充分的使用。
3. 效果分析
由于当前阿里卖家 FFW 页面访问量不够大,同时线上性能数据为初次启动和非初次启动的混合数据不易区分,这里使用多次实验取平均数方式分析效果。
分析结论如下:
- 资讯页:从分片到并行耗时分别减少 9% 和减少 15%,资讯页主要包括 js 加载和数据请求,受益于 domContentLoaded 时间减少数据请求可以更快进行,并行化处理后提速明显。
- 下载页:从分片到并行耗时维持在减少 15% 左右,下载页主要受益于 js 按需加载,而包含多个图片带宽在非理想的并行情况下也得到了较为充分的使用,所以并行化处理效果不明显。
4. 未来展望
分片之后 main.dart.js
还有 1.3 MB 的体积,还有优化空间,另外延迟加载信息的解析还未做到完全精确。总体来说在加载提速上未来可做的事情还有:
- FFW 引擎功能及代码精简,继续减少
main.dart.js
大小 - 延迟加载信息精确分析,做到延迟加载信息的完全精确
- 非当前页面分片预加载,提升多页面切换速度
FFW 在生产环境使用的条件已经成熟,在当前开发人员存量的情况,FFW 是端技术同学的一大利器。FFW 当前与前端体系的分离是影响其在前端推广使用的一大阻力,如果能做好 FFW 和现有前端体系的融合,相信会更加的繁荣。