阿里賣家 Flutter for Web 工程實踐

阿里巴巴移動技術發表於2022-03-04

作者:馬坤樂(坤吾)

Flutter 自 2015 年初次亮相以來,經過了多年的發展已經相當成熟,在阿里、美團、拼多多等網際網路公司都有廣泛的應用。在 ICBU 阿里賣家上 90+% 的新業務使用 Flutter 開發,ICBU 客戶端開發組擁有眾多的 Flutter 開發人員。

Flutter for Web (FFW) 早期試驗版於 2019 年釋出,在當時已經有很多感興趣同學對其進行調研,當時由於剛釋出存在諸多問題不適合在生產環境中使用。在今年(2021)三月份,Flutter 2.0 釋出,FFW 正式進入 stable 分支。

阿里賣家外貿資訊版塊主要使用 Flutter 開發,在本財年的目標中,外貿資訊的App外推廣為開源引流的重要一環。App外資訊推廣需要一個承載內容Web頁面,對該Web頁面的要求如下:

  • 復刻App端相關頁面的 UI、功能(主要包含一個dart編寫的自定義html解析渲染引擎)【主要工作量】
  • 快速上線
  • App端功能同步

由於缺乏前端同學的支援,想要完成此頁面只能由 App 端上同學自己投入,經過一定的考慮我們選擇了 FFW,理由如下:

  • 切換到前端技術棧 Rax 等成本稍高,同時目標頁面功能復刻需要較多時間
  • 使用 FFW 目標頁面上絕大部分程式碼可複用端上現成 dart 程式碼
  • App 端上 Flutter 技術棧同學覆蓋廣

經過以上思考,正式開啟 FFW 填坑之旅。

Demo

目前阿里賣家FFW相關頁面已上線,從 FFW 釋出至今產物 js 檔案大的問題就一直存在,理論上會很影響頁面載入體驗,實際測試中觀察到在 PC、移動裝置上載入體驗尚可,執行很流暢,相關 Demo 如下:

問題總覽

建立 FFW 工程比較簡單,Flutter 切換到 stable 版本,之後執行命令 flutter create xxxProject 進入工程後點選執行一個 Demo 工程便可執行起來。要將 FFW 應用到實際的工程中,需要考慮的是工程的問題和如何融入阿里的體系的問題,如:怎麼釋出、開發流程如何管控、怎麼請求介面等,總結如下:

以上為阿里賣家 FFW 開源引流最小閉環實踐中遇到的問題,除此之外 FFW 待建設的問題還有:

工程基礎

接下來是對最小閉環實踐中,工程基礎問題的出現原因和解決方案的說明。

環境和複用

參考 App 端 Flutter 開發,FFW 中首先要考慮選擇 Flutter 的什麼版本,其次是考慮如何複用已有的 Flutter 程式碼。

Flutter 版本選擇

版本選擇問題因 FFW 和 Flutter for App (FFA) 的 Flutter 版本無法統一產生。FFW 需要的 Flutter 版本為 2.0+,而目前我們 App 端內的 Flutter 版本為 1.X+ ,要升級到 2.0+ 版本還需等待不確定的時間。經過一定的考慮目前我們 FFW 和 FFA 選擇版本如下:

FFA: hummer/release/v1.22.6.3          -- UC的Hummer分支
FFW: origin/flutter-2.8-candidate.9     -- 官方分支

FFW 不選用 stable 版本是因為在最近剛釋出的 iOS 15 上 FFW 頁面會因 webGL 問題會卡死,該問題修復方案目前已整合到了candidate版本。(當前最新stable版本為2.10.0,問題已解決)

程式碼複用

FFA 程式碼複用到 FFW 中要考慮的問題分兩塊:

  • Dart 程式碼複用
  • 平臺相關外掛能力複用

Dart 程式碼複用

FFW 需要 Flutter 2.0+ 版本對應的 dart 版本為 2.12,此版本的 dart 引入了空安全 (Sound null safety) 特性。FFA 上使用的 Flutter 版本為 1.+ 版本對應的 dart 還未引入空安全。同時 Flutter 中新老版本 dart 庫程式碼無法混合編譯,所以目前對已有 App 端程式碼庫還無法做到無縫複用,只能通過修改已有程式碼進行復用,程式碼修改主要的點有:

  • 可為空的變數,型別後新增?
