Flutter 是 Google 的一套跨平臺 UI 框架。目前已是 1.7 的 Release 版本。在移動端雙端投入人力較大,短時間緊急需求的背景下。跨端技術會成爲愈來愈多的移動端技術棧選擇。銘師堂移動端團隊在過去幾個月,對 Flutter 技術作了一些嘗試和工做。這篇文章將會對 Flutter 的基本原理和咱們在 升學e網通 APP
的工程實踐作一個簡單的分享。前端
Flutter framework 層的架構圖以下:java
Foundation: foundation 提供了 framework 常用的一些基礎類,包括但不限於:node
BindBase: 提供了提供單例服務的對象基類,提供了 Widgets、Render、Gestures等能力android
Key: 提供了 Flutter 經常使用的 Key 的基類ios
AbstractNode:表示了控件樹的節點git
在 foundation 之上,Flutter 提供了 動畫、繪圖、手勢、渲染和部件,其中部件就包括咱們比較熟悉的 Material 和 Cupertino 風格web
咱們從 dart 的入口處關注 Flutter 的渲染原理shell
void runApp(Widget app) {
WidgetsFlutterBinding.ensureInitialized()
..attachRootWidget(app)
..scheduleWarmUpFrame();
}
複製代碼
咱們直接使用了 Widgets 層的能力json
負責根據咱們 dart 代碼提供的 Widget 樹,來構造實際的虛擬節點樹redux
在 FLutter 的渲染機制中,有 3 個比較關鍵的概念:
根據 attachRootWidget
的流程,咱們能夠了解到佈局樹的構造流程
attachRootWidget
建立根節點attachToRenderTree
建立 root Elementmount
方法把本身掛載到父 Element。這裏由於本身是根節點,因此能夠忽略掛載過程mount
會經過 createRenderObject
建立 root Element 的 RenderObject到這裏,整顆 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 的渲染。
@protected
void drawFrame() {
assert(renderView != null);
pipelineOwner.flushLayout();
pipelineOwner.flushCompositingBits();
pipelineOwner.flushPaint();
renderView.compositeFrame(); // this sends the bits to the GPU
pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.
}
複製代碼
RenderBinding 的 drawFrame
實際闡明瞭 render obejct 的渲染流程。即 佈局(layout)、繪製(paint)、合成(compositeFrame)
在佈局和渲染中,咱們會觀察到 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-looper 執行任務的順序是
flutter 的異步模型以下圖
每個 GUI 都離不開手勢/指針的相關事件處理。
在 GestureBiding 中,在 _handlePointerEvent
函數中,PointerDownEvent
事件每處理一次,就會建立一個 HintTest
對象。在 HintTest
中,會存有每次通過的控件節點的 path。
最終咱們也會看到一個 dispatchEvent
函數,進行事件的分發以及 handleEvent
,對事件進行處理。
在根節點的 renderview 中,事件會開始從 hitTest
處理,由於咱們添加了事件的傳遞路徑,因此,時間在通過每一個節點的時候,都會被」處理「。
@override // from HitTestDispatcher
void 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 文檔。
Flutter 在 Widgets 之上,實現了兼容 Andorid/iOS 風格的設計。讓APP 在 ui/ue 上有類原生的體驗。
根據咱們本身的實踐,我從 混合開發、基礎庫建設和平常的採坑的角度,分享一些咱們的心得體會。
咱們的 APP 主題大部分是 native 開發完成的。爲了實踐 Flutter,咱們就須要把 Flutter 接入到原生的 APP 裏面去。而且能知足以下需求:
咱們的原生架構是多 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 基礎設置。咱們的基建大體從下面幾個角度出發
咱們封裝了 Channel
,開發了一個 DartBridge
框架。負責原生和 Dart 的互相調用。在此之上,咱們開發了網絡庫、統一跳轉庫等基礎設施
反觀 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)方案,去進行相應的跳轉或者服務調用。
Flutter 提供了本身的http 包。可是集成到原生app的時候,咱們仍然但願網絡這個基礎操做的口子能夠被統一管理。包括統一的https支持,統一的網絡攔截操做,以及可能進行的統一網絡監控和調優。因此在Android中,網絡庫咱們選擇調用 OKHttp。
可是考慮到若是有新的業務需求,咱們開發了一個全新的flutter app,也但願在不更改框架層的代碼,就能夠直接移植過去,而且脫離原生的請求。
這就意味着網絡架構須要把 網絡配置
和 網絡引擎
解耦開。本着不重複造輪子的原則,咱們發現了一個很是優秀的框架:DIO
DIO 留下了一個 HttpClientAdapter
類,進行網絡請求的自定義。
咱們實現了這個類,在 fetch()
函數中,經過 DartBridge
,對原生的網絡請求模塊進行調用。返回的數據是一個包括:
這些數據,經過 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?.extras
extra?.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 個參數
線上開關能夠和 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
ErrorWidget.builder = (details) {
return YourErrorWidget();
};
複製代碼
FlutterError.onError
FlutterError.onError = (FlutterErrorDetails details) {
// your log report
};
複製代碼
到這一步,咱們進行了視圖的異常捕獲。在 dart 的異步操做中拋出的異常又該如何捕獲呢。查詢資料咱們獲得以下結論:
在 Flutter 中有一個 Zone
的概念,它表明了當前代碼的異步操做的一個獨立的環境。Zone 是能夠捕獲、攔截或修改一些代碼行爲的
最終,咱們的異常收集代碼以下
void main() {
runMyApp();
}
runMyApp() {
ErrorHandler.flutterErrorInit(); // 設置同步的異常處理須要的內容
runZoned(() => runApp(MyApp()), // 在 zone 中執行 MyApp
zoneSpecification: 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 版本。而且研究更好的基礎庫、異常收集平臺、工具鏈優化和單容器相關的內容。
最好放上個人微信公衆號二維嗎,感興趣的朋友能夠關注一下。一塊兒分享、探討技術知識