首頁>技術>

原文出處:美團技術團隊

導讀

Flutter是Google開發的一套全新的跨平臺、開源UI框架,支援iOS、Android系統開發,並且是未來新作業系統Fuchsia的預設開發套件。自從2017年5月釋出第一個版本以來,目前Flutter已經發布了近60個版本,並且在2018年5月釋出了第一個“Ready for Production Apps”的Beta 3版本,6月20日釋出了第一個“Release Preview”版本。

初識Flutter

Flutter的目標是使同一套程式碼同時執行在Android和iOS系統上,並且擁有媲美原生應用的效能,Flutter甚至提供了兩套控制元件來適配Android和iOS(滾動效果、字型和控制元件圖示等等),為了讓App在細節處看起來更像原生應用。

在Flutter誕生之前,已經有許多跨平臺UI框架的方案,比如基於WebView的Cordova、AppCan等,還有使用HTML+JavaScript渲染成原生控制元件的React Native、Weex等。

基於WebView的框架優點很明顯,它們幾乎可以完全繼承現代Web開發的所有成果(豐富得多的控制元件庫、滿足各種需求的頁面框架、完全的動態化、自動化測試工具等等),當然也包括Web開發人員,不需要太多的學習和遷移成本就可以開發一個App。同時WebView框架也有一個致命(在對體驗&效能有較高要求的情況下)的缺點,那就是WebView的渲染效率和JavaScript執行效能太差。再加上Android各個系統版本和裝置廠商的定製,很難保證所在所有裝置上都能提供一致的體驗。

為了解決WebView效能差的問題,以React Native為代表的一類框架將最終渲染工作交還給了系統,雖然同樣使用類HTML+JS的UI構建邏輯,但是最終會生成對應的自定義原生控制元件,以充分利用原生控制元件相對於WebView的較高的繪製效率。與此同時這種策略也將框架本身和App開發者綁在了系統的控制元件系統上,不僅框架本身需要處理大量平臺相關的邏輯,隨著系統版本變化和API的變化,開發者可能也需要處理不同平臺的差異,甚至有些特性只能在部分平臺上實現,這樣框架的跨平臺特性就會大打折扣。

Flutter則開闢了一種全新的思路,從頭到尾重寫一套跨平臺的UI框架,包括UI控制元件、渲染邏輯甚至開發語言。渲染引擎依靠跨平臺的Skia圖形庫來實現,依賴系統的只有圖形繪製相關的介面,可以在最大程度上保證不同平臺、不同裝置的體驗一致性,邏輯處理使用支援AOT的Dart語言,執行效率也比JavaScript高得多。

Flutter同時支援Windows、Linux和macOS作業系統作為開發環境,並且在Android Studio和VS Code兩個IDE上都提供了全功能的支援。Flutter所使用的Dart語言同時支援AOT和JIT執行方式,JIT模式下還有一個備受歡迎的開發利器“熱重新整理”(Hot Reload),即在Android Studio中編輯Dart程式碼後,只需要點選儲存或者“Hot Reload”按鈕,就可以立即更新到正在執行的裝置上,不需要重新編譯App,甚至不需要重啟App,立即就可以看到更新後的樣式。

在Flutter中,所有功能都可以通過組合多個Widget來實現,包括對齊方式、按行排列、按列排列、網格排列甚至事件處理等等。Flutter控制元件主要分為兩大類,StatelessWidget和StatefulWidget,StatelessWidget用來展示靜態的文字或者圖片,如果控制元件需要根據外部資料或者使用者操作來改變的話,就需要使用StatefulWidget。State的概念也是來源於Facebook的流行Web框架React,React風格的框架中使用控制元件樹和各自的狀態來構建介面,當某個控制元件的狀態發生變化時由框架負責對比前後狀態差異並且採取最小代價來更新渲染結果。

Hot Reload

Flutter通過將新的程式碼注入到正在執行的DartVM中,來實現Hot Reload這種神奇的效果,在DartVM將程式中的類結構更新完成後,Flutter會立即重建整個控制元件樹,從而更新介面。但是熱重新整理也有一些限制,並不是所有的程式碼改動都可以通過熱重新整理來更新:

編譯錯誤,如果修改後的Dart程式碼無法通過編譯,Flutter會在控制檯報錯,這時需要修改對應的程式碼。控制元件型別從StatelessWidget到StatefulWidget的轉換,因為Flutter在執行熱重新整理時會保留程式原來的state,而某個控制元件從stageless→stateful後會導致Flutter重新建立控制元件時報錯“myWidget is not a subtype of StatelessWidget”,而從stateful→stateless會報錯“type 'myWidget' is not a subtype of type 'StatefulWidget' of 'newWidget'”。全域性變數和靜態成員變數,這些變數不會在熱重新整理時更新。修改了main函式中建立的根控制元件節點,Flutter在熱重新整理後只會根據原來的根節點重新建立控制元件樹,不會修改根節點。某個類從普通型別轉換成列舉型別,或者型別的泛型引數列表變化,都會使熱重新整理失敗。

熱重新整理無法實現更新時,執行一次熱重啟(Hot Restart)就可以全量更新所有程式碼,同樣不需要重啟App,區別是restart會將所有Dart程式碼打包同步到裝置上,並且所有狀態都會重置。

Flutter外掛

Flutter使用的Dart語言無法直接呼叫Android系統提供的Java介面,這時就需要使用外掛來實現中轉。Flutter官方提供了豐富的原生介面封裝:

android_alarm_manager,訪問Android系統的AlertManager。android_intent,構造Android的Intent物件。battery,獲取和監聽系統電量變化。connectivity,獲取和監聽系統網路連線狀態。device info,獲取裝置型號等資訊。image_picker,從裝置中選取或者拍攝照片。package_info,獲取App安裝包的版本等資訊。path_provider,獲取常用檔案路徑。quick_actions,App圖示新增快捷方式,iOS的eponymous concept和Android的App Shortcuts。sensors,訪問裝置的加速度和陀螺儀感測器。shared_preferences,App KV儲存功能。url_launcher,啟動URL,包括打電話、發簡訊和瀏覽網頁等功能。video_player,播放視訊檔案或者網路流的控制元件。

