QTalk 是去哪兒網內部的一個 IM 溝通工具,同時整合了很多內部的系統,比如 OA 審批,門禁打卡,請假審批,預定會議室,駝圈(駝廠朋友圈)等功能;方便內部辦公溝通、交流的同時,也為無紙化辦公,流程審批等提供了支援。
一、原有產品框架
在決定 Flutter 重構之前,我們盤點了現有的 QTalk 工程架構的問題,主要表現為:
- 各端差異性大:Android、iOS 以及 QT 開發框架 (一個 C++桌面端跨平臺解決方案) 三端邏輯程式碼差異大,代表性的有 Web 載入邏輯,移動端 React Native 頁面載入邏輯等,排查問題根源時 3 端都會有不同的情況,解決方案也不相同。
- 研發效率低:需要維護 3 套程式碼,在現有人力資源下,保證功能完整按時上線已經比較吃緊,還需要及時解決線上各種問題。
- 架構層次較差:各端在架構設計上分層各不相同且不清晰,資料流推送方向複雜是主要的兩個問題。
- 原生程式碼複雜度高:藍色區域代表了使用原生平臺能力的程式碼,它們在各平臺相互之間不可複用且容易在版本升級中出現適配問題,在實現需求的時候容易出現各端表現不一致返工的情況。
為了降低開發成本,提高開發效率,儘可能的將程式碼在各個平臺進行復用,於是我們決定重構 QTalk。
二、為什麼要選 Flutter
Flutter 的優勢是渲染效能高與抹平了各端差異,根源在於 Flutter 採用了自主渲染引擎把控了渲染流程,保證了效率,相當於一個應用跑在了遊戲引擎裡。
以往就有人希望用 cocos2d 或者 unity 來製作應用,達到跨端一致與節省工時的目的,但是遊戲引擎渲染是逐幀渲染,原生(iOS、Android)渲染方式是業務驅動,即有模型改動的情況下才渲染,相比之下游戲的渲染方式對效能消耗過大,包大小多倍增加,而 Flutter 通過對渲染流程的改造基本解決了這些問題,Flutter 在渲染時與原生渲染一樣,都會產生渲染樹,只有渲染樹發生改變的時候,重繪製才會啟動,而繪製一般也只發生在有改變的區域。
因 QTalk 開發資源緊缺,所以需要一個跨平臺框架來提升效率。同時 QTalk 也是公司內平時溝通的主要方式,頁面流暢性需要有保障。QTalk 常用的長短連線、長列表、Web 等,Flutter 官方和社群也有一個良好的支援。混合開發在 Flutter 2.0 中也得到官方引擎的支援,所以我們決定使用 Flutter 來開發新版 QTalk。
三、Flutter 版 QTalk 框架
可以看到,資料層來源於推送或 http 或者長連線,處理完成後變成 Flutter 中的 IMMessage 型別物件,在各個模組中處理資料庫儲存與互動邏輯層將資料處理完畢之後可以使用訂閱者模式分發到各個介面使用,而上層的UI層使用Flutter進行開發,遮蔽了各層的差異,達到了最大。
相比於舊的架構,新的架構帶來了如下的優勢:
- 業務表現層基本抹平了各端差異,我們用一套程式碼實現了5端的UI ( Android、iOS、Mac、Windows、Linux), UI 整體程式碼複用率達到 80% 以上,避免了原有各端的表現差異帶來的UI適配額外工作量。
- 邏輯與資料層除了個別能力(例如推送)必須使用原生程式碼,其餘功能都 Dart 的統一實現,在維護和做新需求時工時減少約 50%。
- 在整個 APP 資料流動過程中,所有關於介面的資料都使用單向資料流,同時合理分層,降低了應用複雜度,所有元件都不需要儲存狀態,只負責根據資料來源渲染。
四、遇到的問題
4.1 混合棧
QT 中大部分頁面都是可以使用 Flutter 重構的 IM 業務頁面,但是另外一些頁面面臨更新頻繁,維護方不合適放在 IM 團隊的問題,例如 QT 發現頁,使用 ReactNative 開發,QT 只作為入口展示,所以我們需要一套混合 ReactNative 頁面與 Flutter 的技術方案,現在 Flutter 的主流混合技術棧有 2 種:
- Flutterboost 單引擎實現混合頁面開發。
- Flutter2.0 中官方釋出的 FlutterEngineGroup 使用多引擎解決問題,優化了記憶體佔用和資料共享方式。
我們在 QT 中對 2 種混合方式都進行了嘗試,最終發現的它們各有利弊,如下表:
方案 | Flutterboost | FlutterEngineGroup |
---|---|---|
優勢 | ioslate 共享記憶體,頁面間資料傳遞方便 | 官方支援,程式碼侵入小,效能幾乎不受影響 |
劣勢 | 升級成本大,增加一個頁面消耗比較大,iOS 記憶體消耗大(新版有改善),工程結構需要根據 boost 大改 | ioslate 層不能共享記憶體,直接互相呼叫比較麻煩 |
不過,我們並沒有使用上面的兩種方案,而是利用 Flutter2.0 混合檢視的新特性,走自己的第三條路線:使用 PlatformView 的把 React Native 頁面與 Flutter 頁面混合起來,使用 Flutter 的路由能力支援這個頁面跳轉。
這樣做的好處是,在移動端和 Flutter 視角里,ReactNative 頁面的生命週期都耦合在了 ReactNative 頁面內部,使用的時候可以當做一個單純的 view 看待,所以我們可以在不介入 Native 頁面生命週期的情況下,只把 Native 端當做一個橋來傳遞 Flutter 與 ReactNative 頁面引數,React Native 頁面原本與 Native 的互動方式不變,只加了Native與 Flutter 之間的 PlatformChannel 引數傳遞。
Native與 Flutter通訊使用的是Channel,因此我們進行如下的封裝:
//Flutter 呼叫原生
const MethodChannel _channel =
const MethodChannel('com.mqunar.flutterQTalk/rn_bridge'); //註冊channel
_channel.invokeMapMethod('onWorkbenchShow', {});
//原生呼叫Flutter
_channel.setMethodCallHandler((MethodCall call) async {
var classAndMethod = call.method.split('.');
var className = classAndMethod.first;
if (mRnBridgeHandlers[className] == null) {
throw Exception('not found method response');
}
RNBridgeModule bridgeModule = mRnBridgeHandlers[className]!;
return bridgeModule.handleBridge(call);
});
在 Flutter 端,使用 Native 傳遞過來的 React Native頁面 View 與 FlutterView 混合生成一個新的頁面,這個頁面可以接受 Flutter 棧的呼叫,與Flutter 其他頁面互相傳參與切換都與純 Flutter 頁面沒有區別,這樣在路由層面規避了各個端互相呼叫的適配問題。對應的獲取 React Native頁面的程式碼如下:
Widget getReactRootView(
ReactNativePageState state, Dispatch dispatch, ViewService viewService) {
//安卓與iOS分別處理
if (defaultTargetPlatform == TargetPlatform.android) {
return PlatformViewLink(
viewType: VIEW_TYPE,
surfaceFactory:
(BuildContext context, PlatformViewController controller) {
return AndroidViewSurface(
controller: controller as AndroidViewController,
gestureRecognizers: const <Factory<OneSequenceGestureRecognizer>>{},
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
);
},
onCreatePlatformView: (PlatformViewCreationParams params) {
return PlatformViewsService.initSurfaceAndroidView(
id: params.id,
viewType: VIEW_TYPE,
layoutDirection: TextDirection.ltr,
creationParams: state.params,
creationParamsCodec: StandardMessageCodec(),
)
..addOnPlatformViewCreatedListener(params.onPlatformViewCreated)
..create();
},
);
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
return UiKitView(
viewType: VIEW_TYPE,
creationParams: state.params,
creationParamsCodec: const StandardMessageCodec());
} else {
return Text("Placeholder");
}
}
這樣,我們只增加了很少的程式碼,就解決了 Flutter 混合棧低效及難開發的問題。
4.2 資料傳遞
Flutter 在初期嘗試了 provider,BLoC,mobx 等資料流管理方案,它們的優缺點我們列了一個表。
provider | BLoC | mobx | redux | fish-redux | |
---|---|---|---|---|---|
優勢 | 效能高,官方支援 | 處理非同步事件效率高,分層清晰 | 狀態操作簡單,程式碼少,容易上手 | 單資料流,檢視和業務邏輯分離 | redux 優點基礎上,具有自動合併 reducer,隔離元件的功能,擴充套件性強 |
劣勢 | 容易在 view 中寫邏輯容易使 view 與model 產生耦合 | 狀態共享時容易寫出錯誤邏輯 | 資料合併效率低,過於自由的使用方式容易使程式碼耦合 | 1.redux store 的集中與頁面元件分治之間的矛盾 2.reducer 需要手動合併 | 相對於 mobx 寫起來繁瑣一些 |
下面是一些具體的使用體驗:
- provider: provider是最初選擇的資料管理方案,由官方提供,使用的時候 model 類需要繼承 ChangeNotifier,使用 Consumer 包裹需要改變的元件,一個新開發 Flutter 的同學上手很難把頁面邏輯與頁面 UI 分開,導致耦合嚴重,需要制定程式碼規範,而且如果 Consumer 包裹範圍過大,一不小心就會影響效能表現,造成不必要的卡頓。
- Bloc:分離了邏輯與 UI,但是引入這個方案對程式碼的侵入比 provider 大,而在指定程式碼規範 StreamProvider 可以完全實現 Bloc 的功能,另外相對於 Redux 型別的管理方案,它沒有合併到store的繁瑣寫法跟限制,同時也為共享資料或者多個資料同時影響同一個 view 時的混亂埋下伏筆,所以我們沒有采用。
- Mobx:Mobx 的優點表現為,不用在更新資料時寫 notify 程式碼,但是它是雙向資料繫結,自由度比較大,在沒有程式碼規範的情況下,容易把 get 與 set 的動作順序搞混,而且在效能層面根據我們的測試,在有大量資料改變的情況下,它的資料傳遞與合併會造成程式效率降低。
- Redux :Redux方案中使用純函式 dispatcher 來修改 state,相對於雙向繫結的方式它會分離使用者更新資料與使用資料的操作,會有模板規範使用者,但是 combineReducers 這個操作會使頁面複用變得困難,需要寫很多的額外程式碼。
- fish-redux :fish-redux是由 redux 定製修改版本,邏輯的隔離粒度更細,自動實現了合併reducer,解耦頁面的功能,另外它也存在一些問題,比如全域性變數使用會耦合所有用到的頁面,寫法繁瑣等。
我們開始希望使用 fish-redux 全域性 store 來充當長連結和 http 介面的回撥的觸發器,使用過程裡發現 fish-redux 的 globalstore 需要先在 route 中與將要使用的 page 繫結,每個使用到global屬性的頁面也需要增加屬性接受繫結,這樣與頁面分治的目的相悖了,重用這個頁面的時候也會因為跟global的關係造成額外的開發量。依據以上的狀態管理框架的使用體驗,我們最終決定使用了兩種方式來管理與傳遞資料:Fish-redux和Eventbus。
Fish-redux
Fish-redux 用在邏輯層 IMMessage module 物件邏輯構建與表現層中,使 QT 原有的多方向型龐雜的資料架構變得整齊劃一便於梳理,各個頁面層級開發時拆解為獨立的 page,擴充套件可以使用connector 即插即用,協作開發時降低了因為人員變動造成程式碼在頁面層面造成混亂的可能性。
如圖,我們在編寫程式碼時只需要關心的每一個頁面內部的單向資料流,對頁面資料合併沒有感知,而每個頁面由 5 個檔案組成:Action、Effect、Reducer、State和View,把使用各種方式對資料做處理與頁面重新整理分割開來,從工程層面和頁面層面都維護了程式碼的秩序。
Eventbus
Eventbus的作用是用於資料庫物件與 IMMessage module 物件,資料層與邏輯層溝通。通過事件匯流排來觸發事件和監聽事件,它是一種單例管理分發資料的模式,輕量級,全域性可用,可以在沒有渲染 context 物件參與的情況下傳遞資料,分治資料邏輯與業務。
4.3 ListView改造
Flutter 現在版本的 Listview 在生成每個 item 時,不會根據 model 預取高度,而是在渲染完成以後再統計 item 高,這樣就造成了幾個後果。
- ListView 不支援按 index 跳轉,在 item 不等高的情況下沒有簡單的方式直接跳轉到對應 index。
- 跳轉不在螢幕內的位置時,ListView 因為還不知道這個位置是不是在可滑動範圍內,所以只能先嚐試跳轉,如果最終的跳轉位置大於可滑動範圍,就會產生彈跳。
- scrollToEnd 方法,如果 List 末尾 item 不在螢幕內,則按照螢幕內的item平均高度估計末尾index所在位置,滑動之後,如果最終滑動停留位置不在最後一個item上,還要進行二次甚至三次跳轉。
我們解決的方案也很簡單:引入 scrollable_positioned_list 控制元件,本質上是生成 2 個 ListView,一個 ListView 負責計算高度,一個 ListView 會真正渲染到介面上,跳轉時先讓第一個 List 跳轉,算出最終的 index 高度,然後第二個 List 跳轉精確的位置,而針對彈跳的問題,我們需要修改 ListView ,在跳轉過程中發現有位移過大的情況,馬上進行修正,示例程式碼如下:
void _jumpTo({@required int index, double offset}) {
...
// 使用偏移量offset
var jumpOffset = 0 + offset;
controller.jumpTo(jumpOffset);
// 渲染之後發現溢位,進行修正
WidgetsBinding.instance.addPostFrameCallback((ts) {
var offset = min(jumpOffset, controller.position.maxScrollExtent);
if (controller.offset != offset) {
controller.jumpTo(offset);
});
}
4.4 獲取 iOS 鍵盤高度
iOS 鍵盤高度計算不準確,導致切換鍵盤與表情時高度不一致,使聊天介面抖動
原因:因為有些機型在 safeArea 的 bottom 高度不為 0,一般寫法會直接將聊天頁面寫入一個 safeArea 中,而鍵盤彈出時 safearea 的 bottom 又會清0,導致鍵盤高度跳動。
解決方案:初始化 App 後,本地記錄 safeArea 的bottom 高度,然後在聊天介面中去掉 safeArea 包裝,使用本地記錄的高度,給底部輸入框增加高度避免與 iOS 導航欄重合。
4.5 混合專案斷點除錯
原因:Dart 與 Native 程式碼分別進行編譯,在執行時只能 link 一方的程式碼,編譯器無法解析另一方產生的庫。
解決方案:首先在 Xcode 或者 Android Studio中,由 Native 端啟動 App,然後開啟編譯 Dart 程式碼的ide或者終端,使用 flutter attach
命令連線你的 Dart 程式碼到執行中的應用,這時候就可以同時除錯 Native 與 Dart 與程式碼了。
五、QT 桌面端遇到的問題
5.1 移動端介面的複用
之前提到過我們的資料管理方案可以使各個頁面解耦,page 作為一個整體可以被其他元件複用,桌面端就是利用這種設計模式,只需要給移動端各個page 增加 connector 就可以把移動端 view 整合為一個桌面端主頁面,對應的邏輯層只需要根據桌面端的特性做一部分適配,例如呼叫API不同,桌面端支援右鍵行為等。
圖中 Page 與 Component 都是 fish-redux 中提供的基本邏輯與 UI 單元,它們可以任意的互相組合,它們滿足了 QTalk 多端複用 UI 與邏輯的需求,也是選型的重要依據。
//各子頁面介面卡程式碼
SessionListComponent.component.dart
SessionListState
{
....
}
SessionListConnector
{
//被this的屬性改變之前呼叫,這個元件的state來自上層元件的state的屬性
get
{
return HomePCPage.scState
}
//自身屬性發生改變以後呼叫,同步上層元件的state
set
{
HomePCPage.scState = this.state;
}
}
//桌面端主頁合成程式碼
HomePCPage.page.dart
HomePCPage
{
....
dependencies:
//過載了+號用於增加子元件屬性,返回一個帶有connector的元件給上層page使用
slot:SessionListConnector() + SessionListComponent(),
}
5.2 多 Window
PC 端有很多原生平臺相關能力 Flutter-desktop 尚未擁有,比如多視窗、錄屏、web 使用、拖拽檔案共享和menubar 配置等。
解決方案:引入 NativeShell 框架,採用多引擎方式解決 PC 端遇到的多視窗問題,改變工程結構,在 dart 啟動 main 函式之前增加一個 rust 類來管理視窗,呼叫 rust 中的各平臺系統庫來把各種語言(c++ c# oc等)寫成系統api統一成 rust 型別的檔案,減少平臺差異性。
適配 NativeShell 中也遇到過很多問題,列舉 2 個例子:
打包指令碼空安全報錯
cargo 是 rust 包管理器,NativeShell 使用 cargo 為桌面端打包,NativeShell 預設打包指令碼里不允許沒有適配 null safety 的庫加入工程,我們重新梳理了打包指令碼並且在加入了在 Flutter 編譯時非空判斷,最終順利在rust 環境裡打出了 Mac 與 Windows 的包。
Mac 客戶端打包問題
NativeShell 打包過程裡,每個 Window 都會產生一個子工程,殼工程直接引用了子工程目錄,最終的包裡會含有大量中間產物,造成包體積特別大,我們改造了這個流程,只把子工程產生的 dll 與 framework 加入最終產物中,打出了正常大小的包。我們還與作者溝通,提出了 PR,最終這些程式碼和建議合併到了製作方打包工具當中。
5.3 多視窗造成主 isolate 指令排隊
說明這個問題之前我們先了解一下 Flutter 的事件迴圈原理:Dart 應用中,有一個事件迴圈和 兩個佇列:事件佇列(event queue )和 微任務佇列(microtask queue)。
- event queue: 包含了所有的外部事件:I/O、滑鼠點選、繪製、定時器、Dart isolate 中的訊息等等。
- microtask queue:事件處理程式碼有時需要在當前 event 之後,且在下一個 event 之前做一些任務。
總的來說,event queue 包含了來自於 Dart 和系統的事件。當前,microtask queue 中僅僅包含了來自於 Dart 的事件。
當 main() 退出,event loop 開始工作。首先是執行所有 microtask,它實際上是一個 FIFO 佇列。接著,它將取出並處理第一個 event queue 中的事件。接著,開始執行迴圈:執行所有 microtask,接著執行 event queue 中下一個事件。一旦兩個 queue 都空了,也就是說沒有事件了,就可能會被宿主(比如瀏覽器)處理了。
如果 event loop 正在執行 microtask 佇列中的事件,那麼 event queue 中的事件處理將被停止,這就意味著影像繪製、處理滑鼠點選,處理 I/O 等等這些事件將無法執行,雖然你可以事先知道 task 執行的順序,但是,你無法知道 event loop 什麼時候從佇列中取出任務。Dart 的事件處理系統是基於一個單執行緒迴圈模型,而不是基於時間系統。舉個例子,當你建立一個延時任務,時間在一個你指定的時間入隊。然而,在它前面的事件沒有被處理完,它無法被處理。
PC 與移動端大部分業務邏輯可複用,但是仍然有少量渲染流程存在差異,最多遇到的情況是多個子視窗同時向主視窗傳送訊息,這些訊息在主isolate中會被加入 event queue,訊息量過大的話,就會使得主 isolate event queue 中事件過多,容易造成主isolate所在頁面卡頓。
針對以上的情況,我們增加了一個分發層來解決這個問題,原有各個邏輯控制元件在處理完畢資料之後,向分發層傳送通知,分發層會統計前一渲染幀向主isolate 的操作請求數量,如果超過了閾值就先加入命令佇列中,等待下一渲染幀再傳送請求,如果命令在佇列裡堆積過長,則暫停接受佇列請求,同時傳送失敗通知到子 isolate,子 isolate 可以選擇重發訊息。