乐趣区

关于flutter:Flutter-for-Web-首次首屏优化JS-分片优化

作者:马坤乐(坤吾)

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…

在进行提早加载革新时有两个须要留神的点:

  • 各页面构造方法封装肯定要写到各页面的 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>

之后在构建脚本中解析组件信息,并替换到 deferredLibraryPartsdeferredPartUris 中,同时在线上公布时将分片 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 和现有前端体系的交融,置信会更加的凋敝。

退出移动版