User? nullableUser;
  • 操作可為空的變數時使用 ? 或 !
nullableUser?.toString();   // 空安全,如為空不會出現NPE
nullableUser!.toString();   // 強制指定非空,如為空會報錯
  • 可選引數 @required 註解替換為 required 保留字
/// 老版本
User({
  @required this.name,
  @required this.age,
});

/// 新版本
User({
  required this.name,
  required this.age,
});

低版本程式碼經過這三步修改後基本可在新版本編譯通過,除此之外還會有部分 API 由於版本升級產生變更,也需要相應的修改,如:

/// 老版本
typedef ValueWidgetBuilder<T> 
  = Widget Function(BuildContext context, T value, Widget child);

/// 新版本
typedef ValueWidgetBuilder<T> 
  = Widget Function(BuildContext context, T value, Widget? child);

在 API 變更中這類問題佔大多數,修改起來較簡單。另外還有一類改動,如在抽象類 TextSelectionControls中,handleCut等方法引數的個數發生了變更:

/// 老版本
void handleCut(TextSelectionDelegate delegate) {...}

/// 新版本
void handleCut(TextSelectionDelegate delegate, 
               ClipboardStatusNotifier? clipboardStatus) {...}

這類改動需根據實際情況進行修改,難度中等,新加的引數大概率是可以不使用的。

平臺相關外掛

平臺相關的外掛會呼叫 Native 的能力,要在 FFW 上使用 FFA 中的外掛,需要為外掛在 Web 平臺實現相應的能力,下文 js 呼叫部分會進行說明。如果使用的是pub.dev 中的庫,且該庫滿足如下條件則可直接使用相應的版本:

  • 程式碼庫有 Web 版本
  • 釋出的版本中有支援 Null safety 的版本(支援 Web 也會支援這個)
支援 Web 版本支援空安全

釋出體系

本地Demo工程建立並執行成功後,接下來要考慮幾個問題:

  • 開發到釋出的流程如何管控
  • 如何將頁面釋出到線上公網可訪問

    • 怎麼打包構建
    • 怎麼釋出

對於開發到釋出流程的管控,參考前端選用 DEF 平臺(阿里內部前端開發釋出平臺)通過建立 WebApp 方式管理,這裡不詳細說明。對於頁面釋出涉及內容如下:

工程構建

FFW 的構建方式有兩種,構建的產物在應用中並非全部需要需要進行一定的精簡;另外要在 DEF 平臺上釋出產物還需對產物進行一些額外的處理。在構建中主要考慮如何構建,FFW 編譯構建可選命令如下:

/// canvaskit方式渲染
flutter build web --web-renderer canvaskit
  
/// html方式渲染
flutter build web --web-renderer html

兩條命令的區別是目標頁面以何種方式渲染,Flutter 官方對兩種方式區別的解釋如下:

總結來說如下:

  • Html 方式:頁面使用 Html 的基礎元素渲染,優點是頁面資原始檔小;
  • CanvasKit 方式:使用了 WebAssembly 技術,具有更好的效能,但是因為需要載入 WebAssembly 相關的 wasm 檔案從而多載入 2.+ MB 的資原始檔,更適合對頁面效能有較高要求的場景。

在空工程上兩種方式資源載入對比如下,基於對頁面大小和頁面效能考慮我們選擇使用html的方式。

Html 方式CanvasKit 方式

產物精簡和處理

對於新建立的工程,編譯後產物位於 ./build/web目錄下,結構為:

build
└── [ 384]  web
    ├── [ 224]  assets
    │   ├── [   2]  AssetManifest.json
    │   ├── [  82]  FontManifest.json
    │   ├── [740K]  NOTICES
    │   └── [  96]  fonts
    │       └── [1.2M]  MaterialIcons-Regular.otf
    ├── [ 917]  favicon.png
    ├── [6.5K]  flutter_service_worker.js
    ├── [ 128]  icons
    │   ├── [5.2K]  Icon-192.png
    │   └── [8.1K]  Icon-512.png
    ├── [3.6K]  index.html           【釋出保留】
    ├── [1.2M]  main.dart.js         【釋出保留】
    ├── [ 570]  manifest.json
    └── [  59]  version.json