在Flutter中,依賴包由Pub倉庫管理,專案依賴配置在pubspec.yaml檔案中宣告即可(類似於NPM的版本宣告Pub Versioning Philosophy),對於未釋出在Pub倉庫的外掛可以使用git倉庫地址或檔案路徑:

dependencies:   url_launcher: ">=0.1.2 <0.2.0"  collection: "^0.1.2"  plugin1:     git:       url: "git://github.com/flutter/plugin1.git"  plugin2:     path: ../plugin2/

以shared_preferences為例,在pubspec中新增程式碼:

dependencies:  flutter:    sdk: flutter  shared_preferences: "^0.4.1"

脫字號“^”開頭的版本表示和當前版本介面保持相容的最新版,^1.2.3 等效於 >=1.2.3 <2.0.0 而^0.1.2 等效於 >=0.1.2 <0.2.0,新增依賴後點擊“Packages get”按鈕即可下載外掛到本地,在程式碼中新增import語句就可以使用外掛提供的介面:

import 'package:shared_preferences/shared_preferences.Dart';class _MyAppState extends State<MyAppCounter> {  int _count = 0;  static const String COUNTER_KEY = 'counter';  _MyAppState() {    init();  }  init() async {    var pref = await SharedPreferences.getInstance();    _count = pref.getInt(COUNTER_KEY) ?? 0;    setState(() {});  }  increaseCounter() async {    SharedPreferences pref = await SharedPreferences.getInstance();    pref.setInt(COUNTER_KEY, ++_count);    setState(() {});  }...

Dart

Dart是一種強型別、跨平臺的客戶端開發語言。具有專門為客戶端優化、高生產力、快速高效、可移植(相容ARM/x86)、易學的OO程式設計風格和原生支援響應式程式設計(Stream & Future)等優秀特性。Dart主要由Google負責開發和維護,在2011年10啟動專案,2017年9月釋出第一個2.0-dev版本。

Dart本身提供了三種執行方式:

使用Dart2js編譯成JavaScript程式碼,執行在常規瀏覽器中(Dart Web)。使用DartVM直接在命令列中執行Dart程式碼(DartVM)。AOT方式編譯成機器碼,例如Flutter App框架(Flutter)。

Flutter在篩選了20多種語言後,最終選擇Dart作為開發語言主要有幾個原因:

健全的型別系統,同時支援靜態型別檢查和執行時型別檢查。程式碼體積優化(Tree Shaking),編譯時只保留執行時需要呼叫的程式碼(不允許反射這樣的隱式引用),所以龐大的Widgets庫不會造成釋出體積過大。豐富的底層庫,Dart自身提供了非常多的庫。多生代無鎖垃圾回收器,專門為UI框架中常見的大量Widgets物件建立和銷燬優化。跨平臺,iOS和Android共用一套程式碼。JIT & AOT執行模式,支援開發時的快速迭代和正式釋出後最大程度發揮硬體效能。

在Dart中,有一些重要的基本概念需要了解:

所有變數的值都是物件,也就是類的例項。甚至數字、函式和null也都是物件,都繼承自Object類。雖然Dart是強型別語言,但是顯式變數型別宣告是可選的,Dart支援型別推斷。如果不想使用型別推斷,可以用dynamic型別。Dart支援泛型,List表示包含int型別的列表,List則表示包含任意型別的列表。Dart支援頂層(top-level)函式和類成員函式,也支援巢狀函式和本地函式。Dart支援頂層變數和類成員變數。Dart沒有public、protected和private這些關鍵字,使用下劃線“_”開頭的變數或者函式,表示只在庫內可見。參考庫和可見性。

DartVM的記憶體分配策略非常簡單,建立物件時只需要在現有堆上移動指標,記憶體增長始終是線形的,省去了查詢可用記憶體段的過程:

Dart中類似執行緒的概念叫做Isolate,每個Isolate之間是無法共享記憶體的,所以這種分配策略可以讓Dart實現無鎖的快速分配。

Dart的垃圾回收也採用了多生代演算法,新生代在回收記憶體時採用了“半空間”演算法,觸發垃圾回收時Dart會將當前半空間中的“活躍”物件拷貝到備用空間,然後整體釋放當前空間的所有記憶體:

整個過程中Dart只需要操作少量的“活躍”物件,大量的沒有引用的“死亡”物件則被忽略,這種演算法也非常適合Flutter框架中大量Widget重建的場景。

Flutter Framework

Flutter的框架部分完全使用Dart語言實現,並且有著清晰的分層架構。分層架構使得我們可以在呼叫Flutter提供的便捷開發功能(預定義的一套高品質Material控制元件)之外,還可以直接呼叫甚至修改每一層實現(因為整個框架都屬於“使用者空間”的程式碼),這給我們提供了最大程度的自定義能力。Framework底層是Flutter引擎,引擎主要負責圖形繪製(Skia)、文字排版(libtxt)和提供Dart執行時,引擎全部使用C++實現,Framework層使我們可以用Dart語言呼叫引擎的強大能力。

分層架構

Framework的最底層叫做Foundation,其中定義的大都是非常基礎的、提供給其他所有層使用的工具類和方法。繪製庫(Painting)封裝了Flutter Engine提供的繪製介面,主要是為了在繪製控制元件等固定樣式的圖形時提供更直觀、更方便的介面,比如繪製縮放後的點陣圖、繪製文字、插值生成陰影以及在盒子周圍繪製邊框等等。

Animation是動畫相關的類,提供了類似Android系統的ValueAnimator的功能,並且提供了豐富的內建插值器。Gesture提供了手勢識別相關的功能,包括觸控事件類定義和多種內建的手勢識別器。GestureBinding類是Flutter中處理手勢的抽象服務類,繼承自BindingBase類。

Binding系列的類在Flutter中充當著類似於Android中的SystemService系列(ActivityManager、PackageManager)功能,每個Binding類都提供一個服務的單例物件,App最頂層的Binding會包含所有相關的Bingding抽象類。如果使用Flutter提供的控制元件進行開發,則需要使用WidgetsFlutterBinding,如果不使用Flutter提供的任何控制元件,而直接呼叫Render層,則需要使用RenderingFlutterBinding。

Flutter本身支援Android和iOS兩個平臺,除了效能和開發語言上的“native”化之外,它還提供了兩套設計語言的控制元件實現Material & Cupertino,可以幫助App更好地在不同平臺上提供原生的使用者體驗。

渲染庫(Rendering)

Flutter的控制元件樹在實際顯示時會轉換成對應的渲染物件(RenderObject)樹來實現佈局和繪製操作。一般情況下,我們只會在除錯佈局,或者需要使用自定義控制元件來實現某些特殊效果的時候,才需要考慮渲染物件樹的細節。渲染庫主要提供的功能類有:

abstract class RendererBinding extends BindingBase with ServicesBinding, SchedulerBinding, HitTestable { ... }abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin implements HitTestTarget {abstract class RenderBox extends RenderObject { ... }class RenderParagraph extends RenderBox { ... }class RenderImage extends RenderBox { ... }class RenderFlex extends RenderBox with ContainerRenderObjectMixin<RenderBox, FlexParentData>,                                        RenderBoxContainerDefaultsMixin<RenderBox, FlexParentData>,                                        DebugOverflowIndicatorMixin { ... }

RendererBinding是渲染樹和Flutter引擎的膠水層,負責管理幀重繪、視窗尺寸和渲染相關引數變化的監聽。RenderObject渲染樹中所有節點的基類,定義了佈局、繪製和合成相關的介面。RenderBox和其三個常用的子類RenderParagraph、RenderImage、RenderFlex則是具體佈局和繪製邏輯的實現類。

在Flutter介面渲染過程分為三個階段:佈局、繪製、合成,佈局和繪製在Flutter框架中完成,合成則交由引擎負責:

控制元件樹中的每個控制元件通過實現RenderObjectWidget#createRenderObject(BuildContext context) → RenderObject方法來建立對應的不同型別的RenderObject物件,組成渲染物件樹。因為Flutter極大地簡化了佈局的邏輯,所以整個佈局過程中只需要深度遍歷一次:

渲染物件樹中的每個物件都會在佈局過程中接受父物件的Constraints引數,決定自己的大小,然後父物件就可以按照自己的邏輯決定各個子物件的位置,完成佈局過程。

子物件不儲存自己在容器中的位置,所以在它的位置發生改變時並不需要重新佈局或者繪製。子物件的位置資訊儲存在它自己的parentData欄位中,但是該欄位由它的父物件負責維護,自身並不關心該欄位的內容。同時也因為這種簡單的佈局邏輯,Flutter可以在某些節點設定佈局邊界(Relayout boundary),即當邊界內的任何物件發生重新佈局時,不會影響邊界外的物件,反之亦然:

佈局完成後,渲染物件樹中的每個節點都有了明確的尺寸和位置,Flutter會把所有物件繪製到不同的圖層上:

因為繪製節點時也是深度遍歷,可以看到第二個節點在繪製它的背景和前景不得不繪製在不同的圖層上,因為第四個節點切換了圖層(因為“4”節點是一個需要獨佔一個圖層的內容,比如視訊),而第六個節點也一起繪製到了紅色圖層。這樣會導致第二個節點的前景(也就是“5”)部分需要重繪時,和它在邏輯上毫不相干但是處於同一圖層的第六個節點也必須重繪。為了避免這種情況,Flutter提供了另外一個“重繪邊界”的概念:

在進入和走出重繪邊界時,Flutter會強制切換新的圖層,這樣就可以避免邊界內外的互相影響。典型的應用場景就是ScrollView,當滾動內容重繪時,一般情況下其他內容是不需要重繪的。雖然重繪邊界可以在任何節點手動設定,但是一般不需要我們來實現,Flutter提供的控制元件預設會在需要設定的地方自動設定。

控制元件庫(Widgets)

Flutter的控制元件庫提供了非常豐富的控制元件,包括最基本的文字、圖片、容器、輸入框和動畫等等。在Flutter中“一切皆是控制元件”,通過組合、巢狀不同型別的控制元件,就可以構建出任意功能、任意複雜度的介面。它包含的最主要的幾個類有:

class WidgetsFlutterBinding extends BindingBase with GestureBinding, ServicesBinding, SchedulerBinding,            PaintingBinding, RendererBinding, WidgetsBinding { ... }abstract class Widget extends DiagnosticableTree { ... }abstract class StatelessWidget extends Widget { ... }abstract class StatefulWidget extends Widget { ... }abstract class RenderObjectWidget extends Widget { ... }abstract class Element extends DiagnosticableTree implements BuildContext { ... }class StatelessElement extends ComponentElement { ... }class StatefulElement extends ComponentElement { ... }abstract class RenderObjectElement extends Element { ... }...

基於Flutter控制元件系統開發的程式都需要使用WidgetsFlutterBinding,它是Flutter的控制元件框架和Flutter引擎的膠水層。Widget就是所有控制元件的基類,它本身所有的屬性都是隻讀的。RenderObjectWidget所有的實現類則負責提供配置資訊並建立具體的RenderObjectElement。Element是Flutter用來分離控制元件樹和真正的渲染物件的中間層,控制元件用來描述對應的element屬性,控制元件重建後可能會複用同一個element。RenderObjectElement持有真正負責佈局、繪製和碰撞測試(hit test)的RenderObject物件。

StatelessWidget和StatefulWidget並不會直接影響RenderObject建立,只負責建立對應的RenderObjectWidgetStatelessElement和StatefulElement也是類似的功能。

它們之間的關係如下圖:

如果控制元件的屬性發生了變化(因為控制元件的屬性是隻讀的,所以變化也就意味著重新建立了新的控制元件樹),但是其樹上每個節點的型別沒有變化時,element樹和render樹可以完全重用原來的物件(因為element和render object的屬性都是可變的):

但是,如果控制元件樹種某個節點的型別發生了變化,則element樹和render樹中的對應節點也需要重新建立:

外賣全品類頁面實踐

在調研了Flutter的各項特性和實現原理之後,外賣計劃灰度上線Flutter版的全品類頁面。對於將Flutter頁面作為App的一部分這種整合模式,官方並沒有提供完善的支援,所以我們首先需要了解Flutter是如何編譯、打包並且執行起來的。

Flutter App構建過程

最簡單的Flutter工程至少包含兩個檔案:

執行Flutter程式時需要對應平臺的宿主工程,在Android上Flutter通過自動建立一個Gradle專案來生成宿主,在專案目錄下執行flutter create .,Flutter會建立ios和android兩個目錄,分別構建對應平臺的宿主專案,Android目錄內容如下:

此Gradle專案中只有一個app module,構建產物即是宿主APK。Flutter在本地執行時預設採用Debug模式,在專案目錄執行flutter run即可安裝到裝置中並自動執行,Debug模式下Flutter使用JIT方式來執行Dart程式碼,所有的Dart程式碼都會打包到APK檔案中assets目錄下,由libflutter.so中提供的DartVM讀取並執行:

kernel_blob.bin是Flutter引擎的底層介面和Dart語言基本功能部分程式碼:

third_party/dart/runtime/bin/*.dartthird_party/dart/runtime/lib/*.dartthird_party/dart/sdk/lib/_http/*.dartthird_party/dart/sdk/lib/async/*.dartthird_party/dart/sdk/lib/collection/*.dartthird_party/dart/sdk/lib/convert/*.dartthird_party/dart/sdk/lib/core/*.dartthird_party/dart/sdk/lib/developer/*.dartthird_party/dart/sdk/lib/html/*.dartthird_party/dart/sdk/lib/internal/*.dartthird_party/dart/sdk/lib/io/*.dartthird_party/dart/sdk/lib/isolate/*.dartthird_party/dart/sdk/lib/math/*.dartthird_party/dart/sdk/lib/mirrors/*.dartthird_party/dart/sdk/lib/profiler/*.dartthird_party/dart/sdk/lib/typed_data/*.dartthird_party/dart/sdk/lib/vmservice/*.dartflutter/lib/ui/*.dart

platform.dill則是實現了頁面邏輯的程式碼,也包括Flutter Framework和其他由pub依賴的庫程式碼:

flutter_tutorial_2/lib/main.dartflutter/packages/flutter/lib/src/widgets/*.dartflutter/packages/flutter/lib/src/services/*.dartflutter/packages/flutter/lib/src/semantics/*.dartflutter/packages/flutter/lib/src/scheduler/*.dartflutter/packages/flutter/lib/src/rendering/*.dartflutter/packages/flutter/lib/src/physics/*.dartflutter/packages/flutter/lib/src/painting/*.dartflutter/packages/flutter/lib/src/gestures/*.dartflutter/packages/flutter/lib/src/foundation/*.dartflutter/packages/flutter/lib/src/animation/*.dart.pub-cache/hosted/pub.flutter-io.cn/collection-1.14.6/lib/*.dart.pub-cache/hosted/pub.flutter-io.cn/meta-1.1.5/lib/*.dart.pub-cache/hosted/pub.flutter-io.cn/shared_preferences-0.4.2/*.dart

kernel_blob.bin和platform.dill都是由flutter_tools中的bundle.dart中呼叫KernelCompiler生成。

在Release模式(flutter run --release)下,Flutter會使用Dart的AOT執行模式,編譯時將Dart程式碼轉換成ARM指令:

kernel_blob.bin和platform.dill都不在打包後的APK中,取代其功能的是(isolate/vm)_snapshot_(data/instr)四個檔案。snapshot檔案由Flutter SDK中的flutter/bin/cache/artifacts/engine/android-arm-release/darwin-x64/gen_snapshot命令生成,vm_snapshot_*是Dart虛擬機器執行所需要的資料和程式碼指令,isolate_snapshot_*則是每個isolate執行所需要的資料和程式碼指令。

Flutter App執行機制

Flutter構建出的APK在執行時會將所有assets目錄下的資原始檔解壓到App私有檔案目錄中的flutter目錄下,主要包括處理字元編碼的icudtl.dat,還有Debug模式的kernel_blob.bin、platform.dill和Release模式下的4個snapshot檔案。預設情況下Flutter在Application#onCreate時呼叫FlutterMain#startInitialization來啟動解壓任務,然後在FlutterActivityDelegate#onCreate中呼叫FlutterMain#ensureInitializationComplete來等待解壓任務結束。

觸發熱重新整理時Flutter會檢測發生改變的Dart檔案,將其同步到App私有快取目錄下,DartVM載入並且修改對應的類或者方法,重建控制元件樹後立即可以在裝置上看到效果。

在Release模式下Flutter會直接將snapshot檔案對映到記憶體中執行其中的指令:

在Release模式下,FlutterActivityDelegate#onCreate中呼叫FlutterMain#ensureInitializationComplete方法中會將AndroidManifest中設定的snapshot(沒有設定則使用上面提到的預設值)檔名等執行引數設定到對應的C++同名類物件中,構造FlutterNativeView例項時呼叫nativeAttach來初始化DartVM,執行編譯好的Dart程式碼。

打包Android Library

了解Flutter專案的構建和執行機制後,我們就可以按照其需求打包成AAR然後整合到現有原生App中了。首先在andorid/app/build.gradle中修改:

簡單修改後我們就可以使用Android Studio或者Gradle命令列工具將Flutter程式碼打包到aar中了。Flutter執行時所需要的資源都會包含在aar中,將其釋出到maven伺服器或者本地maven倉庫後,就可以在原生App專案中引用。

但這只是整合的第一步,為了讓Flutter頁面無縫銜接到外賣App中,我們需要做的還有很多。

圖片資源複用

Flutter預設將所有的圖片資原始檔打包到assets目錄下,但是我們並不是用Flutter開發全新的頁面,圖片資源原來都會按照Android的規範放在各個drawable目錄,即使是全新的頁面也會有很多圖片資源複用的場景,所以在assets目錄下新增圖片資源並不合適。

Flutter官方並沒有提供直接呼叫drawable目錄下的圖片資源的途徑,畢竟drawable這類檔案的處理會涉及大量的Android平臺相關的邏輯(螢幕密度、系統版本、語言等等),assets目錄檔案的讀取操作也在引擎內部使用C++實現,在Dart層面實現讀取drawable檔案的功能比較困難。Flutter在處理assets目錄中的檔案時也支援新增多倍率的圖片資源,並能夠在使用時自動選擇,但是Flutter要求每個圖片必須提供1x圖,然後才會識別到對應的其他倍率目錄下的圖片:

flutter:  assets:    - images/cat.png    - images/2x/cat.png    - images/3.5x/cat.pngnew Image.asset('images/cat.png');

這樣配置後,才能正確地在不同解析度的裝置上使用對應密度的圖片。但是為了減小APK包體積我們的點陣圖資源一般只提供常用的2x解析度,其他解析度的裝置會在執行時自動縮放到對應大小。針對這種特殊的情況,我們在不增加包體積的前提下,同樣提供了和原生App一樣的能力:

在呼叫Flutter頁面之前將指定的圖片資源按照裝置螢幕密度縮放,並存儲在App私有目錄下。Flutter中使用時通過自定義的WMImage控制元件來載入,實際是通過轉換成FileImage並自動設定scale為devicePixelRatio來載入。

這樣就可以同時解決APK包大小和圖片資源缺失1x圖的問題。

Flutter和原生程式碼的通訊

我們只用Flutter實現了一個頁面,現有的大量邏輯都是用Java實現,在執行時會有許多場景必須使用原生應用中的邏輯和功能,例如網路請求,我們統一的網路庫會在每個網路請求中新增許多通用引數,也會負責成功率等指標的監控,還有異常上報,我們需要在捕獲到關鍵異常時將其堆疊和環境資訊上報到伺服器。這些功能不太可能立即使用Dart實現一套出來,所以我們需要使用Dart提供的Platform Channel功能來實現Dart→Java之間的互相呼叫。

以網路請求為例,我們在Dart中定義一個MethodChannel物件:

import 'dart:async';import 'package:flutter/services.dart';const MethodChannel _channel = const MethodChannel('com.sankuai.waimai/network');Future<Map<String, dynamic>> post(String path, [Map<String, dynamic> form]) async {  return _channel.invokeMethod("post", {'path': path, 'body': form}).then((result) {    return new Map<String, dynamic>.from(result);  }).catchError((_) => null);}

然後在Java端實現相同名稱的MethodChannel:

public class FlutterNetworkPlugin implements MethodChannel.MethodCallHandler {    private static final String CHANNEL_NAME = "com.sankuai.waimai/network";    @Override    public void onMethodCall(MethodCall methodCall, final MethodChannel.Result result) {        switch (methodCall.method) {            case "post":                RetrofitManager.performRequest(post((String) methodCall.argument("path"), (Map) methodCall.argument("body")),                        new DefaultSubscriber<Map>() {                            @Override                            public void onError(Throwable e) {                                result.error(e.getClass().getCanonicalName(), e.getMessage(), null);                            }                            @Override                            public void onNext(Map stringBaseResponse) {                                result.success(stringBaseResponse);                            }                        }, tag);                break;            default:                result.notImplemented();                break;        }    }}

在Flutter頁面中註冊後,呼叫post方法就可以呼叫對應的Java實現:

loadData: (callback) async {    Map<String, dynamic> data = await post("home/groups");    if (data == null) {      callback(false);      return;    }    _data = AllCategoryResponse.fromJson(data);    if (_data == null || _data.code != 0) {      callback(false);      return;    }    callback(true);  }),

SO庫相容性

Flutter官方只提供了四種CPU架構的SO庫:armeabi-v7a、arm64-v8a、x86和x86-64,其中x86系列只支援Debug模式,但是外賣使用的大量SDK都只提供了armeabi架構的庫。

雖然我們可以通過修改引擎src根目錄和third_party/dart目錄下build/config/arm.gni,third_party/skia目錄下的BUILD.gn等配置檔案來編譯出armeabi版本的Flutter引擎,但是實際上市面上絕大部分裝置都已經支援armeabi-v7a,其提供的硬體加速浮點運算指令可以大大提高Flutter的執行速度,在灰度階段我們可以主動遮蔽掉不支援armeabi-v7a的裝置,直接使用armeabi-v7a版本的引擎。

做到這點我們首先需要修改Flutter提供的引擎,在Flutter安裝目錄下的bin/cache/artifacts/engine下有Flutter下載的所有平臺的引擎:

我們只需要修改android-arm、android-arm-profile和android-arm-release下的flutter.jar,將其中的lib/armeabi-v7a/libflutter.so移動到lib/armeabi/libflutter.so即可:

cd $FLUTTER_ROOT/bin/cache/artifacts/enginefor arch in android-arm android-arm-profile android-arm-release; do  pushd $arch  cp flutter.jar flutter-armeabi-v7a.jar # 備份  unzip flutter.jar lib/armeabi-v7a/libflutter.so  mv lib/armeabi-v7a lib/armeabi  zip -d flutter.jar lib/armeabi-v7a/libflutter.so  zip flutter.jar lib/armeabi/libflutter.so  popddone

這樣在打包後Flutter的SO庫就會打到APK的lib/armeabi目錄中。在執行時如果裝置不支援armeabi-v7a可能會崩潰,所以我們需要主動識別並遮蔽掉這類裝置,在Android上判斷裝置是否支援armeabi-v7a也很簡單:

public static boolean isARMv7Compatible() {    try {        if (SDK_INT >= LOLLIPOP) {            for (String abi : Build.SUPPORTED_32_BIT_ABIS) {                if (abi.equals("armeabi-v7a")) {                    return true;                }            }        } else {            if (CPU_ABI.equals("armeabi-v7a") || CPU_ABI.equals("arm64-v8a")) {                return true;            }        }    } catch (Throwable e) {        L.wtf(e);    }    return false;}

灰度和自動降級策略

Horn是一個美團內部的跨平臺配置下發SDK,使用Horn可以很方便地指定灰度開關:

在條件配置頁面定義一系列條件,然後在引數配置頁面新增新的欄位flutter即可:

因為在客戶端做了ABI兜底策略,所以這裡定義的ABI規則並沒有啟用。

Flutter目前仍然處於Beta階段,灰度過程中難免發生崩潰現象,觀察到崩潰後再針對機型或者裝置ID來做降級雖然可以儘量降低影響,但是我們可以做到更迅速。外賣的Crash採集SDK同時也支援JNI Crash的收集,我們專門為Flutter註冊了崩潰監聽器,一旦採集到Flutter相關的JNI Crash就立即停止該裝置的Flutter功能,啟動Flutter之前會先判斷FLUTTER_NATIVE_CRASH_FLAG檔案是否存在,如果存在則表示該裝置發生過Flutter相關的崩潰,很有可能是不相容導致的問題,當前版本週期內在該裝置上就不再使用Flutter功能。

除了崩潰以外,Flutter頁面中的Dart程式碼也可能發生異常,例如伺服器下發資料格式錯誤導致解析失敗等等,Dart也提供了全域性的異常捕獲功能:

import 'package:wm_app/plugins/wm_metrics.dart';void main() {  runZoned(() => runApp(WaimaiApp()), onError: (Object obj, StackTrace stack) {    uploadException("$obj\\n$stack");  });}

這樣我們就可以實現全方位的異常監控和完善的降級策略,最大程度減少灰度時可能對使用者帶來的影響。

分析崩潰堆疊和異常資料

Flutter的引擎部分全部使用C/C++實現,為了減少包大小,所有的SO庫在釋出時都會去除符號表資訊。和其他的JNI崩潰堆疊一樣,我們上報的堆疊資訊中只能看到記憶體地址偏移量等資訊:

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***Build fingerprint: 'Rock/odin/odin:7.1.1/NMF26F/1527007828:user/dev-keys'Revision: '0'Author: collect by 'libunwind'ABI: 'arm64-v8a'pid: 28937, tid: 29314, name: 1.ui  >>> com.sankuai.meituan.takeoutnew <<<signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0backtrace:    r0 00000000  r1 ffffffff  r2 c0e7cb2c  r3 c15affcc    r4 c15aff88  r5 c0e7cb2c  r6 c15aff90  r7 bf567800    r8 c0e7cc58  r9 00000000  sl c15aff0c  fp 00000001    ip 80000000  sp c0e7cb28  lr c11a03f9  pc c1254088  cpsr 200c0030    #00 pc 002d7088  /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so    #01 pc 002d5a23  /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so    #02 pc 002d95b5  /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so    #03 pc 002d9f33  /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so    #04 pc 00068e6d  /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so    #05 pc 00067da5  /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so    #06 pc 00067d5f  /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so    #07 pc 003b1877  /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so    #08 pc 003b1db5  /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so    #09 pc 0000241c  /data/data/com.sankuai.meituan.takeoutnew/app_flutter/vm_snapshot_instr

單純這些資訊很難定位問題,所以我們需要使用NDK提供的ndk-stack來解析出具體的程式碼位置:

ndk-stack -sym PATH [-dump PATH]Symbolizes the stack trace from an Android native crash.  -sym PATH   sets the root directory for symbols  -dump PATH  sets the file containing the crash dump (default stdin)

如果使用了定製過的引擎,必須使用engine/src/out/android-release下編譯出的libflutter.so檔案。一般情況下我們使用的是官方版本的引擎,可以在flutter_infra頁面直接下載帶有符號表的SO檔案,根據打包時使用的Flutter工具版本下載對應的檔案即可。比如0.4.4 beta版本:

$ flutter --version # version命令可以看到Engine對應的版本 06afdfe54eFlutter 0.4.4 • channel beta • https://github.com/flutter/flutter.gitFramework • revision f9bb4289e9 (5 weeks ago) • 2018-05-11 21:44:54 -0700Engine • revision 06afdfe54eTools • Dart 2.0.0-dev.54.0.flutter-46ab040e58$ cat flutter/bin/internal/engine.version # flutter安裝目錄下的engine.version檔案也可以看到完整的版本資訊 06afdfe54ebef9168a90ca00a6721c2d36e6aafa06afdfe54ebef9168a90ca00a6721c2d36e6aafa

拿到引擎版本號後在https://console.cloud.google.com/storage/browser/flutter_infra/flutter/06afdfe54ebef9168a90ca00a6721c2d36e6aafa/ 看到該版本對應的所有構建產物,下載android-arm-release、android-arm64-release和android-x86目錄下的symbols.zip,並存放到對應目錄:

執行ndk-stack即可看到實際發生崩潰的程式碼和具體行數資訊:

ndk-stack -sym flutter-production-syms/06afdfe54ebef9168a90ca00a6721c2d36e6aafa/armeabi-v7a -dump flutter_jni_crash.txt ********** Crash dump: **********Build fingerprint: 'Rock/odin/odin:7.1.1/NMF26F/1527007828:user/dev-keys'pid: 28937, tid: 29314, name: 1.ui  >>> com.sankuai.meituan.takeoutnew <<<signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0Stack frame #00 pc 002d7088  /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine minikin::WordBreaker::setText(unsigned short const*, unsigned int) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/third_party/txt/src/minikin/WordBreaker.cpp:55Stack frame #01 pc 002d5a23  /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine minikin::LineBreaker::setText() at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/third_party/txt/src/minikin/LineBreaker.cpp:74Stack frame #02 pc 002d95b5  /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine txt::Paragraph::ComputeLineBreaks() at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/third_party/txt/src/txt/paragraph.cc:273Stack frame #03 pc 002d9f33  /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine txt::Paragraph::Layout(double, bool) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/third_party/txt/src/txt/paragraph.cc:428Stack frame #04 pc 00068e6d  /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine blink::ParagraphImplTxt::layout(double) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/lib/ui/text/paragraph_impl_txt.cc:54Stack frame #05 pc 00067da5  /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine tonic::DartDispatcher<tonic::IndicesHolder<0u>, void (blink::Paragraph::*)(double)>::Dispatch(void (blink::Paragraph::*)(double)) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../topaz/lib/tonic/dart_args.h:150Stack frame #06 pc 00067d5f  /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine void tonic::DartCall<void (blink::Paragraph::*)(double)>(void (blink::Paragraph::*)(double), _Dart_NativeArguments*) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../topaz/lib/tonic/dart_args.h:198Stack frame #07 pc 003b1877  /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine dart::NativeEntry::AutoScopeNativeCallWrapperNoStackCheck(_Dart_NativeArguments*, void (*)(_Dart_NativeArguments*)) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../third_party/dart/runtime/vm/native_entry.cc:198Stack frame #08 pc 003b1db5  /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine dart::NativeEntry::LinkNativeCall(_Dart_NativeArguments*) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../third_party/dart/runtime/vm/native_entry.cc:348Stack frame #09 pc 0000241c  /data/data/com.sankuai.meituan.takeoutnew/app_flutter/vm_snapshot_instr

Dart異常則比較簡單,預設情況下Dart程式碼在編譯成機器碼時並沒有去除符號表資訊,所以Dart的異常堆疊本身就可以標識真實發生異常的程式碼檔案和行數資訊:

FlutterException: type '_InternalLinkedHashMap<dynamic, dynamic>' is not a subtype of type 'num' in type cast#0      _$CategoryGroupFromJson (package:wm_app/lib/all_category/model/category_model.g.dart:29)#1      new CategoryGroup.fromJson (package:wm_app/all_category/model/category_model.dart:51)#2      _$CategoryListDataFromJson.<anonymous closure> (package:wm_app/lib/all_category/model/category_model.g.dart:5)#3      MappedListIterable.elementAt (dart:_internal/iterable.dart:414)#4      ListIterable.toList (dart:_internal/iterable.dart:219)#5      _$CategoryListDataFromJson (package:wm_app/lib/all_category/model/category_model.g.dart:6)#6      new CategoryListData.fromJson (package:wm_app/all_category/model/category_model.dart:19)#7      _$AllCategoryResponseFromJson (package:wm_app/lib/all_category/model/category_model.g.dart:19)#8      new AllCategoryResponse.fromJson (package:wm_app/all_category/model/category_model.dart:29)#9      AllCategoryPage.build.<anonymous closure> (package:wm_app/all_category/category_page.dart:46)<asynchronous suspension>#10     _WaimaiLoadingState.build (package:wm_app/all_category/widgets/progressive_loading_page.dart:51)#11     StatefulElement.build (package:flutter/src/widgets/framework.dart:3730)#12     ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:3642)#13     Element.rebuild (package:flutter/src/widgets/framework.dart:3495)#14     BuildOwner.buildScope (package:flutter/src/widgets/framework.dart:2242)#15     _WidgetsFlutterBinding&BindingBase&GestureBinding&ServicesBinding&SchedulerBinding&PaintingBinding&RendererBinding&WidgetsBinding.drawFrame (package:flutter/src/widgets/binding.dart:626)#16     _WidgetsFlutterBinding&BindingBase&GestureBinding&ServicesBinding&SchedulerBinding&PaintingBinding&RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:208)#17     _WidgetsFlutterBinding&BindingBase&GestureBinding&ServicesBinding&SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:990)#18     _WidgetsFlutterBinding&BindingBase&GestureBinding&ServicesBinding&SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:930)#19     _WidgetsFlutterBinding&BindingBase&GestureBinding&ServicesBinding&SchedulerBinding._handleDrawFrame (package:flutter/src/scheduler/binding.dart:842)#20     _rootRun (dart:async/zone.dart:1126)#21     _CustomZone.run (dart:async/zone.dart:1023)#22     _CustomZone.runGuarded (dart:async/zone.dart:925)#23     _invoke (dart:ui/hooks.dart:122)#24     _drawFrame (dart:ui/hooks.dart:109)

Flutter和原生效能對比

雖然使用原生實現(左)和Flutter實現(右)的全品類頁面在實際使用過程中幾乎分辨不出來:

但是我們還需要在效能方面有一個比較明確的資料對比。

我們最關心的兩個頁面效能指標就是頁面載入時間和頁面渲染速度。測試頁面載入速度可以直接使用美團內部的Metrics效能測試工具,我們將頁面Activity物件建立作為頁面載入的開始時間,頁面API資料返回作為頁面載入結束時間。

從兩個實現的頁面分別啟動400多次的資料中可以看到,原生實現(AllCategoryActivity)的載入時間中位數為210ms,Flutter實現(FlutterCategoryActivity)的載入時間中位數為231ms。考慮到目前我們還沒有針對FlutterView做快取和重用,FlutterView每次建立都需要初始化整個Flutter環境並載入相關程式碼,多出的20ms還在預期範圍內:

因為Flutter的UI邏輯和繪製程式碼都不在主執行緒執行,Metrics原有的FPS功能無法統計到Flutter頁面的真實情況,我們需要用特殊方法來對比兩種實現的渲染效率。Android原生實現的介面渲染耗時使用系統提供的FrameMetrics介面進行監控:

public class AllCategoryActivity extends WmBaseActivity {    @Override    protected void onCreate(Bundle savedInstanceState) {        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {            getWindow().addOnFrameMetricsAvailableListener(new Window.OnFrameMetricsAvailableListener() {                List<Integer> frameDurations = new ArrayList<>(100);                @Override                public void onFrameMetricsAvailable(Window window, FrameMetrics frameMetrics, int dropCountSinceLastInvocation) {                    frameDurations.add((int) (frameMetrics.getMetric(TOTAL_DURATION) / 1000000));                    if (frameDurations.size() == 100) {                        getWindow().removeOnFrameMetricsAvailableListener(this);                        L.w("AllCategory", Arrays.toString(frameDurations.toArray()));                    }                }            }, new Handler(Looper.getMainLooper()));        }        super.onCreate(savedInstanceState);        // ...    }}

Flutter在Framework層只能取到每幀中UI操作的CPU耗時,GPU操作在Flutter引擎內部實現,所以要修改引擎來監控完整的渲染耗時,在Flutter引擎目錄下src/flutter/shell/common/rasterizer.cc檔案中新增:

void Rasterizer::DoDraw(std::unique_ptr<flow::LayerTree> layer_tree) {  if (!layer_tree || !surface_) {    return;  }  if (DrawToSurface(*layer_tree)) {    last_layer_tree_ = std::move(layer_tree);#if defined(OS_ANDROID)    if (compositor_context_->frame_count().count() == 101) {      std::ostringstream os;      os << "[";      const std::vector<TimeDelta> &engine_laps = compositor_context_->engine_time().Laps();      const std::vector<TimeDelta> &frame_laps = compositor_context_->frame_time().Laps();      size_t i = 1;      for (auto engine_iter = engine_laps.begin() + 1, frame_iter = frame_laps.begin() + 1;           i < 101 && engine_iter != engine_laps.end(); i++, engine_iter++, frame_iter++) {        os << (*engine_iter + *frame_iter).ToMilliseconds() << ",";      }      os << "]";      __android_log_write(ANDROID_LOG_WARN, "AllCategory", os.str().c_str());    }#endif  }}

即可得到每幀繪製時真正消耗的時間。測試時我們將兩種實現的頁面分別開啟100次,每次開啟後執行兩次滾動操作,使其繪製100幀,將這100幀的每幀耗時記錄下來:

for (( i = 0; i < 100; i++ )); do    openWMPage allcategory    sleep 1    adb shell input swipe 500 1000 500 300 900    adb shell input swipe 500 1000 500 300 900    adb shell input keyevent 4done

將測試結果的100次啟動中每幀耗時取平均値,得到每幀平均耗時情況(橫座標軸為幀序列,縱座標軸為每幀耗時,單位為毫秒):

Android原生實現和Flutter版本都會在頁面開啟的前5幀超過16ms,剛開啟頁面時原生實現需要建立大量View,Flutter也需要建立大量Widget,後續幀中可以重用大部分控制元件和渲染節點(原生的RenderNode和Flutter的RenderObject),所以啟動時的佈局和渲染操作都是最耗時的。

10000幀(100次×100幀每次)中Android原生總平均値為10.21ms,Flutter總平均値為12.28ms,Android原生實現總丟幀數851幀8.51%,Flutter總丟幀987幀9.87%。在原生實現的觸控事件處理和過度繪製充分優化的前提下,Flutter完全可以媲美原生的效能。

總結

Flutter目前仍處於早期階段,也還沒有釋出正式的Release版本,不過我們看到Flutter團隊一直在為這一目標而努力。雖然Flutter的開發生態不如Android和iOS原生應用那麼成熟,許多常用的複雜控制元件還需要自己實現,有的甚至會比較困難(比如官方尚未提供的ListView.scrollTo(index)功能),但是在高效能和跨平臺方面Flutter在眾多UI框架中還是有很大優勢的。

開發Flutter應用只能使用Dart語言,Dart本身既有靜態語言的特性,也支援動態語言的部分特性,對於Java和JavaScript開發者來說門檻都不高,3-5天可以快速上手,大約1-2周可以熟練掌握。

在開發全品類頁面的Flutter版本時我們也深刻體會到了Dart語言的魅力,Dart的語言特性使得Flutter的介面構建過程也比Android原生的XML+JAVA更直觀,程式碼量也從原來的900多行減少到500多行(排除掉引用的公共元件)。Flutter頁面整合到App後APK體積至少會增加5.5MB,其中包括3.3MB的SO庫檔案和2.2MB的ICU資料檔案,此外業務程式碼1300行編譯產物的大小有2MB左右。

Flutter本身的特性適合追求iOS和Android跨平臺的一致體驗,追求高效能的UI互動效果的場景,不適合追求動態化部署的場景。Flutter在Android上已經可以實現動態化部署,但是由於Apple的限制,在iOS上實現動態化部署非常困難,Flutter團隊也正在和Apple積極溝通。

最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 實用乾貨 | 一步一步教你在SpringBoot整合支付寶手機網站支付