Categories
程式開發

Flutter 在哈囉出行 B 端創新業務的實踐


時間線

Flutter 在我們團隊的起步算是比較晚的,直到 Flutter 要出 1.0 版本前夕才開始實踐。

大概的時間線如下:

  • 2018 年 11 月初,在 B 端小範圍嘗試接入 Flutter;
  • 2018 年 12 月 5 日,Flutter 發布了 1.0;
  • 2019 年 4 月中旬,開始大範圍使用;
  • 2019 年 6 月中旬,Flutter 在業務上的效率提升效果開始體現出來;
  • 2019 年 7 月中旬,我所在的業務線的 B 端基本上全員轉 Flutter 進行移動端開發;
  • 2020 年 1 月初,我們用 Flutter 開發了非常多的頁面,積累超過 10 萬行 Flutter 代碼,開始嘗試去 flutter_boost 的解決方案;
  • 2020 年 3 月中旬,開源 Flutter 嵌入原生移動 App 混合棧解決方案。

實踐路線

作為一個創新業務的團隊,要做一門全新技術棧的技術儲備面臨以下幾個問題:

  • 團隊可投入時間少,要保證業務迭代;
  • 團隊成員沒有 Flutter 技術棧的基礎;
  • 如何驗證引入 Flutter 能帶來什麼業務價值。

這三個問題都是非常現實的問題,如果沒有明確的路線規劃盲目的引入 Flutter 的,踩坑過多最終會導入投入產出比太低而在業務上無法接受。

我把實踐路線主要分一下四個階段:

  • 路線規劃
  • 技術儲備
  • 業務驗證
  • 持續集成

下面介紹在每個階段我們做了哪些事以及獲得的成果和經驗。

路線規劃階段

目標設定:提升人效 50% ~ 100%

關鍵行動

  • 能用 Flutter 進行開發的優先使用 Flutter 來開發,不大範圍使用 Flutter 進行開發是很難達成提升人效的目標的;
  • Flutter 方案不成熟的直接使用原生開發,避免踩坑過多降低人效,比如地圖,存在地圖的頁面,我們還是直接用原生進行開發;
  • 不在早期引入狀態管理的庫,避免入門成本上升,也避免引入之後代碼量變多;
  • 團隊成員分批入坑 Flutter,不過於保守也不能太過於激進,避免在引入 Flutter 階段對業務迭代的影響;
  • 做好降級,異常監控等穩定性相關的工作。

技術儲備階段

demo 驗證

在技​​術儲備階段,主要是準備最小可驗證的 demo,驗證以下幾點:

  • 驗證 Flutter 嵌入現有 iOS 和 Android App 的方案,最終採用 Flutter 官方提供的解決方案;
  • 驗證 Flutter 包管理中的 開發模式發布模式,雖然作為創新業務,但哈囉出行的B 端集合了幾乎所有業務線的功能,我們在實踐Flutter 的時候不能影響其它業務線的正常開發,所以我們需要一個發布模式,避免其它的開發者也要安裝Flutter 的開發環境;
  • 驗證 包大小內存佔用,以及 性能 是否滿足,作為創新業務的 B 端 App,在這方面我們可能要求並不高,不做展開;
  • 解決 Flutter 異常收集和監控 的問題,底褲是一定要穿上的,考慮各種方案之後,最終選擇 Sentry 作為早期的解決方案;
  • 驗證 混合棧 管理的方案是否可行,最終採納 flutter_boost 的方案;
  • 解決原生和 dart 狀態同步 的問題,為了避免開發過多的插件來做狀態的同步,抽象了一個通用的狀態同步插件;
  • 驗證持續集成的方案。

建立規範

沒有規範,會增加後續人員的入門成本:

  • 包和分支管理的規範,作為一個多業務線的 App,包管理一定要考慮後續其它業務線接入的情況;
  • dart 編碼規範,主要是 dart linter 的接入,考量每個規則以及規則之間存在的衝突,解決這些規則上的衝突,因為最終要求每一個 linter 的警告都必須解決掉;
  • 建立 最佳實踐 的積累方式,讓團隊每個人能避免他人踩過的坑。