其中各目錄和檔案的作用和說明如下:

  • assets: 圖片、字型等資原始檔,對應 yaml 檔案中配置的 assets,在 FFW 中圖片配置在 TPS 上且不使用 IconFont 的情況下,該目錄可不需要;
  • favicon.png: 頁面的 icon,使用 TPS 資源時可不需要;
  • flutter_service_worker.js:本地 debug 時控制頁面載入、reload、關閉等,釋出時不需要;
  • icons:icon 資源,釋出到 TPS 可不需要;
  • index.html:頁面入口檔案,主要工作是引入 main.dart.js 還有一些其他資源,類似 App 的殼工程,需要;
  • main.dart.js:工程中 dart 編譯後的產物,需要;
  • manifest.json: 頁面作為 webapp 使用的配置,可不需要;
  • version.json: 構建資訊,可不需要。

在實際釋出中,需要的構建產物只有 index.html 和 main.dart.js ,對於每次的迭代,不涉及到 “殼工程” 變更時只需要 main.dart.js 即可。

選定了需要的產物後,在 DEF 平臺釋出前還需要對這兩個檔案進行一些處理:

  • html 中對 main.dart.js 的引用替換為相應迭代的cdn地址(根據迭代號、釋出環境拼接);
  • html 中 <base> 標籤修改,參考 https://docs.flutter.dev/deve...
  • js 和 html 檔案內註釋移除(def釋出門神檢查);
  • js 中替換 ?? 運算子(釘釘 H5 容器中該運算子報錯);
  • 將index.html 和 main.dart.js 移動到 DEF 平臺上的產物資料夾。

頁面釋出

在DEF平臺上,產物檔案處理完成後 js 和 html 檔案會被髮布到相應的cdn,同時html會被部署到特定的地址上:

預發:

線上:

對於線上環境index.html內容如下:

<!DOCTYPE html>
<html>
 <head> 
  <!-- 釋出到域名的二級目錄時使用 --> 
  <base href="/content_page/" /> 
 </head> 
 <body> 
  <!-- 替換為 main.dart.js 相應的 cdn 地址 --> 
  <script type="text/javascript" src="https://g.alicdn.com/algernon/alisupplier_content_web/1.0.10/main.dart.js"></script>  
 </body>
</html>

至此使用頁面部署地址就可以訪問到我們的目標頁面瞭如果頁面是一次性開啟的,且不需要在內部進行多頁面跳轉,到這一步釋出工作就完成了。如果涉及到多頁面跳轉,還需要將相關的內容釋出到自己的域名下,比較簡單的方式為配置重定向,除此之外直接引用產物也可:

  • 目標域名地址重定向:將自己域名下地址重定向到頁面部署地址,如將https://alisupplier.alibaba.c...對映到 https://market.m.taobao.com/a...。這樣def每次釋出後不需做額外修改。注意:要求FFW的UrlStrategy為hash tag方式(預設的UrlStrategy);
  • 目標域名地址重定向:在目標域名下建立 index.html 並引用 main.dart.js 檔案,或者目標頁面內嵌釋出的頁面。參照:https://docs.flutter.dev/depl...

程式碼除錯

基礎鏈路跑通後就可以進行需求的開發了,開發過程中比較重要的環境是程式碼的除錯,FFW 可在 Chrome 中以類似 App 的方式除錯且體驗較好。在 Debug 環境下在 IDE 中設定斷點後,即可在 IDE 中除錯斷點,也可在 Chrome 中檢視斷點,Chrome 中甚至可看到 dart 程式碼。以 VSCode 為例 Debug 過程和體驗如下:

啟動Flutter除錯

VSCode 和 Chrome 中可見的斷點

能力支援

進入到實際的開發中後,就需要諸如路由、介面請求等能力的支援了,首先是頁面路由和地址。

頁面路由和地址

