點(diǎn)擊“ 開(kāi)發(fā)者技術(shù)前線 ”,選擇“星標(biāo)” 讓一部分開(kāi)發(fā)者看到未來(lái) 來(lái)自:美團(tuán)技術(shù)團(tuán)隊(duì)
美團(tuán)外賣(mài)商家端基于 FlutterWeb 的技術(shù)探索已久,目前在多個(gè)業(yè)務(wù)中落地了App、PC、H5的多端復(fù)用,有效提升了產(chǎn)研的整體效率。在這過(guò)程中,性能問(wèn)題是我們面臨的最大挑戰(zhàn),本文結(jié)合實(shí)際業(yè)務(wù)場(chǎng)景進(jìn)行思考,介紹美團(tuán)外賣(mài)商家端在 FlutterWeb 性能優(yōu)化上所進(jìn)行的探索和實(shí)踐,希望對(duì)大家能有所幫助或啟發(fā)。 一、背景
1.1 關(guān)于FlutterWeb
1.2 業(yè)務(wù)現(xiàn)狀
二、挑戰(zhàn)
三、整體設(shè)計(jì)
四、設(shè)計(jì)與實(shí)踐
4.1 精簡(jiǎn) SDK
4.2 JS 分片
4.3 預(yù)加載方案
4.4 分平臺(tái)打包
4.5 圖標(biāo)字體精簡(jiǎn)
五、總結(jié)與展望
一、背景 1.1 關(guān)于FlutterWeb 時(shí)間回?fù)艿?2018 年,Google 首次公開(kāi) FlutterWeb Beta 版,表露出要實(shí)現(xiàn)一份代碼、多端運(yùn)行的愿景。經(jīng)過(guò)無(wú)數(shù)工程師兩年多的努力,在今年年初( 2021 年 3 月份 ),F(xiàn)lutter 2.0 正式對(duì)外發(fā)布,它將 FlutterWeb 功能并入了 Stable Channel,意味著 Google 更加堅(jiān)定了多端復(fù)用的決心。
圖1 FlutterWeb歷史 當(dāng)然 Google 的“野心”不是沒(méi)有底氣的,主要體現(xiàn)在它強(qiáng)大的跨端能力上,我們看一下 Flutter 的跨端能力在 Web 側(cè)是如何體現(xiàn)的:
圖2 Flutter跨端能力 上圖分別是 FlutterNative 和 FlutterWeb 的架構(gòu)圖。通過(guò)對(duì)比可以看出,應(yīng)用層 Framework 是公用的,意味著在 FlutterWeb 中我們也可以直接使用 Widgets、Gestures 等組件來(lái)實(shí)現(xiàn)邏輯跨端。而關(guān)于渲染跨端,F(xiàn)lutterWeb 提供了兩種模式來(lái)對(duì)齊 Engine 層的渲染能力:Canvaskit Render 和 HTML Render,下方表格對(duì)兩者的區(qū)別進(jìn)行了對(duì)比:
圖3 模式對(duì)比 Canvaskit Render 模式 :底層基于 Skia 的 WebAssembly 版本,而上層使用 WebGL 進(jìn)行渲染,因此能較好地保證一致性和滾動(dòng)性能,但糟糕的兼容性(WebAssembly 從 Chrome 57 版本才開(kāi)始支持 )是我們需要面對(duì)的問(wèn)題。此外 Skia 的 WebAssembly 文件大小達(dá)到了 2.5M,且 Skia 自繪引擎需要字體庫(kù)支持,這意味著需要依賴(lài)超大的中文字體文件,對(duì)頁(yè)面加載性能影響較大,因此目前并不推薦在 Web 中直接使用 Canvaskit Render( 官方也建議將 Canvaskit Render 模式用于桌面應(yīng)用 )。 HTML Render 模式 :利用 HTML + Canvas 對(duì)齊了 Engine 層的渲染能力,因此兼容性表現(xiàn)優(yōu)秀。另外,MTFlutterWeb 對(duì)滾動(dòng)性能已有過(guò)探索和實(shí)踐,目前能夠應(yīng)對(duì)大部分業(yè)務(wù)場(chǎng)景。而關(guān)于加載性能,此模式下的初始包為 1.2M,是 Canvaskit Render 模式產(chǎn)物體積的 1/2,且我們可對(duì)編譯流程進(jìn)行干預(yù),控制輸出產(chǎn)物,因此優(yōu)化空間較大。基于以上原因,美團(tuán)外賣(mài)技術(shù)團(tuán)隊(duì)選擇在 HTML Render 模式下對(duì) FlutterWeb 頁(yè)面的性能進(jìn)行優(yōu)化探索。 1.2 業(yè)務(wù)現(xiàn)狀 美團(tuán)外賣(mài)商家端以 App、PC 等多元化的形態(tài)為商家提供了訂單管理、商品維護(hù)、顧客評(píng)價(jià)、外賣(mài)課堂等一系列服務(wù),且 App、PC 雙端業(yè)務(wù)功能基本對(duì)齊。此外,我們還在 PC 上特供了針對(duì)連鎖商家的多店管理功能。同時(shí),為滿(mǎn)足平臺(tái)運(yùn)營(yíng)訴求,部分業(yè)務(wù)具有外投 H5 場(chǎng)景,例如美團(tuán)外賣(mài)商家課堂,它是一個(gè)以文章、視頻等形式幫助商家學(xué)習(xí)外賣(mài)運(yùn)營(yíng)知識(shí)、了解行業(yè)發(fā)展和跟進(jìn)經(jīng)營(yíng)策略的內(nèi)容平臺(tái),具有較強(qiáng)的傳播屬性,因此我們提供了站外分享的能力。
圖4 業(yè)務(wù)形態(tài) 為了實(shí)現(xiàn)多端( App、PC、H5 )復(fù)用,提升研發(fā)效率,我們于 2021 年年初開(kāi)始著手 MTFlutterWeb 研發(fā)體系的建設(shè)。目前,我們基于 MTFlutterWeb 完成提效的業(yè)務(wù)超過(guò)了 9 個(gè),在 App 中,能夠基于 FlutterNative 提供高性能的服務(wù);在 PC 端和 Mobile 瀏覽器中,利用 FlutterWeb 做到了低成本適配,提升了產(chǎn)研的整體效率。 然而,加載性能問(wèn)題是 MTFlutterWeb 應(yīng)用推廣的最大障礙。這里依然以美團(tuán)外賣(mài)商家課堂業(yè)務(wù)為例,在項(xiàng)目之初頁(yè)面完全加載時(shí)間 TP90 線達(dá)到了 6s 左右,距離我們的指標(biāo)基線值( 頁(yè)面完全加載時(shí)間 TP90 線不高于 3s,基線值主要依據(jù)美團(tuán)外賣(mài)商家端的業(yè)務(wù)場(chǎng)景、用戶(hù)畫(huà)像等來(lái)確定 )有些差距,用戶(hù)訪問(wèn)體驗(yàn)有很大的提升空間,因此 FlutterWeb 頁(yè)面加載性能優(yōu)化,是我們亟需解決的問(wèn)題。 二、挑戰(zhàn) 不過(guò),想要突破 FlutterWeb 頁(yè)面加載的性能瓶頸,我們面臨的挑戰(zhàn)也是巨大的。這主要體現(xiàn)在 FlutterWeb 缺失靜態(tài)資源的優(yōu)化策略,以及復(fù)雜的架構(gòu)設(shè)計(jì)和編譯流程。下圖展示了 Flutter 業(yè)務(wù)代碼被轉(zhuǎn)換成 Web 平臺(tái)產(chǎn)物的流程,我們來(lái)具體進(jìn)行分析:
圖5 FlutterWeb 編譯流程 Framework、Flutter_Web_SDK (Flutter_Web_SDK 基于 HTML、Canvas,承載 HTML Render 模式的具體實(shí)現(xiàn) )等底層 SDK 是可被業(yè)務(wù)代碼直接引入的,幫助我們快速開(kāi)發(fā)出跨端應(yīng)用;flutter_tools 是各平臺(tái)(Android、iOS、Web )的編譯入口,它接收 flutter build web 命令和參數(shù)并開(kāi)始編譯流程,同時(shí)等待處理結(jié)果回調(diào),在回調(diào)中我們可對(duì)編譯產(chǎn)物進(jìn)行二次加工;frontend_server 負(fù)責(zé)將 Dart 轉(zhuǎn)換為 AST,生成 kernel 中間產(chǎn)物 app.dill 文件(實(shí)際上各平臺(tái)的編譯過(guò)程都會(huì)生成這樣的中間產(chǎn)物 ),并交由各平臺(tái) Compiler 進(jìn)行轉(zhuǎn)譯;Dart2JS Compiler 是 Dart-SDK 中具體負(fù)責(zé)轉(zhuǎn)譯 JS 的模塊,它將上述中間產(chǎn)物 app.dill 進(jìn)行讀取和解析,并注入 Math、List、Map 等 JS 工具方法,最終生產(chǎn)出 Web 平臺(tái)所能執(zhí)行的 JS 文件。編譯產(chǎn)物 主要為 main.dart.js、index.html、images 等靜態(tài)資源,F(xiàn)lutterWeb 對(duì)這些靜態(tài)資源缺少常規(guī) Web 項(xiàng)目中的優(yōu)化手段,例如:文件 Hash 化、文件分片、CDN 支持等。可以看出,要完成對(duì) FlutterWeb 編譯產(chǎn)物的優(yōu)化,就需要干預(yù) FlutterWeb 的眾多編譯模塊。而為了提升整體的編譯效率,大部分模塊都被提前編譯成了 snapshot 文件( 一種 Dart 的編譯產(chǎn)物,可被 Dart VM 所運(yùn)行,用于提升執(zhí)行效率 ),例如:flutter_tools.snapshot、frontend_server.snapshot、dart2js.snapshot 等,這又加大了對(duì) FlutterWeb 編譯流程進(jìn)行干預(yù)的難度。 三、整體設(shè)計(jì) 如前文所述,為了實(shí)現(xiàn)邏輯、渲染跨平臺(tái),F(xiàn)lutter 的架構(gòu)設(shè)計(jì)及編譯流程都具有一定的復(fù)雜性。但由于各平臺(tái)( Android、iOS、Web )的具體實(shí)現(xiàn)是解耦的,因此我們的思路是定位各模塊( Dart-SDK、Framework、Flutter_Web_SDK、flutter_tools )的 Web 平臺(tái)實(shí)現(xiàn)并尋求優(yōu)化,整體設(shè)計(jì)圖如下所示:
圖6 整體設(shè)計(jì) SDK 瘦身 :我們分別對(duì) FlutterWeb 所依賴(lài)的 Dart-SDK、Framework、Flutter_Web_SDK 進(jìn)行了瘦身,并將這些精簡(jiǎn)版 SDK 集成合入 CI/CD(持續(xù)集成與部署 )系統(tǒng),為減小產(chǎn)物包體積奠定了基礎(chǔ); 編譯優(yōu)化 :此外,我們?cè)?flutter_tools 中的編譯流程做了干預(yù),分別進(jìn)行了 JS 文件分片、靜態(tài)資源 Hash 化、資源文件上傳 CDN 等優(yōu)化,使得這些在常規(guī) Web 應(yīng)用中基礎(chǔ)的性能優(yōu)化手段得以在 FlutterWeb 中落地。同時(shí)加強(qiáng)了 FlutterWeb 特殊場(chǎng)景下的資源優(yōu)化,如:字體圖標(biāo)精簡(jiǎn)、Runtime Manifest 隔離、Mobile/PC 分平臺(tái)打包等;加載優(yōu)化 :在編譯階段進(jìn)行靜態(tài)資源優(yōu)化后,我們?cè)谇岸诉\(yùn)行時(shí),支持了資源預(yù)加載與按需加載,通過(guò)設(shè)定合理的加載時(shí)機(jī),從而減小初始代碼體積,提升頁(yè)面首屏的渲染速度。下面,我們分別對(duì)各項(xiàng)優(yōu)化進(jìn)行詳細(xì)的說(shuō)明。 四、設(shè)計(jì)與實(shí)踐 4.1 精簡(jiǎn) SDK 4.1.1 包體積分析 工欲善其事,必先利其器,在開(kāi)始做體積裁剪之前,我們需要一套類(lèi)似于 webpack-bundle-analyzer 的包體積分析工具,便于直觀地比較各個(gè)模塊的體積占比,為優(yōu)化性能提供幫助。 Dart2JS 官方提供了 --dump-info 命令選項(xiàng)來(lái)分析 JS 產(chǎn)物,但其表現(xiàn)差強(qiáng)人意,它并不能很好地分析各個(gè)模塊的體積占比。這里更推薦使用 source-map-explorer ,它的原理是通過(guò) sourcemap 文件進(jìn)行反解,能清晰地反映出每個(gè)模塊的占用大小,為 SDK 的精簡(jiǎn)提供了指引。下圖展示了 FlutterWeb JS 產(chǎn)物的反解信息( 截圖僅包含 Framework 和 Flutter_Web_SDK ):
圖7 反解信息 4.1.2 SDK 裁剪 FlutterWeb 依賴(lài)的 SDK 主要包括 Dart-SDK、Framework 和 Flutter_Web_SDK,這些 SDK 對(duì)包體積的影響是巨大的,幾乎貢獻(xiàn)了初始化包的所有大小。雖然在 Release 模式下的編譯流程中,Dart Compiler 會(huì)利用 Tree-Shaking 來(lái)剔除那些引入但未使用的 packages、classes、functions 等,很大程度上減少了包體積。但這些 SDK 中仍然存在一些能被進(jìn)一步優(yōu)化的代碼。 以 Flutter Framework 為例,由于它是全平臺(tái)公用的模塊,因此不可避免地存在各平臺(tái)的兼容邏輯( 通常以 if-else、switch 等條件判斷形式出現(xiàn) ),而這部分代碼是不能被 Tree-Shaking 剔除的,我們觀察如下的代碼: //?FileName:?flutter/lib/src/rendering/editable.dart void ?_handleKeyEvent(RawKeyEvent?keyEvent)?{ ??if ?(kIsWeb)?{ ????//?On?web?platform,?we?should?ignore?the?key. ????return ; ??} ??//?Other?codes?... }上述代碼選自 Framework 中的 RenderEditable 類(lèi),當(dāng) kIsWeb 變量為真,表示當(dāng)前應(yīng)用運(yùn)行在 Web 平臺(tái)。受限于 Tree-Shaking 的機(jī)制原理,上述代碼中,其它平臺(tái)的兼容邏輯即注釋 Other codes 的部分是無(wú)法被剔除的,但這部分代碼,對(duì) Web 平臺(tái)來(lái)說(shuō)卻是 Dead Code( 永遠(yuǎn)不可能被執(zhí)行到的代碼 ),是可以被進(jìn)一步優(yōu)化的。
圖8 部分功能構(gòu)成 上圖展示了 SDK 的一部分功能構(gòu)成,從圖中可以看出,F(xiàn)lutterWeb 依賴(lài)的這些 SDK 中包含了一些使用頻率較低的功能,例如:藍(lán)牙、USB、WebRTC、陀螺儀等功能的支持。為此,我們提供了對(duì)這些長(zhǎng)尾功能的定制能力( 這些功能默認(rèn)不開(kāi)啟,但業(yè)務(wù)可配置 ),將未被啟用長(zhǎng)尾的功能進(jìn)行裁剪。 通過(guò)上述分析可得,我們的思路就是對(duì) Dead Code 進(jìn)行二次剔除,以及對(duì)這些長(zhǎng)尾功能做裁剪。基于這樣的思路,我們深入 Dart-SDK、Framework 和 Flutter_Web_SDK 各個(gè)擊破,最終將 JS Bundle 產(chǎn)物體積由 1.2M 精簡(jiǎn)至 0.7M,為 FlutterWeb 頁(yè)面性能優(yōu)化打下了堅(jiān)實(shí)的基礎(chǔ)。
圖9 精簡(jiǎn)成果 4.1.3 SDK 集成 CI/CD 為了提升構(gòu)建效率,我們將 FlutterWeb 依賴(lài)的環(huán)境定制為 Docker 鏡像,集成入 CI/CD( 持續(xù)集成與部署 )系統(tǒng)。SDK 裁剪后,我們需要更新 Docker 鏡像,整個(gè)過(guò)程耗時(shí)較長(zhǎng)且不夠靈活。因此,我們將 Dart-SDK、Framework、Flutter_Web_SDK 按版本打包傳至云端,在編譯開(kāi)始前讀取 CI/CD 環(huán)境變量:sdk_version( SDK 版本號(hào) ),遠(yuǎn)程拉取相應(yīng)版本的 SDK 包,并替換當(dāng)前 Docker 環(huán)境中的對(duì)應(yīng)模塊,基于以此方案實(shí)現(xiàn) SDK 的靈活發(fā)布,具體流程圖如下圖所示:
圖10 集成CI/CD 4.2 JS 分片 FlutterWeb 編譯之后默認(rèn)會(huì)生成 main.dart.js 文件,它囊括了 SDK 代碼以及業(yè)務(wù)邏輯,這樣會(huì)引起以下問(wèn)題: 功能無(wú)法及時(shí)更新 :為了實(shí)現(xiàn)瀏覽器的緩存優(yōu)化,我們的項(xiàng)目開(kāi)啟了對(duì)靜態(tài)資源的強(qiáng)緩存,若 main.dart.js 產(chǎn)物不支持 Hash 命名,可能導(dǎo)致程序代碼不能被及時(shí)更新;無(wú)法使用 CDN :FlutterWeb 默認(rèn)僅支持相對(duì)域名的資源加載方式,無(wú)法使用當(dāng)前域名以外的 CDN 域名,導(dǎo)致無(wú)法享受 CDN 帶來(lái)的優(yōu)勢(shì);首屏渲染性能不佳 :雖然我們進(jìn)行了 SDK 瘦身,但 main.dart.js 文件依然維持在 0.7M 以上,單一文件加載、解析時(shí)間過(guò)長(zhǎng),勢(shì)必會(huì)影響首屏的渲染時(shí)間。針對(duì)文件 Hash 化和 CDN 加載的支持,我們?cè)?flutter_tools 編譯流程中對(duì)靜態(tài)資源進(jìn)行二次處理:遍歷靜態(tài)資源產(chǎn)物,增加文件 Hash ( 文件內(nèi)容 MD5 值 ),并更新資源的引用;同時(shí)通過(guò)定制 Dart-SDK,修改了 main.dart.js、字體等靜態(tài)資源的加載邏輯,使其支持 CDN 資源加載。 4.2.1 Lazy Loading Flutter 官方提供 deferred as 關(guān)鍵字來(lái)實(shí)現(xiàn) Widget 的懶加載,而 dart2js 在編譯過(guò)程中可以將懶加載的 Widget 進(jìn)行按需打包,這樣的拆包機(jī)制叫做 Lazy Loading。借助 Lazy Loading,我們可以在路由表中使用 deferred 引入各個(gè)路由( 頁(yè)面 ),以此來(lái)達(dá)到業(yè)務(wù)代碼拆離的目的,具體使用方法和效果如下所示: //?使用方式 import ?'pages/index/index.dart' ?deferred ?as ?IndexPageDefer; { ??'/index' :?(context)?=>?FutureBuilder( ????future:?IndexPageDefer.loadLibrary(), ????builder:?(context,?snapshot)?=>?IndexPageDefer.Demo(), ??) ??...?... }
圖11 效果演示 使用 Lazy Loading 后,業(yè)務(wù)頁(yè)面的代碼會(huì)被拆分到了多個(gè) PartJS( 對(duì)應(yīng)圖中 xxx.part.js 文件 ) 中。這樣看似解決了業(yè)務(wù)代碼與 SDK 耦合的問(wèn)題,但在實(shí)際操作過(guò)程中,我們發(fā)現(xiàn)每次業(yè)務(wù)代碼的變動(dòng),仍然會(huì)導(dǎo)致編譯后的 main.dart.js 隨之發(fā)生變化( 文件 Hash 值變化 )。經(jīng)過(guò)定位與跟蹤,我們發(fā)現(xiàn)這個(gè)變化的部分是 PartJS 的加載邏輯和映射關(guān)系,我們稱(chēng)之為 Runtime Manifest。因此,需要設(shè)計(jì)一套方案對(duì) Runtime Manifest 進(jìn)行抽離,來(lái)保證業(yè)務(wù)代碼的修改對(duì) main.dart.js 的影響達(dá)到最低。 4.2.2 Runtime Manifest抽離 通過(guò)對(duì)業(yè)務(wù)代碼的抽離,此時(shí) main.dart.js 文件的構(gòu)成主要包含 SDK 和 ?Runtime Manifest:
圖12 main.dart.js構(gòu)成 那如何能將 Runtime Manifest 進(jìn)行抽離呢?對(duì)比常規(guī) Web 項(xiàng)目,我們的處理方式是把 SDK、Utils、三方包等基礎(chǔ)依賴(lài),利用 Webpack、Rollup 等打包工具進(jìn)行抽離并賦予一個(gè)穩(wěn)定的 Hash 值。同時(shí),將 Runtime Manifest ( 分片文件的加載邏輯和映射關(guān)系 )注入到 HTML 文件中,這樣保證了業(yè)務(wù)代碼的變動(dòng)不會(huì)影響到公共包。借助常規(guī) Web 項(xiàng)目的編譯思路,我們深入分析了 FlutterWeb 中 Runtime Manifest 的生成邏輯和 PartJS 的加載邏輯,定制出如下的解決方案:
圖13 Runtime Manifest抽離 在上圖中,Runtime Manifest 的生成邏輯位于 Dart2JS Compiler 模塊,在該生成邏輯中,我們對(duì) Runtime Manifest 代碼塊進(jìn)行了標(biāo)記,之后在 flutter_tools 中將標(biāo)記的 Runtime Manifest 代碼塊抽離并寫(xiě)入 HTML 文件中( 以 JS 常量形式存在 )。而在 PartJS 的加載流程中,我們將 manifest 信息的讀取方式改為了 JS 常量的獲取。按照這樣的拆分方式,業(yè)務(wù)代碼的變更只會(huì)改變 Runtime Manifest 信息 ,而不會(huì)影響到 main.dart.js 公共包。 4.2.3 main.dart.js 切片 經(jīng)過(guò)以上引入 Lazy Loading、Runtime Manifest 抽離,main.dart.js 文件的體積穩(wěn)定在 0.7M 左右,瀏覽器對(duì)大體積單文件的加載,會(huì)有很沉重的網(wǎng)絡(luò)負(fù)擔(dān),所以我們?cè)O(shè)計(jì)了切片方案,充分地利用瀏覽器對(duì)多文件并行加載的特性,提升文件的加載效率。