人員準備

團隊分成兩組,先後入坑 Flutter,主要做以下準備:

  • 了解 dart 語言,能用 dart 進行基本的頁面開發;
  • 了解 開發規範,包括包和分支管理、編碼等規範;
  • 盡量查閱相關的最佳實踐。

業務驗證階段

降級方案

雖然我們是創新業務,但出於對線上敬畏之心,我們依然準備了降級的方案,一旦 Flutter 上線之後影響到 App 的穩定性,可以隨時降級。

所以我們選擇了既有的模塊,將這些模塊用 Flutter 重新開發一遍。同時也為後續的人效對比提供數據支撐。

代碼量減少

僅供參考,我們 Flutter 的代碼量實踐下來會比任何一端的代碼量都少一些,相對於 iOS,我們一般是純代碼佈局,代碼量減少更多。

更少的代碼,一定程度上表示更少的 bug,更少的 bug 表示花在修復 bug 上的時間也減少了。

多端一致性

Flutter 渲染的多端一致性,讓我們在 UI 佈局上所花費的時間更少了。當然早期的 Flutter SDK 在處理字體、光標等方面略有差異,甚至有 bug,但都不是很大的問題。

人效提升

僅供參考,畢竟每個團隊的情況不盡相同,業務複雜度也不盡相同。

這裡給出我們早期的三個數據的對比,19 年我們下半年的時間基本上進入了純Flutter 開發的階段,但iOS 和Android 兩端還是需要分別打包、測試、上線,這會一定程度上降低人效提升的百分比,所以我們綜合的人效提升會在90% 左右。

Flutter 在哈囉出行 B 端創新業務的實踐 1

業務價值

通過引入Flutter,我們在業務上能更快的進行迭代,使用Flutter 開發的部分人效提升接近90% 左右,因為我們總歸是有一些功能需要用原生進行開發的,這部分工作量不好做對比。

這達成了我們最初引入 Flutter 設定的目標,提升了整個團隊的人效,完美的支撐了業務的快速迭代。

持續集成階段

在業務驗證階段,我們達成了提升人效 90% 的目標之後,欠缺的持續集成需要被提上日程,最緊迫的兩個事情就是 插件發布編譯產物發布

作為一個業務團隊,我們依然沒有太多精力投入到工程建設上,所以很多工程化相關的能力,最開始都是手工的方式進行的,大概可以分幾個階段:

  • 手工發布,持續 3 個月;
  • 腳本發布,持續 2 個月;
  • 一鍵發布,19 年 12 月份至今。

手工發布

  • flutter plugin 的發布都是手工活,比如 iOS 發布 pod 源碼和 Android 的 aar 都是手工進行的,部分還需要拷貝代碼;
  • flutter 編譯產物的發布也是一樣靠手工,一定程度上降低了人效;

腳本發布

這個階段主要是通過腳本實現 插件發布編譯產物發布 的半自動化,但依然沒有集成到 App 發布的 CI 系統。

這個階段也是在不斷完善發布腳本,最終效果是根據 pubspec.yaml 文件的描述,自動發布有更新的插件,並最終發布編譯產物。

一鍵發布

將現有的發布腳本集成到 App 發布的 CI 系統,效果就是一鍵打包,徹底將這塊活自動化。

架構 1.0 的建設

架構建設方面,我們需要解決的三個主要問題:

  • 頁面模塊化
  • 頁面間通信
  • 頁面棧管理

在解決這三個問題的過程中,我們大致經歷了從 架構 1.0架構 2.0,除了頁面模塊化基本保持不變,頁面間通信、頁面棧管理從 架構 1.0架構 2.0 的變化是非常大的。

頁面狀態管理 在我們的業務上還不是一個主要問題,我們也嘗試過引入 bloc,但還未進行足夠探索,所以這裡不做展開。

頁面模塊化 1.0

模塊化的定義,根據業務域劃分不同的業務模塊,為了避免與 WebComponent 的區別,不使用組件化這個名詞。

