Flutter 重構 QTalk

作者介紹

趙龍,2020 年加入 Qunar,擔任大前端 iOS 開發,OC、SWIFT、 C++、Dart 等技能豐富,喜歡優化開發流程,研究增加效率的代碼和開發方式。

QTalk 是去哪兒網內部的一個 IM 溝通工具,同時集成了很多內部的系統,比如 OA 審批,門禁打卡,請假審批,預定會議室,駝圈(駝廠朋友圈)等功能;方便內部辦公溝通、交流的同時,也爲無紙化辦公,流程審批等提供了支持。

一、首先看一下 QTalk 的原有產品框架

在決定 Flutter 重構之前,我們盤點了現有的 QTalk 工程架構的問題:

1. 各端差異性大: Android、iOS 以及 QT 開發框架 (一個 C++ 桌面端跨平臺解決方案) 三端邏輯代碼差異大,代表性的有 Web 加載邏輯,移動端 ReactNative 頁面加載邏輯等,排查問題根源時 3 端都會有不同的情況,解決方案也不相同。

2. 研發效率低: 需要維護 3 套代碼,在現有人力資源下,保證功能完整按時上線已經比較喫緊,還需要及時解決線上各種問題。

3. 架構層次較差: 各端在架構設計上分層各不相同且不清晰,數據流推送方向複雜是主要的兩個問題。

4. 原生代碼複雜度高: 藍色區域代表了使用原生平臺能力的代碼,它們在各平臺相互之間不可複用且容易在版本升級中出現適配問題,在實現需求的時候容易出現各端表現不一致返工的情況。

綜上所述,我們決定重構 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 類型對象,在各個模塊中處理數據庫存儲與交互邏輯層將數據處理完畢之後可以使用訂閱者模式分發到各個界面使用。

相比於舊的架構,新的架構有這些優勢:

  1. 業務表現層基本抹平了各端差異,我們用一套代碼實現了 5 端的 UI (Android、iOS、Mac、Windows、Linux), UI 整體代碼複用率達到 80% 以上,避免了原有各端的表現差異帶來的 UI 適配額外工作量。

  2. 邏輯與數據層除了個別能力(例如推送)必須使用原生代碼,其餘功能都 Dart 的統一實現,在維護和做新需求時工時減少約 50%。

  3. 在整個 APP 數據流動過程中,所有關於界面的數據都使用單向數據流,同時合理分層,降低了應用複雜度,所有組件都不需要保存狀態,只負責根據數據源渲染。

四、Flutter 重構 QT 移動端遇到的問題

  1. 在 QTalk 中使用混合棧

QT 中大部分頁面都是可以使用 Flutter 重構的 IM 業務頁面,但是另外一些頁面面臨更新頻繁,維護方不合適放在 IM 團隊的問題,例如 QT 發現頁,使用 ReactNative 開發,QT 只作爲入口展示,所以我們需要一套混合 ReactNative 頁面與 Flutter 的技術方案,現在 Flutter 的主流混合技術棧有 2 種:

  1. Flutterboost 單引擎實現混合頁面開發。

  2. Flutter2.0 中官方發佈的 FlutterEngineGroup 使用多引擎解決問題,優化了內存佔用和數據共享方式。

我們在 QT 中對 2 種混合方式都進行了嘗試,最終發現的它們各有利弊,如下:

wfRFqv

在嘗試了兩張方式的改造工程,感慨兩種方式都有不足之處時,我們想到可以利用 Flutter2.0 混合視圖的新特性,走自己的第三條路線:使用 PlatformView 的把 ReactNative 頁面與 Flutter 頁面混合起來,使用 Flutter 的路由能力支持這個頁面跳轉。

這樣做的好處是,在移動端和 Flutter 視角里,ReactNative 頁面的生命週期都耦合在了 ReactNative 頁面內部,使用的時候可以當做一個單純的 view 看待,所以我們可以在不介入 Native 頁面生命週期的情況下,只把 Native 端當做一個橋來傳遞 Flutter 與 ReactNative 頁面參數,ReactNative 頁面原本與 Native 的交互方式不變,只加了 Native 與 Flutter 之間的 PlatformChannel 參數傳遞。

例子如下:

//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 傳遞過來的 ReactNative 頁面 View 與 FlutterView 混合生成一個新的頁面,這個頁面可以接受 Flutter 棧的調用,與 Flutter 其他頁面互相傳參與切換都與純 Flutter 頁面沒有區別,這樣在路由層面規避了各個端互相調用的適配問題。

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 混合棧低效及難開發的問題

  1. QT 的數據傳遞方式

