Flutter原理與美團的實踐

原文地址:https://blog.csdn.net/MeituanTech/article/details/81567238

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

在Dart代碼文件中修改字符串“Hello, World”,添加一個驚歎號,點擊保存或者熱刷新按鈕就可以立即更新到界面上,僅需幾百毫秒:

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

  1. 編譯錯誤,如果修改後的Dart代碼無法通過編譯,Flutter會在控制檯報錯,這時需要修改對應的代碼。
  2. 控件類型從StatelessWidgetStatefulWidget的轉換,因爲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’”。
  3. 全局變量和靜態成員變量,這些變量不會在熱刷新時更新。
  4. 修改了main函數中創建的根控件節點,Flutter在熱刷新後只會根據原來的根節點重新創建控件樹,不會修改根節點。
  5. 某個類從普通類型轉換成枚舉類型,或者類型的泛型參數列表變化,都會使熱刷新失敗。

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

Flutter插件

Flutter使用的Dart語言無法直接調用Android系統提供的Java接口,這時就需要使用插件來實現中轉。Flutter官方提供了豐富的原生接口封裝:

在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本身提供了三種運行方式:

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

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

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

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

  • 所有變量的值都是對象,也就是類的實例。甚至數字、函數和null也都是對象,都繼承自Object類。
  • 雖然Dart是強類型語言,但是顯式變量類型聲明是可選的,Dart支持類型推斷。如果不想使用類型推斷,可以用dynamic類型。
  • Dart支持泛型,List<int>表示包含int類型的列表,List<dynamic>則表示包含任意類型的列表。
  • 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和其三個常用的子類RenderParagraphRenderImageRenderFlex則是具體佈局和繪製邏輯的實現類。

在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所有的實現類則負責提供配置信息並創建具體的RenderObjectElementElement是Flutter用來分離控件樹和真正的渲染對象的中間層,控件用來描述對應的element屬性,控件重建後可能會複用同一個element。RenderObjectElement持有真正負責佈局、繪製和碰撞測試(hit test)的RenderObject對象。

StatelessWidgetStatefulWidget並不會直接影響RenderObject的創建,它們只負責創建對應的RenderObjectWidgetStatelessElementStatefulElement也是類似的功能。

它們之間的關係如下圖:

如果控件的屬性發生了變化(因爲控件的屬性是隻讀的,所以變化也就意味着重新創建了新的控件樹),但是其樹上每個節點的類型沒有變化時,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/*.dart
third_party/dart/runtime/lib/*.dart
third_party/dart/sdk/lib/_http/*.dart
third_party/dart/sdk/lib/async/*.dart
third_party/dart/sdk/lib/collection/*.dart
third_party/dart/sdk/lib/convert/*.dart
third_party/dart/sdk/lib/core/*.dart
third_party/dart/sdk/lib/developer/*.dart
third_party/dart/sdk/lib/html/*.dart
third_party/dart/sdk/lib/internal/*.dart
third_party/dart/sdk/lib/io/*.dart
third_party/dart/sdk/lib/isolate/*.dart
third_party/dart/sdk/lib/math/*.dart
third_party/dart/sdk/lib/mirrors/*.dart
third_party/dart/sdk/lib/profiler/*.dart
third_party/dart/sdk/lib/typed_data/*.dart
third_party/dart/sdk/lib/vmservice/*.dart
flutter/lib/ui/*.dart

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

flutter_tutorial_2/lib/main.dart
flutter/packages/flutter/lib/src/widgets/*.dart
flutter/packages/flutter/lib/src/services/*.dart
flutter/packages/flutter/lib/src/semantics/*.dart
flutter/packages/flutter/lib/src/scheduler/*.dart
flutter/packages/flutter/lib/src/rendering/*.dart
flutter/packages/flutter/lib/src/physics/*.dart
flutter/packages/flutter/lib/src/painting/*.dart
flutter/packages/flutter/lib/src/gestures/*.dart
flutter/packages/flutter/lib/src/foundation/*.dart
flutter/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在Debug模式下使用JIT執行方式,主要是爲了支持廣受歡迎的熱刷新功能:

觸發熱刷新時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中修改:

APK AAR
修改android插件類型 apply plugin: ‘com.android.application’
刪除applicationId字段 applicationId “com.example.fluttertutorial”
建議添加發布所有配置功能,方便調試 -

簡單修改後我們就可以使用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.png
new Image.asset('images/cat.png');

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

  1. 在調用Flutter頁面之前將指定的圖片資源按照設備屏幕密度縮放,並存儲在App私有目錄下。
  2. 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.gnithird_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/engine
for 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
  popd
done

這樣在打包後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 0x0

backtrace:
    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對應的版本 06afdfe54e
Flutter 0.4.4 • channel beta • https://github.com/flutter/flutter.git
Framework • revision f9bb4289e9 (5 weeks ago) • 2018-05-11 21:44:54 -0700
Engine • revision 06afdfe54e
Tools • Dart 2.0.0-dev.54.0.flutter-46ab040e58
$ cat flutter/bin/internal/engine.version # flutter安裝目錄下的engine.version文件也可以看到完整的版本信息 06afdfe54ebef9168a90ca00a6721c2d36e6aafa
06afdfe54ebef9168a90ca00a6721c2d36e6aafa

拿到引擎版本號後在 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 0x0
Stack 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:55
Stack 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:74
Stack 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:273
Stack 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:428
Stack 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:54
Stack 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:150
Stack 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:198
Stack 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:198
Stack 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:348
Stack 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 4
done

將測試結果的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積極溝通。

美團外賣大前端團隊將來也會繼續在更多場景下使用Flutter實現,並且將實踐過程中發現和修復的問題積極反饋到開源社區,幫助Flutter更好地發展。如果你也對Flutter感興趣,歡迎加入。

參考資料

  1. Flutter中文官網

  2. Flutter框架技術概覽

  3. Flutter插件倉庫

  4. A Tour of the Dart Language

  5. A Tour of the Dart Libraries

  6. Why Flutter Uses Dart

  7. Flutter Layout機制簡介

  8. Flutter’s Layered Design

  9. Flutter’s Rendering Pipeline

  10. Flutter: The Best Way to Build for Mobile?@GOTO conf

  11. Flutter Engine

  12. Writing custom platform-specific code with platform channels

  13. Flutter Engine Operation in AOT Mode

  14. Flutter’s modes

  15. Symbolicating-production-crash-stacks

作者簡介

少傑,美團高級工程師,2017年加入美團,目前主要負責外賣App監控等基礎設施建設工作。

招聘

美團外賣誠招Android、iOS、FE高級/資深工程師和技術專家,Base北京、上海、成都,歡迎有興趣的同學投遞簡歷到wukai05#meituan.com。 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章