一、背景
1.1 業務背景
美團外賣商家端業務圍繞數百萬商家,在 PC 和 App 上分別提供了交易履約、運營、廣告、營銷等一系列功能,且經常有外投 H5 的場景(如外賣學院、商家社群、營銷活動等)。在這種多形態的業務場景下,如何保障多端體驗的一致性,以及如何提升多端迭代的效率,一直是商家端產研關注的重點。
1.1.1 保障多端體驗一致性
由於端能力的不同,導致了業務在 App 和 Web 上存在較大的表現差異,例如:App 上自帶動畫轉場,而在 Web 中的實現成本卻較高,往往也就降級捨棄了這部分功能。此外,即使我們可利用公司內部的 Roo、MTDUI 等多端 UI 元件庫來儘量抹平各端的 UI 差異,但由於元件庫在各端的實現不盡相同,很難做到完美的一致性體驗。
1.1.2 提升多端迭代效率
由於各端技術體系的不同,涉及多端的需求往往需要不同的開發、測試團隊各自完成開發、聯調、測試、上線等流程,佔用資源巨大,在各團隊不可並行支援的情況下,甚至可能導致整個業務交付週期被拉長。雖然 React Native、Flutter 等跨平臺方案解決了一部分複用的問題,但顯然在商家端業務場景下是遠遠不夠的,我們的目標是要達到全平臺(Android、iOS、PC、H5)複用,最大化地提升多端的迭代效率。
1.2 技術背景
1.2.1 Flutter 在美團外賣商家端的儲備
MTFlutter 是美團外賣搭建起的公司級 Flutter 研發生態,它的架構圖如下圖所示:
如圖所示,MTFlutter 已涵蓋研發、除錯、測試、釋出、線上運維及工程管理整套閉環,同時落地了動態化解決方案,支撐了公司多個業務發展。在大前端融合的趨勢下,美團外賣商家端持續在探索更優的多端複用方案,通過 MTFlutter 生態的建設,目前 Flutter 技術棧已覆蓋商家端 App 中 90%以上的業務,同時具備 Flutter 開發能力的同學也達到 90% 以上。因此,在有足夠技術“儲備”的前提下,我們能夠基於 Flutter 做全平臺(Android、iOS、PC、H5)複用的探索。
1.2.2 Flutter Web 的支援
2018 年 Google 首次公開 Flutter Web Beta 版,旨在進一步實現一份程式碼、多端執行的願景。目前,Flutter Web 已被正式合入 Master,期間經過無數工程師的努力,Flutter Web 已能提供與 Flutter Natvie 較統一的互動行為和視覺體驗。
如上圖可知,Flutter Web 與 Flutter Native 的整體架構相似,二者共用 Framework 層(綠色部分),提供了包括動畫、手勢、基礎 Widget 類,以及大部分應用所需的 Material/Cupertino 主題 Widget 集合。區別在於:Flutter Web 重寫了 dart:ui 層(黃色部分),利用 DOM、Canvas 對齊了 Flutter Native 的 UI 渲染能力,使得 Flutter 編寫的 UI 能夠在現代瀏覽器上正常展示。
此外,得益於 dart2js 這個早已成熟的工具,Dart 邏輯能夠很容易的轉換為 JavaScript,進而在 Web 中被正常執行。
二、面臨的挑戰
綜上所述,我們選擇基於 Flutter Web 探索跨端(App\PC\H5)解決方案,真正實現“Write Once & Run AnyWhere”。當然,面臨挑戰也是巨大的,主要體現在 Flutter 和 MTFlutter 現階段對 Web 支援還不是很充足。
2.1 Flutter Web 現狀
Google 官方目前對 Flutter Web 的工作主要還集中在 dart:ui(Web)的對齊,工程化和效能相關的事項做的還比較少,例如:
- Flutter Web 構建產物較簡陋,只是簡單的輸出 main.dart.js(1.1M,未 Gzip) 和 圖片等靜態資源,缺少 JS 拆包、檔案 Hash、資源上傳 CDN 等優化工作,極大影響了頁面的載入效能。
- 由於 Flutter Web 自身實現了一套頁面滾動機制,頁面滾動過程中,會頻繁計算位置資訊,引起滾動區域內容被重新建立,最終導致頁面滾動效能較差。
2.2 MTFlutter 現狀
雖然 MTFlutter 做了諸多 Flutter Native 層面的定製與優化,但在 Flutter Web 上的建設才剛起步,具體表現在:
- MTFlutter 現有的基礎依賴如:Request(請求封裝)、Router(路由)、埋點、容器橋、前端監控,尚未支援在 Web 中的實現。
- MTFlutter 已實現了完整的 Flutter Module 的打包釋出流程,但並不支援 Web 的構建與部署。
三、整體設計
上圖為 MTFlutter + Web 架構圖,由圖可知 Flutter Web 頁面要滿足投產要求,還有大量的工作(上圖黃色部分所示),主要包括:
- 擴充套件基礎依賴(如:Request、Router、埋點等)在 Web 側的支援。
- 完善工程化建設,例如:靜態資源優化、構建與部署自動化。
- 深入滾動效能與頁面載入效能優化,使得 Flutter Web 能夠滿足基本的投產要求。
四、詳細設計
4.1 基礎依賴建設
企業級應用的基礎開發依賴 (如:請求庫、路由庫、埋點庫等),要重新在 Flutter 中用 Dart 搭建一套,時間成本、相容性、風險等都是不可控的。而 MTFlutter 是基於原有 Native 基礎依賴開發的 Plugin,因此並不支援 Web 端。此章節將展開介紹如何絲滑無感地擴充套件 MTFlutter 基礎依賴在 Web 端的實現。
4.1.1 Flutter Package 分平臺程式設計
在 Flutter 中通過使用 Package 可以建立易於共享的模組化程式碼。官方強烈推薦使用 Package 形式管理各種工具方法。在官方定義中 Package 包含以下兩種類別:
- Dart Package:用 Dart 編寫的常規 Package,其中一些可能包含依賴於 Flutter 框架的特定功能,其使用範圍僅限於 Flutter,例如 path。
- Plugin Package:用 Dart 編寫 API 多個平臺各自實現的特殊 Dart Package。Plugin Package 可以為 Android(使用 Kotlin 或 Java)、iOS(使用 Swift 或 Objective-C)、Web、macOS、Windows 或 Linux 或其任意組合編寫外掛包。
下面分別對這兩種型別 Package 中如何分平臺程式設計進行介紹。
(1) Dart Package
Dart Package 是純 Dart 編寫,因此大部分程式碼均可由 dart2js 直接編譯出 Web 平臺可執行的程式碼,但某些涉及 Native 能力的庫 (如 dart:io)是無法被轉譯的,因此需要有對平臺進行相容的方法,下面介紹兩種在 Dart Package 中分平臺程式設計的方案。
程式碼級別分平臺
針對程式碼級別的分平臺,我們可以藉助 Flutter SDK 提供的一個常量 kIsWeb。使用方法如下:
檢視原始碼可知,kIsWeb 之所以能被用於判斷 Web 平臺,是利用了 JavaScript 不支援整型的特徵,在 Web 環境下,Dart 的 double 和 int 由相同型別的物件支援,浮點數 "0.0" 等於整數 "0",對於在 AOT 或 VM 上執行的 Dart 程式碼卻並非如此。
import 'package:flutter/foundation.dart';
if (kIsWeb) {
print('Web 端')
} else {
print('其他端');
}
複製程式碼
檔案級別分平臺
針對檔案級別分平臺,我們利用條件匯入匯出,其中條件匯出具體用法如下:
// tool.dart
export 'src/tool_native.dart' // 兜底匯出,即沒有命中條件時匯出的檔案
if (dart.library.html) 'src/tool_web.dart'; // web 端匯出的檔案,該檔案中可以使用 dart:html,也可以通過判斷 dart.library.js 匯出 Web 端檔案。
複製程式碼
// 引入 tool.dart
import 'package:tool/tool.dart';
void main() {
print('import tool');
}
複製程式碼
條件匯入和條件匯出類似,僅需將 export 改為 import 即可。在業務開發中這也是一種非常實用的分平臺程式設計方法。
(2) Plugin Package
Plugin Package (下文簡稱為 Plugin) 在 Android 和 iOS 平臺都是通過 MethodChannel 實現在 UI 層和 Platform 層傳遞訊息從而達到特定平臺支援的,官方文件中也全方位介紹了在 Android 和 iOS 平臺的具體實現方法及例子,Web 平臺的實現卻介紹的較少。總結起來,Web 平臺和 Native 平臺實現方式的不同主要集中在下面兩點。
首先,Web Plugin 推薦的方式不是以其平臺特有的 JS 語言實現,而是通過 Dart Library 或 Package 實現,對於已有現成可用的 JS SDK 或需要大量使用 JS 實現功能的情況下,官方提供了 package:js 包呼叫 Javascript,從而實現與 Javascript 的互動。
其次,Web Plugin 不是通過註冊 MethodChannel 傳遞訊息的,Flutter 內部可直接呼叫通過官方指定形式 (Federated Plugin) 編寫的 Flutter Web Plugin 類。
下圖完整的展示了一個 Plugin 的整體架構:
4.1.2 基礎依賴建設
整體來講,MTFlutter 基礎依賴都是使用 Plugin 的形式開發維護的。為處理依賴中的公共邏輯,提高 Plugin 的可擴充套件性,MTFlutter Plugin 在 Flutter Plugin 架構(各平臺原生實現層和 Plugin Interface 層)之上又增加了公共邏輯處理層,最終暴露給使用者是 Plugin API 層提供的介面。MTFlutter Plugin 架構圖如下:
在細節實現上,由於專案中各種依賴的型別之間存在著差異,因此在依賴處理上也略有不同,下面介紹擁有不同特點的依賴所對應解決方案。
(1)各平臺實現能在 Web 側對齊的場景,如埋點庫
埋點庫無論在 Native 端還是在 Web 端都是使用公司統一提供的 SDK,在 API 設計上具有天然的一致性,因此我們完全有能力在 Plugin Interface 層對齊所有介面,上層業務邏輯只需按需做些相容處理即可。埋點庫 Web 端擴充套件的整體設計思路如下:
- 在業務專案的 web/index.html 檔案中直接引入 Script 指令碼並且進行初始化 (注意:引入 Script 的位置,需要放在 main.dart.js 前面)。
- 藉助 package:js 庫呼叫埋點 JS SDK,對齊 Flutter 埋點庫的 API ,實現 Flutter Plugin 的 Web 端支援,詳細架構圖如下圖所示:
(2)各平臺實現在 Web 側無法對齊的場景,如路由庫
MTFlutter 路由庫是 Native 底層維護的一套全新的路由體系,依靠原生支援提供了強大的定製化功能,而在 Web 端無法這些無法在各平臺原生實現層達到 100% 支援。由於 MTFlutter Plugin 最終暴露的是 Plugin API,因此我們選擇直接對齊 Plugin API 實現路由庫在 Web 端的支援(藉助 Flutter Navigator、dart:html 用純 Dart 語言完成了擴充套件),詳細架構如下圖所示:
(3)Web 端需要通過大量 JS 實現功能的依賴庫,如請求庫
由於在現有的 Web 請求中統一封裝著大量的業務處理邏輯(如攔截器、異常上報等),如果用 Dart 重新實現一遍,成本還是較高的。想複用原有基於 Axios( JS 請求庫) 封裝的請求庫就相當於讓 Plugin 的 Web 平臺實現使用 JS 語言。Dart 和 JS 互動是通過 package:js 進行介面呼叫,因此我們在公共邏輯處理層用 Dart 對齊了相應的 API,詳細架構圖如下圖所示:
4.2 效能優化
常規的 Web 專案中,為了保證頁面有更好的載入和渲染效能,在靜態資原始檔的處理方面,我們需要做很多的工作,例如:資原始檔 Hash 化、CDN 化、按需載入處理等,這些可以通過 Webpack、Rollup 等構建工具進行預處理。但在 Flutter Web 中,這些預處理的操作目前官方還不支援,原因是 Flutter 暴露給我們的命令只有一個 flutter build web
,導致我們無法直接進行更細粒度的個性化定製。如果想要讓 Flutter Web 達到企業級應用的標準,我們需要更深層次的探索 Flutter SDK 的執行原理。下面我們列出目前遇到的效能問題及其解決方案。
4.2.1 目前存在的效能問題
Google 官方對 Flutter Web 效能優化所做的事項還比較少,編譯輸出的頁面存在較大的效能問題,主要體現在以下兩方面:
- 首屏渲染時間長。即使使用了 FutureBuilder 把業務程式碼拆分成 xxx.part.js 之後,main.dart.js 體積依然維持在 1.1M。單一檔案載入、解析時間過長,且靜態資源缺少 CDN 化的支援,勢必會影響首屏的渲染時間。
- 滾動效能較差。 Flutter Web 自身實現了一套頁面滾動機制,在頁面滾動過程中,會頻繁的建立 Canvas,最終導致滾動效能問題,甚至引起頁面 Crash。
通過下圖對瀏覽器網路監控情況的展示,可以清晰的反映出以上問題:
為了解決上述的效能問題,我們探索了 Flutter SDK 編譯過程,總結出從 Flutter 業務程式碼到 Web 產物的整體流程,詳細流程如下圖所示:
從流程中我們可以看到,Flutter 在 Web 端目前只支援 Dart-->JS 的轉換,以及 UI 層的對齊,在工程化和效能優化方面做的工作並不多。
因此,我們必須解決以上的效能問題,才能保證我們的業務可以正常的交付。通過對編譯流程的仔細分析與梳理,我們在 AOT 產物生成之前對 Flutter SDK 進行定製,分別進行載入效能優化和記憶體效能優化,下面分別介紹這兩部分的內容。
4.2.2 載入效能優化
執行 flutter build web
命令之後,我們得到的主要靜態資源有:主檔案 main.dart.js(1.1M),各頁面的業務程式碼 xxx.part.js(使用 FutureBuilder 後)、圖片檔案。直接應用這些資源到專案中,會遇到以下問題:
- 功能無法及時更新:瀏覽器對同名檔案的快取,可能導致程式程式碼不被及時更新或者出現執行錯亂。
- 首屏渲染效能差:main.dart.js 檔案過大,單一檔案載入、解析時間過長,勢必會影響首屏的渲染時間。
- 無法使用 CDN:Flutter 僅支援相對路徑的載入方式,無法使用當前域名以外的 CDN 域名,導致無法享受 CDN 帶來的優勢。
為此,在載入部分我們對 Flutter SDK 增加了如下三方面的優化,以達到線上執行的標準,優化步驟如下圖所示:
資原始檔 Hash 化
除了 web/index.html 檔案之外,我們要對所有的引用到檔案進行 Hash 化。對 build_system/web.dart 的修改按以下步驟進行:
- 遍歷產物目錄,並建立 ResourceMap。
- 分別計算每個檔案的 Hash 值。
- 為新檔案命名為 name-[hash].xxx。
- 修改新檔名在對應檔案中的引用關係。
大檔案分片
Flutter Web 編譯之後會生成 main.dart.js 這一主檔案,體積為1.1M( Gzip 之後約 400K ),這給頁面的載入效能帶來很大的影響。為此,我們對程式碼進行分片,藉助瀏覽器對多檔案並行載入的特性,可以有效提升頁面的載入效能。
具體實施步驟是:將 main.dart.js
在 Dart 側拆分成多份純文字檔案,前端通過 XHR 的方式並行載入並按順序拼接成 Javascript 程式碼置於 <script> 標籤中,從而實現分片檔案的並行載入。
資原始檔 CDN 化
由於 Flutter Web 資源引用機制的不同,即使在資原始檔 Hash 化的過程中,把檔案的相對路徑替換成帶 CDN 域名的絕對路徑,也無法實現 CDN 資源的載入。同時本地測試發現圖片和 Javascript 資源的載入邏輯還不盡相同,為此針對各自的載入邏輯要分別進行優化。
- 圖片處理:經過對原始碼的大量閱讀及梳理,我們發現圖片請求的 URL 首先會讀取
meta
標籤中assetBase
值進行 URL 路徑拼接,根據拼接好的 URL 來獲取資源。目前,在專案web/index.html
模板檔案中並沒有meta
標籤,於是就會根據相對路徑進行請求。解決方案是在編譯過程中,根據請求環境增加meta
標籤並把content
設定為 CDN 路徑。 - JavaScript 處理:為了解決圖片資原始檔的載入問題,我們雖然增加了
assetBase
的meta
標籤,但發現xxx.part.js
檔案依然使用當前域名進行載入,可見 Javascript 資源的載入和圖片資源載入的邏輯不盡相同。對main.dart.js
原始碼分析,我們發現請求xxx.part.js
的域名取決於包含main.dart.js
內容的Script
標籤的src
屬性。通過對js_helper.dart
的動態編譯,我們把讀取src
屬性修改為讀取window.assetBase
這一全域性變數(meta
標籤中assetBase
值加工後的變數)來實現xxx.part.js
檔案的 CDN 載入。
4.2.3 滾動效能優化
當頁面出現可滾動區域時,每次頁面滾動會建立大量的 Canvas。使用 Safari 的 Canvas 分析工具,我們發現問題的根本原因是頁面滾動的過程中,Flutter 會頻繁的建立滾動區域的 Canvas,每次建立的 Canvas 記憶體都在10~70M 不等,滾動的內容越多,記憶體的佔用就會越大,這樣滾動幾幀之後,記憶體的佔用就會超過瀏覽器的閾值。
Flutter 對 Canvas 的管理有一個 ReusablePool 的概念,在初始過程中會建立一定的數量的 Canvas,頁面互動過程中沒有變化的部分,會優先使用 pool 中已經快取過的 Canvas 以便能夠節省記憶體。由於 Flutter Web 自身實現了一套頁面滾動機制,頁面滾動過程中,會頻繁計算位置資訊,引起滾動區域內容被重新建立,這就是為什麼每次滾動都會建立 Canvas 的原因。
我們設計的解決方案是:修改 FlutterSDK,在滾動的過程中定義一個閾值,當滾動的高度在閾值範圍內,我們就會把當前的 Canvas 快取起來。這樣選擇性的建立和銷燬 Canvas 可以有效的緩解記憶體壓力,從而提升頁面滾動效能。
4.3 構建與部署
4.3.1 Docker 映象定製
由於 MTFlutter Web 環境安裝步驟較固定,且整個安裝過程耗時較長 ( > 80s ) 。因此將其定製為 Docker 映象並整合至 Talos,Flutter Web 編譯階段便能免去安裝流程,有效提升構建效率。Docker 映象定製和釋出的詳細流程見官方文件,本文不再贅述。其中用於定製 Flutter Web 映象的 Dockerfile 檔案如下:
FROM $BaseImage \# 繼承基礎映象
RUN apt-get update
RUN apt-get install rubygems -y
RUN gem install flutter-cli
RUN flutter-cli install
ENV PATH="/$User/.flutter_sdk/bin:${PATH}"
ENV PUB\_HOSTED\_URL="https://xxx.com" \# 私有pub服務
ENV FLUTTER\_STORAGE\_BASE_URL="https://storage.flutter-io.cn"
RUN ~/.flutter_sdk/bin/flutter config --enable-web
複製程式碼
4.3.2 持續交付與部署
為了實現持續交付與部署,我們建立起了 Flutter Web 在 Talos(美團內部前端持續交付解決方案) 中的釋出流水線:
可以看到,流水線中已經免去了 MTFlutter Web 環境的安裝流程,現有流水線中重要節點介紹如下:
- Flutter-Web-Build 利用 Docker 內建的 MTFlutter 進行 Web 編譯。
- Flutter-Web-Publish 負責將編譯產物上傳美團資源儲存伺服器。
五、成果展示
5.1 效果展示
我們在美團外賣商家學院(一個以文章、視訊等形式幫助商家學習外賣運營知識、瞭解行業發展和平臺策略的平臺,它有很強的傳播屬性,具有外部投放的場景)率先落地了 Flutter Web,現以商家學院視訊內容頁為例,對比 Flutter Native 和 Flutter Web 的展現效果:
可以看出,兩者的互動、視覺體驗是高度一致的,既保證了業務在 App 內接近 Native 的體驗,又極大提高了 Web 與 Flutter Native 的體驗一致性。
5.2 頁面載入效能
如前文所述,我們實施了一系列針對 Flutter Web 的資源優化手段,使得頁面載入效能有較大提升,其中頁面完全載入時間大致由 1300ms (TP50) 降到了 580ms(TP50),更多的效能指標資料見下圖:
可以看到 Flutter Web 與現有 Web 專案效能指標資料差距已不大,可滿足日常業務要求。但載入效能資料仍有較大的優化空間,我們會持續對其進行探索。
5.3 滾動效能
針對滾動優化,我們通過修改 Flutter SDK,使得 Canvas 在頁面滾動時無需重複建立,而是被快取起來。這樣大大節省了記憶體的開銷(優化後頁面記憶體佔用穩定為 100M 左右,與常規 Web 頁面無異),同時在一定程度上提升了滾動效能。以商家學院文章內容頁為例,對比優化前後滾動 FPS :
可以看到,Flutter Web 頁面滾動效能已得到較大提升,足以應對大部分業務場景。但由於 Flutter Web 頁面滾動過程中會頻繁進行位置資訊的計算,在複雜的業務場景(如頁面存在大量動畫) 仍然會暴露出一定的問題。因此對滾動效能的進一步優化也會是我們未來的工作重心。
5.4 業務迭代效率
基於團隊對 Flutter Web 工程化能力的建設和 Flutter 良好的跨平臺特性,Flutter Web 在美團外賣商家學院改版需求的落地,大大提升了迭代效率,估算人效提升 40% 以上,計算公式為:
其中 E 代表人效提升,Ci 指的是相容和適配所耗費的時間,Np 表示業務跨端數量,目前美團外賣商家學院在 Native 和 H5 兩端完成了複用,後續在 PC 側需求的對齊中,效率提升數值會被放大,預計人效提升達 60% 以上。同時我們將在更多的業務中進行推廣與應用,提升整體業務的迭代效率。
六、總結與展望
綜上所述,美團外賣商家端多元的業務形態和足夠的技術“儲備”,使得基於 Flutter 實現多端複用成為了可能。而 Flutter Web 在美團外賣商家學院業務中也取得了階段性的成果,實現了 App、H5 側的體驗一致性,為後續推動更多業務線實現 App-Web 一體化打下了堅實的基礎。
可以預見的是,基於 Flutter Web 實現的多端複用,勢必會有效縮短專案交付週期。但由於我們對頁面載入效能、滾動效能做的仍不夠完美,不足以應對更加複雜的業務場景,因此我們依然還有許多工作:
- 頁面滾動效能優化: 由於 Flutter 與 Web 的佈局差異,使得 dart:ui ( Web ) 也受 Flutter Native 的佈局約束,如何打破這樣的約束,是解決滾動效能問題的關鍵。
- 頁面載入效能優化: 當前的頁面載入效能仍有較大優化空間,需要對 Flutter 進行編譯干預與優化(如按需分離 main.dart.js),減小資源包大小,有效提升頁面載入效能。
- Flutter Web 基建:完善並優化開發、除錯、編譯、構建、部署鏈路,使得新老專案能快速接入 Flutter Web。
- Flutter Web 在 PC 側的複用:與 UED 團隊共同制訂 PC 與 App 適配規範,同時基於 Dart2js 和 dart:ui (Web)的強大能力,實現邏輯的抽象,完成元件、模組的適配,達到提效最大化;
- 跟進 Flutter 官方動向:Flutter 2.0 的釋出,穩定了對 Web 的支援,同時預設採用 Canvaskit 編譯模式,此模式下對頁面滾動效能有較大提升。但由於 canvaskit.wasm 檔案過於龐大(2.5M),降低了載入效能,因此目前仍不建議在 Web 側直接使用 Canvaskit。不過官方承諾會在 2021 年對效能進行整體優化,還是值得期待的,我們也將保持跟進和溝通。
我們會持續基於 Flutter Web 做更多的探索和嘗試。如果您對 Flutter Web 也感興趣,歡迎大家在文末評論區留言或者給出建議,非常感謝。
閱讀美團技術團隊更多技術文章
前端 | 演算法 | 後端 | 資料 | 安全 | 運維 | iOS | Android | 測試
| 在公眾號選單欄對話方塊回覆【2020年貨】、【2019年貨】、【2018年貨】、【2017年貨】等關鍵詞,可檢視美團技術團隊歷年技術文章合集。
版權宣告
本文系美團技術團隊出品,著作權歸屬美團。歡迎出於分享和交流等非商業目的轉載或使用本文內容,敬請註明“內容轉載自美團技術團隊”。本文未經許可,不得進行商業性轉載或者使用。任何商用行為,請傳送郵件至tech@meituan.com申請授權。