QT Flutter 在初期嘗試了 provider,BLoC,mobx 等數據流管理方案,它們的優缺點我們列了一個表:

JqsXeQ

下面有一些具體的使用體驗:

  1. provider 是最初選擇的數據管理方案,由官方提供,使用的時候 model 類需要繼承 ChangeNotifier,使用 Consumer 包裹需要改變的組件,一個新開發 Flutter 的同學上手很難把頁面邏輯與頁面 UI 分開,導致耦合嚴重,需要制定代碼規範,而且如果 Consumer 包裹範圍過大,一不小心就會影響性能表現,造成不必要的卡頓。

  2. Bloc 的 demo 中我們看到整個流程異步化,分離了邏輯與 UI,但是引入這個方案對代碼的侵入比 provider 大,而在指定代碼規範 StreamProvider 可以完全實現 Bloc 的功能,另外相對於 Redux 類型的管理方案,它沒有合併到 store 的繁瑣寫法跟限制,同時也爲共享數據或者多個數據同時影響同一個 view 時的混亂埋下伏筆,所以我們沒有采用。

  3. mobx 的 demo 中我們發現它有自身的優點,不用在更新數據時寫 notify 代碼,但是它是雙向數據綁定,自由度比較大,在沒有代碼規範的情況下,容易把 get 與 set 的動作順序搞混,而且在性能層面根據我們的測試,在有大量數據改變的情況下,它的數據傳遞與合併會造成程序效率降低。

  4. redux 方案中使用純函數 dispatcher 來修改 state,相對於雙向綁定的方式它會分離使用者更新數據與使用數據的操作,會有模板規範使用者,但是 combineReducers 這個操作會使頁面複用變得困難,改動很多。

  5. fish-redux 是由 redux 定製修改版本,邏輯的隔離粒度更細,自動實現了合併 reducer,解耦頁面的功能,另外它也存在一些問題,比如全局變量使用會耦合所有用到的頁面,寫法繁瑣等。

  6. 我們開始希望使用 fish-redux 全局 store 來充當長鏈接和 http 接口的回調的觸發器,使用過程裏發現 fish-redux 的 globalstore 需要先在 route 中與將要使用的 page 綁定,每個使用到 global 屬性的頁面也需要增加屬性接受綁定,這樣與頁面分治的目的相悖了,重用這個頁面的時候也會因爲跟 global 的關係造成額外的開發量。

依據以上體驗,我們最終決定使用了兩種方式來管理與傳遞數據。

Fish-redux 用在邏輯層 IMMessage module 對象邏輯構建與表現層中,使 QT 原有的多方向型龐雜的數據架構變得整齊劃一便於梳理,各個頁面層級開發時拆解爲獨立的 page,擴展可以使用 connector 即插即用,協作開發時降低了因爲人員變動造成代碼在頁面層面造成混亂的可能性。

如圖,我們在編寫代碼時只需要關心的每一個頁面內部的單向數據流,對頁面數據合併沒有感知,而每個頁面由 5 個文件組成:Action, Effect, Reducer, State, View,把使用各種方式對數據做處理與頁面刷新分割開來,從工程層面和頁面層面都維護了代碼的秩序。

用於數據庫對象與 IMMessage module 對象,數據層與邏輯層溝通。

通過事件總線來觸發事件和監聽事件,它是一種單例管理分發數據的模式,輕量級,全局可用,可以在沒有渲染 context 對象參與的情況下傳遞數據,分治數據邏輯與業務。

3. ListView 改造

Flutter 現在版本的 Listview 在生成每個 item 時,不會根據 model 預取高度,而是在渲染完成以後再統計 item 高,這樣就造成了幾個後果。

a. ListView 不支持按 index 跳轉,在 item 不等高的情況下沒有簡單的方式直接跳轉到對應 index。

b. 跳轉不在屏幕內的位置時,ListView 因爲還不知道這個位置是不是在可滑動範圍內,所以只能先嚐試跳轉,如果最終的跳轉位置大於可滑動範圍,就會產生彈跳。

c. 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);
 });
 }
  1. 準確獲取 iOS 鍵盤高度

iOS 鍵盤高度計算不準確,導致切換鍵盤與表情時高度不一致,使聊天界面抖動

原因: 因爲有些機型在 safeArea 的 bottom 高度不爲 0,一般寫法會直接將聊天頁面寫入一個 safeArea 中, 而鍵盤彈出時 safearea 的 bottom 又會清 0,導致鍵盤高度跳動。