如何劃分模塊這可能需要另外一篇文章來說明,簡單來說就是業務域的劃分。要保持模塊的內聚,每個模塊的初始化需要獨立進行,要做到這點,我們的方案是將所有模塊掛載到模塊樹上,類似文件夾的樹形結構。

Flutter 在哈囉出行 B 端創新業務的實踐 2

頁面模塊化 1.0 主要提供以下能力:

  • 模塊掛載
  • 模塊初始化
  • 模塊異步初始化

掛載完成之後,初始化 root 模塊,會將所有掛載在樹上的模塊都進行初始化。這個樹形結構在葉子節點就是頁面,頁面的路徑天然可作為頁面的 url。

模塊劃分本質上是根據業務域對頁面進行組織。不管是單一倉庫還是多倉庫,都可以通過這種簡單的樹形結構來實現模塊掛載和初始化。

頁面間通信 1.0

模塊間通信,本質上主要是頁面間通信。

移動端很多模塊化的方案,都會將模塊間通信作為主要能力進行建設,我們在原生端也有一套這類方案,但在Flutter 嵌入原生應用中,並不能簡單複用這套方案,如果生搬硬套會帶來很多的編碼量,並不是一個很輕量的解決方案。

頁面間通信的能力,需要重頭開始建設,早期我們抽象了一個狀態同步的方案,開發一個插件 topic_center 專門用來給原生和 dart 進行狀態同步。

topic_center 提供的能力:

  • 原生模塊間的狀態同步
  • Flutter 模塊間的狀態同步
  • Flutter 端按需同步原生狀態
  • 三端一致的狀態的獲取與訂閱 API

topic_center Flutter 端按需同步原生狀態的數據流:

Flutter 在哈囉出行 B 端創新業務的實踐 3

topic_center 提供如下的 API,topic_center 遵循 Flutter 的多端一致性原則,我們在三端提供了一樣的 API,下面僅展示 dart 的 API 定義:

dart
void putValue(T value, String topic);

Future getValue(String topic);

Stream getValueStream(String topic);

void putListValue(E value, String topic);

Future getListValue(String topic);

Stream getListValueStream(String topic);

void putMapValue(Map value, String topic);

Future getMapValue(String topic);

Stream getMapValueStream(String topic);

void putTuple2Value(Tuple2 value, String topic);

Future getTuple2Value(String topic);

Stream getTuple2ValueStream(String topic);

void putTuple3Value(Tuple3 value, String topic);

Future getTuple3Value(String topic);

Stream getTuple3ValueStream(String topic);

void putTuple4Value(Tuple4 value, String topic);

Future getTuple4Value(String topic);

Stream getTuple4ValueStream(String topic);

void putTuple5Value(Tuple5 value, String topic);

Future getTuple5Value(String topic);

Stream getTuple5ValueStream(String topic);

topic_center 是我們在 架構 1.0 時提供的頁面間通信解決方案,後面會講到我們在進行架構升級之後提供的更輕量級的解決方案。

頁面棧管理 1.0

如果沒有混合棧管理,我們在原生應用上引入 Flutter 將是一個極為麻煩的事情,我們可能為此維護比較混亂的 Channel 通信層。

flutter_boost 是閒魚開源的優秀的 Flutter 混合棧管理解決方案,也是當時社區唯一可選的解決方案。

flutter_boost 的優勢:

  • Flutter 頁面的路由與原生頁面一樣
  • Flutter 頁面的交互手勢與原生頁面一樣
  • 提供頁面關閉回傳參數的能力

如果不使用 flutter_boost,我們的頁面結構可能是這樣的:

Flutter 在哈囉出行 B 端創新業務的實踐 4

使用了 flutter_boost 之後可以是這樣的:

Flutter 在哈囉出行 B 端創新業務的實踐 5

架構 1.0 的問題

頁面間通信 1.0 的問題

  • topic 的管理成本過高

topic_center 插件能解決頁面間通信的問題,但有一個不算問題的問題,對 topic 的管理成本過高。為了避免全局topic 重複的問題,每個頁面狀態的同步都需要在topic 上帶上各種前綴,一般就是模塊、子模塊、功能、頁面作為前綴,然後這個topic 最後長得跟頁面的url 極為相似。為了解決這個問題,需要想辦法去掉這個 topic 的管理成本。

  • 源碼過於復雜

