Flutter 在铭师堂的实践
簡介
Flutter 是 Google 的一套跨平臺 UI 框架。目前已經是 1.7 的 Release 版本。在移動端雙端投入人力較大,短期緊急需求的背景下。跨端技術會成為越來越多的移動端技術棧選擇。銘師堂移動端團隊在過去幾個月,對 Flutter 技術做了一些嘗試和工作。這篇文章將會對 Flutter 的基本原理和我們在 升學e網通 APP 的工程實踐做一個簡單的分享。
Flutter 的架構和原理
Flutter framework 層的架構圖如下:
Foundation: foundation 提供了 framework 經常使用的一些基礎類,包括但不限于:
-
BindBase: 提供了提供單例服務的對象基類,提供了 Widgets、Render、Gestures等能力
-
Key: 提供了 Flutter 常用的 Key 的基類
-
AbstractNode:表示了控件樹的節點
在 foundation 之上,Flutter 提供了 動畫、繪圖、手勢、渲染和部件,其中部件就包括我們比較熟悉的 Material 和 Cupertino 風格
我們從 dart 的入口處關注 Flutter 的渲染原理
void runApp(Widget app) {WidgetsFlutterBinding.ensureInitialized()..attachRootWidget(app)..scheduleWarmUpFrame(); } 復制代碼我們直接使用了 Widgets 層的能力
widgets
負責根據我們 dart 代碼提供的 Widget 樹,來構造實際的虛擬節點樹
在 FLutter 的渲染機制中,有 3 個比較關鍵的概念:
- Widget: 我們在 dart 中直接編寫的 Widget,表示控件
- Element:實際構建的虛擬節點,所有的節點構造出實際的控件樹,概念是類似前端經常提到的 vitrual dom
- RenderObject: 實際負責控件的視圖工作。包括布局、渲染和圖層合成
根據 attachRootWidget 的流程,我們可以了解到布局樹的構造流程
到這里,整顆 tree 的 root 節點就構造出來了,在 mount 中,會通過 BuildOwner#buildScope 執行子節點的創建和掛載, 這里需要注意的是 child 的 RenderObject 也會被 attach 到 parent 的 RenderObejct 上去
整個過程我們可以通過下圖表示
感興趣可以參考 Element、RenderObjectElement、RenderObject 的源碼
渲染
負責實際整個控件樹 RenderObject 的布局和繪制
runApp 后會執行 scheduleWarmUpFrame 方法,這里就會開始調度渲染任務,進行每一幀的渲染
從 handleBeginFrame 和 handleDrawFrame 會走到 binding 的 drawFrame 函數,依次會調用 WidgetsBinding 和 RendererBinding 的 drawFrame。
這里會通過 Element 的 BuildOwner,去重新塑造我們的控件樹。
大致原理如圖
在構造或者刷新一顆控件樹的時候,我們會把有改動部分的 Widget 標記為 dirty,并針對這部分執行 rebuild,但是 Flutter 會有判斷來保證盡量復用 Element,從而避免了反復創建 Element 對象帶來的性能問題。
在對 dirty elements 進行處理的時候,會對它進行一次排序,排序規則參考了 element 的深度:
static int _sort(Element a, Element b) {if (a.depth < b.depth)return -1;if (b.depth < a.depth)return 1;if (b.dirty && !a.dirty)return -1;if (a.dirty && !b.dirty)return 1;return 0;} 復制代碼根據 depth 排序的目的,則是為了保證子控件一定排在父控件的左側, 這樣在 build 的時候,可以避免對子 widget 進行重復的 build。
在實際渲染過程中,Flutter 會利用 Relayout Boundary機制
void markNeedsLayout() {// ...if (_relayoutBoundary != this) {markParentNeedsLayout();} else {_needsLayout = true;if (owner != null) {owner._nodesNeedingLayout.add(this);owner.requestVisualUpdate();}}//...} 復制代碼在設置了 relayout boundary 的控件中,只有子控件會被標記為 needsLayout,可以保證,刷新子控件的狀態后,控件樹的處理范圍都在子樹,不會去重新創建父控件,完全隔離開。
在每一個 RendererBinding 中,存在一個 PipelineOwner 對象,類似 WidgetsBinding 中的 BuildOwner. BuilderOwner 負責控件的build 流程,PipelineOwner 負責 render tree 的渲染。
void drawFrame() {assert(renderView != null);pipelineOwner.flushLayout();pipelineOwner.flushCompositingBits();pipelineOwner.flushPaint();renderView.compositeFrame(); // this sends the bits to the GPUpipelineOwner.flushSemantics(); // this also sends the semantics to the OS.} 復制代碼RenderBinding 的 drawFrame 實際闡明了 render obejct 的渲染流程。即 布局(layout)、繪制(paint)、合成(compositeFrame)
調度(scheduler和線程模型)
在布局和渲染中,我們會觀察到 Flutter 擁有一個 SchedulerBinding,在 frame 變化的時候,提供 callback 進行處理。不僅提供了幀變化的調度,在 SchedulerBinding 中,也提供了 task 的調度函數。這里我們就需要了解一下 dart 的異步任務和線程模型。
dart 的單線程模型,所以在 dart 中,沒有所謂的主線程和子線程說法。dart 的異步操作采取了 event-looper 模型。
dart 沒有線程的概念,但是有一個概念,叫做 isolate, 每個 isolate 是互相隔離的,不會進行內存的共享。在 main isolate 的 main 函數結束之后,會開始一個個處理 event queue 中的 event。也就是,dart 是先執行完同步代碼后,再進行異步代碼的執行。所以如果存在非常耗時的任務,我們可以創建自己的 isolate 去執行。
每一個 isolate 中,存在 2 個 event queue
- Event Queue
- Microtask Queue
event-looper 執行任務的順序是
flutter 的異步模型如下圖
Gesture
每一個 GUI 都離不開手勢/指針的相關事件處理。
在 GestureBiding 中,在 _handlePointerEvent 函數中,PointerDownEvent 事件每處理一次,就會創建一個 HintTest 對象。在 HintTest 中,會存有每次經過的控件節點的 path。
最終我們也會看到一個 dispatchEvent 函數,進行事件的分發以及 handleEvent,對事件進行處理。
在根節點的 renderview 中,事件會開始從 hitTest 處理,因為我們添加了事件的傳遞路徑,所以,時間在經過每個節點的時候,都會被”處理“。
// from HitTestDispatchervoid dispatchEvent(PointerEvent event, HitTestResult hitTestResult) {if (hitTestResult == null) {assert(event is PointerHoverEvent || event is PointerAddedEvent || event is PointerRemovedEvent);try {pointerRouter.route(event);} catch (exception, stack) {}return;}for (HitTestEntry entry in hitTestResult.path) {try {entry.target.handleEvent(event, entry);} catch (exception, stack) {}}} 復制代碼這里我們就可以看出來 Flutter 的時間順序,從根節點開始分發,一直到子節點。同理,時間處理完后,會沿著子節點傳到父節點,最終回到 GestureBinding。 這個順序其實和 Android 的 View 事件分發 和 瀏覽器的事件冒泡 是一樣的。
通過 GestureDector 這個 Widget, 我們可以觸發和處理各種這樣的事件和手勢。具體的可以參考 Flutter 文檔。
Material、Cupertino
Flutter 在 Widgets 之上,實現了兼容 Andorid/iOS 風格的設計。讓APP 在 ui/ue 上有類原生的體驗。
Flutter 的工程實踐
根據我們自己的實踐,我從 混合開發、基礎庫建設和日常的采坑的角度,分享一些我們的心得體會。
混合工程
我們的 APP 主題大部分是 native 開發完成的。為了實踐 Flutter,我們就需要把 Flutter 接入到原生的 APP 里面去。并且能滿足如下需求:
- 對不參與 Flutter 實踐的原生開發同學不產生影響。不需要他們去安裝 Flutter 開發環境
- 對于參與 FLutter 的同學來說,我們要共享一份dart 代碼,即共享一個代碼倉庫
我們的原生架構是多 module 組件化,每個 module 是一個 git 倉庫,使用 google git repo 進行管理。以 Android 工程為例,為了對原生開發沒有影響。最順理成章的思路就是,提供一個 aar 包。對于 Android 的視角來說,flutter 其實只是一個 flutterview,那么我們按照 flutter 的工程結構自己創建一個相應的 module 就好了。
我們查看 flutter create 創建的flutter project的Andorid的 build.gradle,可以找到幾個關鍵的地方
app的build.gradle
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"flutter {source '../..' } 復制代碼這里制定了 flutter 的gradle,并且制定了 flutter 的source 文件目錄。
我們可以猜測出來,flutter相關的構建和依賴,都是 flutter 的gradle 文件里面幫我們做的。那么在我們自己創建的原生 module 內部,也用同樣的方式去組織。就可以了。
同時,我們可以根據自己的實際去制定 flutter 的 source 路徑。也通過 repo 將原生的module 和 dart 的lib目錄,分成2個git倉庫。就完美實現了代碼的隔離。對于原生開發來說,后面的構建打包等持續集成都不會收到 flutter 的影響。
混合工程的架構如下:
混合工程啟動和調試
在一個 flutter 工程中,我們一般是使用 flutter run 命令啟動一個 flutter 應用。這時候我們就會有關注到:混合工程中,我們進入app會先進入原生頁面,如何再進入 flutter 頁面。那么我們如何使用熱重載和調試功能呢。
熱重載
以 Andorid 為例,我們可以先給 app 進行 ./gradlew assembleDebug 打出一個 apk 包。
然后使用
flutter run --use-application-binary {debug apk path} 復制代碼命令。會啟動我們的原生 app, 進入特定的 flutter 入口頁面,命令行會自動出現 flutter 的 hot reload。
混合工程調試
那么我們如何進行 flutter 工程的調試呢?我們可以通過給原生的端口和移動設備的 Observatory 端口進行映射。其實這個方法也同樣適用于我們運行了一個純 flutter 應用,想通過類似 attach 原生進程的方式里面開始斷點。
命令行啟動app, 出現flutter 的hotreload 后,我們可以看到
An Observatory debugger and profiler on Android SDK built for x86 is available at: http://127.0.0.1:54946/ 復制代碼這端。這個地址,我們可以打開一個關于 dart 的性能和運行情況的展示頁面。
我們記錄下這個端口 xxxx
然后通過 adb logcat | grep Observatory 查看手機的端口,可以看到如下輸出
我們把最后一個地址輸入到手機的瀏覽器,可以發現手機上也可以打開這個頁面
我們可以理解成這里是做了一次端口映射,設備上的端口記錄為 yyyy
在 Android Studio 中,我們在 run -> Edit Configurations 里面,新建一個 dart remote debug, 填寫 xxxx 端口。
如果不成功,可以手動 forward 一下
adb forward tcp:xxxx tcp:yyyy 復制代碼然后啟動這個調試器,就可以進行 dart 的斷點調試了。
原生能力和插件開發
在 flutter 開發中,我們需要經常使用原生的功能,具體的可以參考 官方文檔, native 和 flutter 通過傳遞消息,來實現互相調用。
架構圖如下
查看源碼,可以看到 flutter 包括 4 中 Channel 類型。
- BasicMessageChannel 是發送基本的信息內容的通道
- MethodChannel和 OptionalMethodChannel是發送方法調用的通道
- EventChannel 是發送事件流 stream 的通道。
在 Flutter 的封裝中,官方對純 Flutter 的 library 定義為 Package, 對調用了原生能力的 libraray 定義為 Plugin。
官方同時也提供了 Plugin 工程的腳手架。通過 flutter create --org {pkgname} --template=plugin xx 創建一個 Plugin 工程。內部包括三端的 library 代碼,也包括了一個 example 目錄。里面是一個依賴了此插件的 flutter 應用工程。具體可以參考插件文檔
在實踐中,我們可以發現 Plugin 的依賴關系如下。 例如我們的 Flutter 應用叫 MyApp, 里面依賴了一個 Plugin 叫做 MyPlugin。那么,在 Andorid APP 中,庫依關系如下圖
但是如果我們在創建插件工程的時候,原生部分代碼,不能依賴到插件的原生 aar。這樣每次編譯的時候就會在 GeneratedPluginRegistrant 這個類中報錯,依賴關系就變成了下圖
我們會發現紅色虛線部分的依賴在插件工程中是不存在的。
仔細思考一下會發現,其實我們在 Flutter 應用工程中使用 Plugin 的時候,只是在 pubspec.yaml 中添加了插件的依賴。原生部分是怎么依賴到插件的呢?
通過比較 flutter create xx(應用工程) 和 flutter create --template=plugin (插件工程) ,我們會發現在settings.gradle 中有一些不一樣。應用工程中,有如下一段自動生成的 gradle 代碼
gradle 會去讀取一個 .flutter-plugins 文件。從這里面讀取到插件的原生工程地址,include 進來并制定了 path。
我們查看一個 .flutter-plugins 文件:
path_provider=/Users/chenglei/flutter/.pub-cache/hosted/pub.flutter-io.cn/path_provider-1.1.0/復制代碼我們也可以大致猜測到,flutter的 gradle 腳本里面會把自己include進來的插件工程全部依賴一遍。
從這個角度,我們發現插件工程開發還是有一些規則上的限制的。 從開發的角度看,必須遵循腳手架的規范編寫代碼。如果依賴其他的插件,必須自己寫腳本解決上面的依賴問題。 從維護的角度看,插件工程仍然需要至少一個android 同學 加一個 iOS 同學進行維護。
所以我們在涉及原生的 Flutter 基礎庫開發中,沒有采用原生工程的方式。而是通過獨立的 fluter package、獨立的android ios module打二進制包的形式。
flutter基礎設施之路
基于上一小節的結論,我們開發了自己的一套 flutter 基礎設置。我們的基建大致從下面幾個角度出發
- 利用現有能力:基于 Channel 調用原生的能力,例如網絡、日志上報。可以收攏 APP 中這些基礎操作
- 質量和穩定性:Flutter 是新技術,我們如何在它上線的時候做到心中有底
- 開發規范:從早期就定下第一版的代碼結構、技術棧選擇,對于后面的演進益大于弊
利用現有能力
我們封裝了 Channel,開發了一個 DartBridge 框架。負責原生和 Dart 的互相調用。在此之上,我們開發了網絡庫、統一跳轉庫等基礎設施
DartBridge
反觀 e網通 APP 在 webview 的通信,是在消息到達另一端后,通過統一的路由調用格式進行路由調用。對于路由提供方來說,只識別路由協議,不關心調用端是哪一段。在一定程度上,我們也可以把統一的路由協議理解為“跨平臺”。我們內部協議的格式是如下形式:
scheme://{"domain":"", "action":"", "params":""}
所以在 Flutter 和原生的通信中,結合實際業務場景,我們沒有使用 MethodChannel,而是使用了 BasicMessageChannel, 通過這一個 channel,發送最基本的路由協議。被調用方收到后,調用各自的路由庫,返回調用結果給通道。我們封裝了一套 DartBridge 來進行消息的傳遞。
通過閱讀源碼我們可以發現,Channel 的設計非常的完美。它解耦了消息的編解碼方式,在 Codec 對象中,我們可以進行我們的自定義編碼,例如序列化為 json 對象的 JsonMessageCodec。
var _dartBridgeChannel = BasicMessageChannel(DART_BRIDGE_CHANNEL,JSONMessageCodec()); 復制代碼在實際開發中,我們可能想要查詢消息內容。如果消息的內容是獲取原生的內容,例如一個學生的作業總數,我們希望在原生提供服務前,不阻塞自己的開發。并且在不修改業務代碼的情況下獲取到路由的mock數據。所以我們在路由的內部增加了攔截器和mock服務的功能。在sdk初始化的時候,我們可以通過對象配置的方式,配置一些對應 domain、action的mock數據。
整個 DartBridge 的架構如下
基于這個架構模型,我們收到消息后,通過原生路由(例如 ARouter)方案,去進行相應的跳轉或者服務調用。
網絡庫 EIO
Flutter 提供了自己的http 包。但是集成到原生app的時候,我們仍然希望網絡這個基礎操作的口子可以被統一管理。包括統一的https支持,統一的網絡攔截操作,以及可能進行的統一網絡監控和調優。所以在Android中,網絡庫我們選擇調用 OKHttp。
但是考慮到如果有新的業務需求,我們開發了一個全新的flutter app,也希望在不更改框架層的代碼,就可以直接移植過去,并且脫離原生的請求。
這就意味著網絡架構需要把 網絡配置 和 網絡引擎 解耦開。本著不重復造輪子的原則,我們發現了一個非常優秀的框架:DIO
DIO 留下了一個 HttpClientAdapter 類,進行網絡請求的自定義。
我們實現了這個類,在 fetch() 函數中,通過 DartBridge,對原生的網絡請求模塊進行調用。返回的數據是一個包括:
- nativeBytes List 網絡數據的字節流
- statusCode 網絡請求的 http code
- headers Map<String, dynamic> 網絡的 response headers
這些數據,通過 Okhttp 請求可以獲取。這里有一個細節問題。在 OkHttp 中,請求到的 bytes是一個 byte[], 直接給到dart 這邊,被我強轉成了一個List, 因為java 中 byte的范圍是 -126 - 127 ,所以這時候,就出現了亂碼。
通過對比實際的dart dio請求到的相同的字節流,我發現,byte中的一些數據轉換成int的時候發生了溢出,變成了負數,產生了亂碼。正好是做一次補碼運算,就成了正確的。所以。我在 dart 端,對數據做了一次統一的轉化:
nativeBytes = nativeBytes.map((it) {if (it < 0) {return it + 256;} else {return it;}}).toList(); 復制代碼關于 utf8 和 byte 具體的編解碼過程,我們不做贅述。感興趣的同學可以參考一下這篇文章
統一路由跳轉
在 DartBridge 框架的基礎上,我們對接原生的路由框架封裝了我們自己的統一跳轉。目前我們的架構還比較簡單,采用了還是多容器的架構,在業務上去規避這點。我們的容器頁面其實就是一個 FlutterActivity,我們給容器也設置了一個 path,原生在跳轉flutter的時候,其實是跳轉到了這個容器頁。在容器頁中,拿到我們實際的 Flutter path 和 參數。偽代碼如下:
val extra = intent?.extrasextra?.let {val path = it.getString("flutterPath") ?: ""val params = HashMap<String, String>()extra.keySet().forEach { key ->extra[key]?.let { value ->params[key] = value.toString()}}path.isNotEmpty().let {// 參數通過 bridge 告訴flutter的第一個 widget// 在flutter頁面內實現真正的跳轉DartBridge.sendMessage<Boolean>("app", "gotoFlutter",HashMap<String,String>().apply {put("path", path)put("params", params)}, {success->Log.e("native跳轉flutter成功", success.toString())}, { code, msg->Log.e("native跳轉flutter出錯", "code:$code;msg:$msg")})}} 復制代碼那么,業務在原生跳往 Flutter 頁面的時候,我們每次都需要知道容器頁面的path嗎,很明顯是不能這樣的。 所以我們在上面敘述的基礎上,抽象了一個 flutter 子路由表。進行單獨維護。 業務只需要跳往自己的子路由表內的 path,在 SDK內部,會把實際的path 替換成容器的 path,把路由表 path 和跳轉參數整體作為實際的參數。
在 Andorid 中,我提供了一個 pretreatment 函數,在 ARouter 的 PretreatmentService 中調用進行處理。返回最終的路由 path 和 參數。
質量和穩定性
線上開關
為了保證新技術的穩定,在 Flutter 基礎 SDK 中,我們提供了一個全局開關的配置。這個開關目前還是高粒度的,控制在進入 Flutter 頁面的時候是否跳轉容器頁。 在開關處理的初始化中,需要提供 2 個參數
- 是否允許線上打開 Flutter 頁面
- 在不能打開 Flutter 頁面的時候,提供一個 Flutter 和 native 頁面的路由映射表。跳轉到對應的原生頁面或者報錯頁。
線上開關可以和 APP 現有的無線配置中心對接。如果線上出現 Flutter 的質量問題。我們可以下發配置來控制頁面跳轉實現降級。
異常收集
在原生開發中,我們會使用例如 bugly 之類的工具查看線上收集的 crash 異常堆棧。Flutter 我們應該怎么做呢?在開發階段,我們經常會發現 Flutter 出現一個報錯頁面。 閱讀源碼,我們可以發現其實這個錯誤的顯示是一個 Widget:
在 ComponentElement 的 performRebuild 函數中有如下調用
在調用 build 方法 ctach 到異常的時候,會返回顯示一個 ErrorWidget。進一步查看會發現,它的 builder 是一個 static 的函數表達式。
(FlutterErrorDetails details) => ErrorWidget(details.exception)
它的參數最終也返回了一個私有的函數表達式 _debugReportException
最終這里會調用 onError 函數,可以發現它也是一個 static 的函數表達式
那么對于異常捕獲,我們只需要重寫下面 2 個函數就可以進行 build 方法中的視圖報錯
- ErrorWidget.builder
- FlutterError.onError
到這一步,我們進行了視圖的異常捕獲。在 dart 的異步操作中拋出的異常又該如何捕獲呢。查詢資料我們得到如下結論:
在 Flutter 中有一個 Zone 的概念,它代表了當前代碼的異步操作的一個獨立的環境。Zone 是可以捕獲、攔截或修改一些代碼行為的
最終,我們的異常收集代碼如下
void main() {runMyApp(); }runMyApp() {ErrorHandler.flutterErrorInit(); // 設置同步的異常處理需要的內容runZoned(() => runApp(MyApp()), // 在 zone 中執行 MyAppzoneSpecification: null,onError: (Object obj, StackTrace stack) {// Zone 中的統一異常捕獲ErrorHandler.reportError(obj, stack);}); } 復制代碼開發規范
在開發初期,我們就內部商議定下了我們的 Flutter 開發規范。重點在代碼的組織結構和狀態管理庫。 開發結構我們考慮到未來有新增多數 Flutter 代碼的可能,我們選擇按照業務分模塊管理各自的目錄。
. +-- lib | +-- main.dart | +-- README.md | +-- business | +-- business1 | +-- module1 | +-- business1.dart | +-- store | +-- models | +-- pages | +-- widgets | +-- repositories | +-- common | +-- ui | +-- utils | +--comlib | +-- router | +-- network 復制代碼在每個業務中,根據頁面和具體的視圖模塊,分為了 page 和 widgets 的概念。store 中,我們會存放相關的狀態管理。repositories 中我們要求業務把各自的邏輯和純異步操作抽象為獨立的一層。每個業務早期可以維護一個自己的 common, 可以在迭代中不停的抽象自己的 pakcage,并沉淀到最終面向每個人的 comlib。這樣,基本可以保證在迭代中避免大家重復造輪子導致的代碼冗余混亂。
在狀態管理的技術選型上,我們調研了包括 Bloc、'redux和mobx`。我們的結論是
- flutter-redux 的概念和設計非常的優秀,但是適合統一的全局狀態管理,其實和組件的分割又有很大的矛盾。在開源方案中,我們發現 fish-redux 很好的解決了這個問題。
- Bloc 的大致思路其實和 redux 有很高的相似度。但是功能還是不如 redux 多。
- mobx,代碼簡單,上手快。基本上搞清楚 Observables、Actions和Reactions幾個概念就可以愉快的開發。
最終處于上手成本和代碼復雜度的考慮,我們選擇了 mobx 作為我們的狀態管理組件。
總結
到這里,我分享了一些 Flutter 的原理和我們的一些實踐。希望能和一些正在研究 Flutter 的同學進行交流和學習。我們的 Flutter 在基礎設施開發的同時,還剝離編寫了一些 升學e網通 APP 上的頁面和一些基礎的 ui 組件庫。在未來我們會嘗試在一些老的頁面中,上線 Flutter 版本。并且研究更好的基礎庫、異常收集平臺、工具鏈優化和單容器相關的內容。
轉載于:https://juejin.im/post/5d3c341c6fb9a07ecf726ced
總結
以上是生活随笔為你收集整理的Flutter 在铭师堂的实践的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 沉思录---Windows Phone软
- 下一篇: 接口批量测试