在 FFW 應用中出現多頁面,或者需要通過 Http 連結傳參時,就需要進行相應的路由配置。類似FFA,可在根MaterialApp中配置相應的 Route,之後使用Navigator.push跳轉或通過頁面地址直接開啟頁面即可。如下程式碼可實現命名跳轉和頁面地址跳轉:

/// MaterialApp 配置
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      onGenerateRoute: RouteConfiguration.onGenerateRoute,
      onGenerateInitialRoutes: (settings) {
        return [RouteConfiguration.onGenerateRoute(RouteSettings(name: settings))];
      },
    );
  }
}
/// 命名路由配置
class RouteConfiguration {
  static Map<String, RouteWidgetBuilder?> builders = {
    /// 頁面A
    '/page_a': (context, params) {
      return PageA(title: params?['title']);
    },

    /// 頁面B
    '/page_b': (context, params) {
      return PageB(param1: params?['param1'], param2: params?['param2']);
    },
  };

  static Route<dynamic> onGenerateRoute(RouteSettings settings) {
    return NoAnimationMaterialPageRoute(
      settings: settings,
      builder: (context) {
        var uri = Uri.parse(settings.name ?? '');
        var route = builders[uri.path];

        if (route != null) {
          return route(context, uri.queryParameters);
        } else {
          return CommonPageNotFound(routeSettings: settings);
        }
      },
    );
  }
}

配置完成後即可在頁面能進行跳轉,或者通歐冠瀏覽器地址直接跳轉:

  • 應用內跳轉:配置完成之後,在應用內部可通過Navigator跳轉到目標頁面
/// Navigator 跳轉頁面 B
Navigator.of(context).restorablePushNamed('/page_b?param1=123¶m2=abc');
  • 地址跳轉:在瀏覽器位址列中輸入頁面的地址跳轉到頁面
/// 頁面 B 訪問地址
https://xxx.xx/#/page_b?param1=123¶m2=abc

注意:上述地址跳轉方式要求 FFW 的 UrlStrategy 為 hash tag 方式(預設的UrlStrategy)。

Web 平臺的 Native —— JS 呼叫

通過使用 pub.dev 等倉庫,可以在 FFW 中輕鬆的使用各種能力。對於倉庫中沒有的能力就要考慮進行擴充套件了。在 FFA 上可通過外掛的方式使用 native 的能力,同樣在 FFW 上可通過擴充套件使用 js 的能力。通過呼叫 js 的能力前端海量的技術積累便可應用到 FFW 上。

FFW 中的 dart 最終會編譯成 js ,在 FFW 中理應可以天然使用 js。為了在 dart 中支援 js 的呼叫,dart 官方釋出了 js 庫,通過使用該庫中的註解可是很方便的在 dart 中呼叫 js。

比如需要呼叫 alert 方法時,進行如下定義:

/// 檔案:js_interface.dart

/// 呼叫js方法的工具類庫,需在 dependencies 中引入 js 庫
@JS()
library lib.content;

import 'package:js/js.dart';

/// alert 彈窗
@JS('alert')
external void jsAlert(String msg);

之後在需要alert的地方引入js_interface.dart 並呼叫 jsAlert方法即可:

import 'package:mtest/js_interface.dart';

...
jsAlert('測試訊息');
...

更多用法詳見 pub.dev 中 js 庫的說明:https://pub.dev/packages/js。打通了 js 的能力後,接下來的很多問題迎刃而解。

Mtop介面

鑑於 App 端現有 Mtop (阿里App使用的一種閘道器)的建設,如果能在 FFW 中呼叫現有的 Mtop 將可以減少很多的工作量。為此需要為 FFW 新增 Mtop 呼叫的能力,要完成這個工作需要兩部分的工作:

  • FFW 端支援 Mtop 呼叫
  • 服務端支援 H5 方式的 Mtop 呼叫

FFW 支援 Mtop

通過呼叫 mtop.js 的能力便可在 FFW 中引入 mtop 的能力。整體流程如下:

1、在index.html 中引入 mtop.js

<script src="//g.alicdn.com/mtb/lib-mtop/2.6.1/mtop.js"></script>