topic_center 這個庫的投入產出比實在是不高,源碼過於復雜 帶來不只是解決方案的複雜,也帶來 維護成本推高 很多。

頁面棧管理 1.0 的問題

  • 路由 API 過於簡陋

比如,項目上需要實現關閉到某個頁面的場景,或者刪除當前頁面之下的某個頁面,我們需要在flutter_boost 上自行擴展,且難於維護,如何跟官方的flutter_boost 保持代碼同步是一個艱難的事情。

  • 使用的開源庫的 API 不再向後兼容

我們在項目上大量使用頁面回傳參數的能力,但是該 API 在新版本上被移除了。

  • 最大的問題 iOS 內存佔用過高

flutter_boost iOS 端的實現方案,在實際項目上使用時,我們只能將每一個 Flutter 頁面都套在一個原生的 FlutterViewController 中 ,這直接導致每打開一個 Flutter 頁面的內存佔用高出 10M 左右。

為了解決這些問題,我們開始了 架構 2.0 的建設。

架構 2.0 的建設

架構 2.0 主要是解決 頁面間通信 1.0頁面棧管理 2.0 的解決方案存在的一些問題而演變出來的,同時對 頁面模塊化 做更細緻的職能分解。

頁面模塊化 2.0

方案可以參考 ThrioModule,ThrioModule 的 API 也遵守多端一致性。

相比於 頁面模塊化 1.0,功能的變遷如下:

  • 模塊掛載 1.0
  • 模塊初始化 1.0
  • 模塊異步初始化 1.0
  • 頁面路由註冊 2.0
  • 頁面路由行為觀察 2.0
  • 頁面生命週期觀察 2.0
  • 頁面通知接收 2.0

以上功能均提供三端一致的 API 2.0

頁面棧路由 2.0

我們開發了 thrio,主要是解決 頁面間通信 1.0頁面棧管理 1.0 中存在的問題。

thrio 的頁面棧結構

thrio 的原理上改善點是除了復用 FlutterEngine,還復用了原生的頁面容器,頁面棧結構如下:

Flutter 在哈囉出行 B 端創新業務的實踐 6

thrio 的路由

thrio 提供了三端一致的路由 API

頁面的 push

  • dart 端打開頁面
dart
ThrioNavigator.push(url: 'flutter1');
// 传入参数
ThrioNavigator.push(url: 'native1', params: { '1': {'2': '3'}});
// 是否动画,目前在内嵌的 dart 页面中动画无法取消,原生 iOS 页面有效果
ThrioNavigator.push(url: 'native1', animated:true);
// 接收锁打开页面的关闭回调
ThrioNavigator.push(
    url: 'biz2/flutter2',
    params: {'1': {'2': '3'}},
    poppedResult: (params) => verbose('biz2/flutter2 popped: $params'),
);
  • iOS 端打開頁面
objc
[ThrioNavigator pushUrl:@"flutter1"];
// 接收所打开页面的关闭回调
[ThrioNavigator pushUrl:@"biz2/flutter2" poppedResult:^(id _Nonnull params) {
    ThrioLogV(@"biz2/flutter2 popped: %@", params);
}];
  • Android 端打開頁面
kotlin
ThrioNavigator.push(this, "biz1/flutter1",
        mapOf("k1" to 1),
        false,
        poppedResult = {
            Log.e("Thrio", "native1 popResult call params $it")
        }
)

頁面的 pop

  • dart 端關閉頂層頁面
dart
ThrioNavigator.pop(params: 'popped flutter1'),
  • iOS 端關閉頂層頁面
objc
[ThrioNavigator popParams:@{@"k1": @3}];
  • Android 端關閉頂層頁面
kotlin
ThrioNavigator.pop(this, params, animated)

頁面的 popTo

  • dart 端關閉到頁面
dart
ThrioNavigator.popTo(url: 'flutter1');
  • iOS 端關閉到頁面