解決方案: 初始化 App 後,本地記錄 safeArea 的 bottom 高度,然後在聊天界面中去掉 safeArea 包裝,使用本地記錄的高度,給底部輸入框增加高度避免與 iOS 導航欄重合。

5.Dart 代碼無法與 Native 代碼一起進行斷點調試

原因: Dart 與 Native 代碼分別進行編譯,在運行時只能 link 一方的代碼,編譯器無法解析另一方產生的庫。

解決方案: 首先在 Xcode 或者 Android Studio 中,由 Native 端啓動 App,然後打開編譯 Dart 代碼的 ide 或者終端,使用 flutter attach 命令連接你的 Dart 代碼到運行中的應用,這時候就可以同時調試 Native 與 Dart 與代碼了。

五、 QT 桌面端遇到的問題與解決方案

  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(),
}
  1. 多 Window 創建,互傳消息,調用 PC 端 native 能力

PC 端有很多原生平臺相關能力 Flutter-desktop 尚未擁有,比如多窗口,錄屏,web 使用,拖拽文件共享,menubar 配置等

解決方案: 引入 NativeShell 框架,採用多引擎方式解決 PC 端遇到的多窗口問題,改變工程結構,在 dart 啓動 main 函數之前增加一個 rust 類來管理窗口,調用 rust 中的各平臺系統庫來把各種語言(c++ c# oc 等)寫成系統 api 統一成 rust 類型的文件,減少平臺差異性。

適配 NativeShell 中也遇到過很多問題,列舉 2 個例子:

a. 打包腳本空安全報錯

cargo 是 rust 包管理器,NativeShell 使用 cargo 爲桌面端打包,NativeShell 默認打包腳本里不允許沒有適配 null safety 的庫加入工程,我們重新梳理了打包腳本並且在加入了在 Flutter 編譯時非空判斷,最終順利在 rust 環境裏打出了 Mac 與 Windows 的包。

b. Mac 客戶端打包問題

NativeShell 打包過程裏,每個 Window 都會產生一個子工程,殼工程直接引用了子工程目錄,最終的包裏會含有大量中間產物,造成包體積特別大,我們改造了這個流程,只把子工程產生的 dll 與 framework 加入最終產物中,打出了正常大小的包。我們還與作者溝通,提出了 PR,最終這些代碼和建議合併到了製作方打包工具當中.

  1. PC 端多窗口同時向主窗口發送消息,造成的 Dart 主 isolate 指令排隊

說明這個問題之前我們先了解一下 Flutter 的事件循環原理:

Dart 應用中,有一個事件循環和 兩個隊列:event queue 和 microtask queue。

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 可以選擇重發消息。

六、技術上的成果總結

  1. 產品開發方向:

a. 以前三個工程的開發量變爲一個,開發的週期縮短一半以上;

b. 開發完成以後各端一致性明顯提高,反工與線上出現各端差異性問題概率降低。

  1. 技術架構方向:

a. 整理以前各端在邏輯層與業務層耦合的地方,解決了多方向數據流造成的代碼不易維護問題,建立一個 Flutter 代碼工程規範;

b. 決了 Flutter 在搭建聊天頁面業務層各種 native 適配問題,爲團隊積累了 Flutter 開發經驗;

c. 解決混合棧等與原工程頁面混合的問題,探索了與原生(iOS,Android 等平臺)RN Web 等類型頁面各種混合方式的優劣,相信以後公司其他業務中引入 Flutter 會順利很多。

  1. 性能數據與用戶體驗上:

a. iOS 包大小由原來的 200M 縮小到 117M,安卓包大小由 44.9M 縮小到 27.5M;

b. 移動端 Flutter 版本內存水位與 Native 版本基本相同;

c. 用戶打開 APP 速度提升,原 QTalk APP 冷啓動時間 iOS 與 Andriod 平均是 2.6s,現在平均時間是 1.5s,提升 42%。

七、接下來的要做的方向

  1. 手機端已經上線 - 需要解決長連接穩定性以及補全業務代碼,完善功能與開發適合 QTalk 的 pipline。

  1. PC 端尚未開發完畢:需要完成 Windows 端數據庫選型實現以及截屏,鍵盤功能,native 端的 sdk 接入,接入合適的日誌收集工具。

  2. 參與大客戶端對於 Flutter 的遷移改造,用我們的經驗解決混合棧,長列表和數據流以及設備端適配方面的問題。

Flutter 探索之路沒有結束,QTalk 團隊仍在努力,我們後續會找機會跟大家就 Flutter 相關技術作進一步交流。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/qG0L2CDuQYGLohPYwQsc9g