2、定義介面檔案 js_mtop_interface.dart

@JS()
library lib.mtop;

import 'package:js/js.dart';
import 'package:js/js_util.dart';
import 'dart:convert';
import 'dart:js';

/// mtop請求的引數
@anonymous
@JS()
class MtopParams {
  external String get api;
  external String get v;
  external dynamic get data;
  external String get ecode;
  external String get dataType;
  external String get type;
  external factory MtopParams({
    required String api,
    required String v,
    required dynamic data,
    required String ecode,
    required String dataType,
    required String type,
  });
}

/// lib.mtop 請求函式
@JS('lib.mtop.request')
external dynamic _jsMtopRequest(MtopParams params);

/// dart map 轉為 js 的 object
Object mapToJSObj(Map<String, dynamic> a) {
  var object = newObject();
  a.forEach((k, v) {
    var key = k;
    var value = v;
    setProperty(object, key, value);
  });
  return object;
}

/// mtop js 請求介面
Future mtopRequest(String api, Map<String, dynamic> params, String version, String method) {
  var jsPromise = _jsMtopRequest(
    MtopParams(
      api: api,
      v: version,
      data: mapToJSObj(params),
      ecode: '0',
      type: method,
      dataType: 'json',
    ),
  );
  return promiseToFuture(jsPromise);
}

/// 返回結果解析使用
@JS('JSON.stringify')
external String stringify(Object obj);

3、進行 mtop 介面呼叫

import 'package:mtest/mtop/js_mtop_interface.dart';

...
try {
   var res = await mtopRequest(apiName, params, version, method);
   print('res $res');
} catch (err) {
   data = stringify(err);
}

4、解析結果:介面請求返回的結果是一個 jsObject,可通過 js 方法 JSON.stringify 轉成 json 後在 dart 層面使用

String jsonStr = stringify(res);

服務端 H5 Mtop 配置

FFW 中接入 mtop.js 後,需要對目標 mtop 介面進行相應的處理才可呼叫:

  • mtop 釋出 h5 版本
  • 申請配置CORS域名白名單

打點

同 mtop 請求,FFW 中可引入黃金令箭的 js 庫進行打點,流程如下:

1、index.html 引入 js 檔案

<script type="text/javascript" src="https://g.alicdn.com/alilog/mlog/aplus_v2.js"></script>

2、定義介面檔案 js_goldlog_interface.dart

@JS()
library lib.goldlog;

import 'package:js/js.dart';

/// record 函式
@JS('goldlog.record')
external dynamic _jsGoldlogRecord(String t, String e, String n, String o, String a);

void goldlogRecord(String logkey, String eventType, String queryParams) {
  _jsGoldlogRecord(logkey, eventType, queryParams, 'GET', '');
}

3、打點呼叫

import 'package:mtest/track/js_goldlog_interface.dart';

...
goldlogRecord(logkey, eventType, queryParams);
...

之後在 log 平臺進行相應的點位配置即可。

監控

監控能力接入較為簡單,這裡選擇 arms(應用實時監控服務),直接在 index.html 中引入 arms 即可。流程如下:

1、在 index.html 中引入相關庫

<script type="text/javascript">
    var trace = window.TraceSdk({
      pid: 'as-content-web',
      plugins: [
        [window.TraceApiPlugin, { sampling: 1 }],
        [window.TracePerfPlugin],
        [window.TraceResourceErrorPlugin]
      ],
    });
    // 啟動 trace 並監聽全域性 JS 異常,debug時問題暫時註釋
    trace.install();
    trace.logPv();
</script>

2、在 arms 平臺上進行相關配置

注意:trace.install() 在 Debug 環境下會導致頁面不展示,可在 Debug 環境中禁用。

優化和相容

完成了上述基礎能力建設後,FFW 基本可滿足簡單需求的開發。需求開發之外還需考慮體驗、相容性等問題。

載入優化

FFW 從釋出至今都存在的一個問題就是包大小問題,對與一個空的 helloworld 工程,單 js 包大小是 1.2 MB(未壓縮前),在移動裝置上網路不好的時候可能需要載入好些秒。為了提升頁面載入的體驗,考慮可以做的事情如下:

等待過程優化

FFW 頁面在 js 載入完成之前都是白屏,給人一種頁面卡死的感覺,為此可以在 js 載入完成前增加載入動畫不至於讓頁面一直白屏。參考App上管用的做法,可在資料載入出來之間插入骨骼屏,實現如下:

 <iframe src="https://g.alicdn.com/algernon/alisupplier_content_web/0.9.1/skeleton/index.html" id="iFrame" frameborder="0" scrolling="no"></iframe>
  <script>
    function setIframeSize() {
      <!-- 骨骼屏尺寸設定,佔滿全屏 -->
    }
    function removeIFrame() {
      var iframe = document.getElementById("iFrame");
      iframe.parentNode.removeChild(iframe);
    }
    setIframeSize();
</script>

  <!-- load 完成之後移除骨骼屏 -->
  <script type="text/javascript" src="https://g.alicdn.com/algernon/alisupplier_content_web/1.0.10/main.dart.js" 
    onload="removeIFrame()"></script>

TODO JS拆包&優化

等待過程優化可在一定程度上提升等待體驗,單治標不治本,要想載入快還得讓載入的資源小,對於多頁面應用,可以將整個 main.dart.js 拆分成多個小的包,在使用的過程中逐步載入,目前瞭解到美團有相應的技術,但實現細節未知,有待研究。可參考 https://github.com/flutter/fl...

相容問題

類似 App 在不同裝置上會有體驗問題,FFW 在不同的 H5 容器中頁會存在相容問題,在我們的實踐中不同 H5 容器踩坑記錄如下:

釘釘H5容器內白屏問題:

  • 不支援 ?? 語法,替換後解決
  • FFW產物js中包含大量try{}finally{}無catch操作,在釘釘H5容器中會報錯,打包時使用指令碼統一替換解決

微信H5容器內白屏問題:

  • 移除MaterialIcons,改用圖片代替

iOS 15 上頁面卡死問題:

iOS相容性問題:

  • 可點選的RichText,設定下劃線屬性後,緊跟著圖片的連結會被遮擋,暫未找到解法,只能先不使用RichText自帶的下劃線
  • 可點選的RichText點選後螢幕會自動滾動。驗證為InteractiveSelectionu屬性導致,設定為false後表現和Android一致

其他問題

除了 H5 容器的相容問題外,在實踐中還遇到 FFW 自身的一些問題,記錄如下:

provider 庫問題:

  • provider 庫中 /lib/src/provider.dart ProviderNotFoundException類toString()方法中包含一個巨長的錯誤說明String,該String編譯後的js語法會出錯,刪除後即可

JsonConverter問題:

  • JsonConverter().convert 執行時會報錯,謹慎使用,dart array 轉 js array 可手動轉換

TODO 的內容

當前實踐中只完成了業務可用的一個小閉環建設,FFW 中仍有很多 TODO 的內容,如下:

工程構建:

  • DEF 雲端構建:經嘗試DEF雲端構建平臺安裝 Flutter 環境的時候對阿里外內容的請求都會 403,而 Flutter 中有很多內容需要線上拉取,如 Flutter 根目錄下 packages 中的內容,目前使用本地構建,待解決;
  • 本地debug時mtop訪問:mtop請求需配置CORS白名單且埠需是80,本地debug時使用的是ip、埠為一個隨機數,強行設定時報無權操作,目前只能本地執行http伺服器設定host後在chrome中debug,斷點debug待解決。

基礎功能:

  • 視訊、音訊播放能力待研究

相容和優化

  • js 包拆分載入待研究
  • 自定義字型檔案優化待研究

暢想:

  • App 中 Flutter 動態化:將 App 內的 Flutter 頁面替換為 FFW,做成類似 weex 的動態化方案
  • App WebApp 化:Flutter 實現的 App 通過 FFW 可以低成本轉成 WebApp,解決諸如 App 沒 Mac 版本的問題

關注【阿里巴巴移動技術】微信公眾號,每週 3 篇移動技術實踐&乾貨給你思考!

相關文章