objc
[ThrioNavigator popToUrl:@"flutter1" animated:NO];
  • Android 端關閉到頁面
kotlin
ThrioNavigator.popTo(context, url, index)

頁面的 remove

  • dart 端關閉特定頁面
dart
ThrioNavigator.remove(url: 'flutter1', animated: true);
  • iOS 端關閉特定頁面
objc
[ThrioNavigator removeUrl:@"flutter1" animated:NO];
  • Android 端關閉特定頁面
kotlin
ThrioNavigator.remove(context, url, index)

thrio 的頁面通知

頁面通知作為解決頁面間通信的一個能力被引入 thrio,以一種非常輕量的方式解決了 topic_center 所要解決的問題,而且不需要管理 topic。

發送頁面通知

  • dart 端給特定頁面發通知
dart
ThrioNavigator.notify(url: 'flutter1', name: 'reload');
  • iOS 端給特定頁面發通知
objc
[ThrioNavigator notifyUrl:@"flutter1" name:@"reload"];
  • Android 端給特定頁面發通知
kotlin
ThrioNavigator.notify(url, index, params)

接收頁面通知

  • dart 端接收頁面通知

使用 NavigatorPageNotify 這個 Widget 來實現在任何地方接收當前頁面收到的通知。

dart
NavigatorPageNotify(
      name: 'page1Notify',
      onPageNotify: (params) =>
          verbose('flutter1 receive notify: $params'),
      child: Xxxx());
  • iOS 端接收頁面通知

UIViewController實現協議NavigatorPageNotifyProtocol,通過 onNotify 來接收頁面通知:

objc
- (void)onNotify:(NSString *)name params:(NSDictionary *)params {
  ThrioLogV(@"native1 onNotify: %@, %@", name, params);
}
  • Android 端接收頁面通知

Activity實現協議OnNotifyListener,通過 onNotify 來接收頁面通知:

kotlin
class Activity : AppCompatActivity(), OnNotifyListener {
    override fun onNotify(name: String, params: Any?) {
    }
}

因為 Android activity 在後台可能會被銷毀,所以頁面通知實現了一個懶響應的行為,只有當頁面呈現之後才會收到該通知,這也符合頁面需要刷新的場景。

架構 2.0 的優勢

在我們的業務上存在很多模塊,進去之後是,首頁 -> 列表頁 -> 詳情頁 -> 處理頁 -> 結果頁,大致會是連續打開 5 個 Flutter 頁面的場景。

這裡會對 架構 1.0架構 2.0 我們所使用的解決方案做一些優劣對比,僅表示我們業務場景下的結果,不一樣的場景不具備可參考性。

在此僅列出兩個比較明顯的改善措施,這些改善主要是原理層面的優勢帶來的,不代表 thrio 的實現比 flutter_boost 高明,另外數據僅供參考,只是為了說明原理帶來的優勢。

thrio 在 iOS 上的內存佔用

同樣連續打開 5 個頁面的場景,boost 的方案會消耗 91.67M 內存,thrio 只消耗 42.76 內存,模擬器上跑出來的數據大致如下:

Flutter 在哈囉出行 B 端創新業務的實踐 7

thrio 在 Android 上的頁面打開速度

同樣連續打開5 個頁面的場景,thrio 打開第一個頁面跟boost 耗時是一樣的,因為都需要打開一個新的Activity,之後4 個頁面thrio 會直接打開Flutter 頁面,耗時會降下來,以下單位為ms:

Flutter 在哈囉出行 B 端創新業務的實踐 8

總結

總的來說,引入 Flutter 是一個很明智的選擇,人效提升是非常明顯的。如果你的 App 對包大小不敏感,那完全可以嘗試在項目中引入 Flutter。

當然過程中也遇到了非常多的問題,但相對於人效提升來說,解決這些問題的成本都是可接受的。

如果你想要無縫的將 Flutter 引入現有項目,thrio 可能會節省你很多精力。當然 thrio 是個非常年輕的庫,相比於前輩 flutter_boost 還有很長的路要走,也歡迎有興趣的同學給 thrio 貢獻代碼。

作者:稻子,就職於哈囉出行。