Flutter for Web 首次首屏優化——JS 分片優化

語言: CN / TW / HK

作者:馬坤樂(坤吾)

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.方案總覽

圖 1.  FFW js 分片示意

頁面 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. widgetConstructor 錯誤使用方式說明

則執行時會得到如下的報錯資訊

圖 3. widgetConstructor 錯誤使用方式報錯資訊

2.2.4 分片效果

改造完成後即可進行編譯除錯,檢視 js 分片和按需載入的效果。

產物對比

檢視編譯產物發現 main.dart.js 被拆分成了一個較小的 main.dart.js 和諸多小的 main.dart.js_xx.part.js

圖 4. 分片前後編譯產物對比

頁面載入對比

在瀏覽器中檢視頁面 js 載入發現資訊頁和下載頁總的 js 大小均有減少,下載頁因壓縮問題傳輸 js 會比分包前稍大,但總大小有所減少,另外因為分包實現了部分的並行載入,總體耗時有所減少:

表1. 資訊頁 js 載入情況對比

表2. 下載頁 js 載入情況對比

在實驗室環境經過多次測試後取平均時間,發現下載頁耗時減少 15%,資訊頁載入總載入耗時減少 9%。由於下載頁 js 減少更多結果符合預期。

2.3 並行載入

經過延遲載入改造後,產物 js 分成了多個包,相關頁面載入耗時也有所減少,但是在載入中發現一個問題,main.dart.js 和其他分片的 js 不是同時載入的:

圖 5. 分片後 js 載入時序

main.dart.js_xx.part.js 是在 main.dart.js 載入完成之後過了相當一段時間才開始載入,這浪費了很多的載入時間,如果所有的分片 js 都在 main.dart.js 載入時同時載入,則載入耗時基本只會和 main.dart.js 載入耗時相同。

2.3.1 分片載入原理

為了讓所有分片 js 同時載入,首先觀察分片的載入過程。開啟頁面後檢查頁面發現情況如下,頁面內被注入了分片 js 的載入程式碼:

圖 6. FFW 自動注入的分片載入程式碼

main.dart.js 中查詢相關分片的檔名,可發現如下內容:

圖 7. 分片 main.dart.js 內的 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 載入完成時間相同:

圖 8. 並行載入改造後的 js 載入時序

同時檢查頁面發現,FFW 沒有再額外注入分片 js 的載入程式碼,至此分片 js 並行載入達到了理想的效果。

圖 9. 並行載入改造後 FFW 不再注入分片載入程式碼

2.3.3 異常說明

在實際使用中發現 deferredLibraryParts 中包含的資訊與實際所需分片可能不完全相同,如在 main.dart.js 中資訊頁面的deferredLibraryParts載入資訊為 0,4,1,5,2,6 6 個分片,但在實際開啟頁面的時候發現還會載入 index 為 7 的分片:

圖 10. FFW 額外需載入的 js 分片

簡單的解析 deferredLibraryParts 不夠精確,要做到更精確還需深入分析 main.dart.js 程式碼,這裡目前採用人工修正的方式處理。

2.3.4 並行效果

經過並行載入改造後,資訊頁面總載入耗時進一步減少,載入耗時由 -9% 變為 -15%。下載頁則提升不明顯,考慮原因為下載頁多圖片資源佔比稍大,IO資源在非並行的狀態下已經得到了較為充分的使用。

3. 效果分析

由於當前阿里賣家 FFW 頁面訪問量不夠大,同時線上效能資料為初次啟動和非初次啟動的混合資料不易區分,這裡使用多次實驗取平均數方式分析效果。

圖 11. 資訊頁下載頁分片及並行改造結果對比

分析結論如下:

  • 資訊頁:從分片到並行耗時分別減少 9% 和減少 15%,資訊頁主要包括 js 載入和資料請求,受益於 domContentLoaded 時間減少資料請求可以更快進行,並行化處理後提速明顯。
  • 下載頁:從分片到並行耗時維持在減少 15% 左右,下載頁主要受益於 js 按需載入,而包含多個圖片頻寬在非理想的並行情況下也得到了較為充分的使用,所以並行化處理效果不明顯。

4. 未來展望

分片之後 main.dart.js 還有 1.3 MB 的體積,還有優化空間,另外延遲載入資訊的解析還未做到完全精確。總體來說在載入提速上未來可做的事情還有:

  • FFW 引擎功能及程式碼精簡,繼續減少 main.dart.js 大小
  • 延遲載入資訊精確分析,做到延遲載入資訊的完全精確
  • 非當前頁面分片預載入,提升多頁面切換速度

FFW 在生產環境使用的條件已經成熟,在當前開發人員存量的情況,FFW 是端技術同學的一大利器。FFW 當前與前端體系的分離是影響其在前端推廣使用的一大阻力,如果能做好 FFW 和現有前端體系的融合,相信會更加的繁榮。