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

稻子_Aadan發表於2020-05-06

時間線

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 程式碼;
  • 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 人天 雙端人天 人效提升
賬單管理 18.5 26 40%
自助還車 12.5 21 68%
19 年綜合 -- -- 90%

業務價值

通過引入 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 的區別,不使用元件化這個名詞。

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

module

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

  • 模組掛載
  • 模組初始化
  • 模組非同步初始化

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

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

頁面間通訊 1.0

模組間通訊,本質上主要是頁面間通訊。

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

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

topic_center 提供的能力:

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

topic_center Flutter 端按需同步原生狀態的資料流:

topic_center.png

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

void putValue<T>(T value, String topic);

Future<T> getValue<T>(String topic);

Stream<T> getValueStream<T>(String topic);

void putListValue<E>(E value, String topic);

Future<List<E>> getListValue<E>(String topic);

Stream<List<E>> getListValueStream<E>(String topic);

void putMapValue<K, V>(Map<K, V> value, String topic);

Future<Map<K, V>> getMapValue<K, V>(String topic);

Stream<Map<K, V>> getMapValueStream<K, V>(String topic);

void putTuple2Value<T0, T1>(Tuple2<T0, T1> value, String topic);

Future<Tuple2<T0, T1>> getTuple2Value<T0, T1>(String topic);

Stream<Tuple2<T0, T1>> getTuple2ValueStream<T0, T1>(String topic);

void putTuple3Value<T0, T1, T2>(Tuple3<T0, T1, T2> value, String topic);

Future<Tuple3<T0, T1, T2>> getTuple3Value<T0, T1, T2>(String topic);

Stream<Tuple3<T0, T1, T2>> getTuple3ValueStream<T0, T1, T2>(String topic);

void putTuple4Value<T0, T1, T2, T3>(Tuple4<T0, T1, T2, T3> value, String topic);

Future<Tuple4<T0, T1, T2, T3>> getTuple4Value<T0, T1, T2, T3>(String topic);

Stream<Tuple4<T0, T1, T2, T3>> getTuple4ValueStream<T0, T1, T2, T3>(String topic);

void putTuple5Value<T0, T1, T2, T3, T4>(Tuple5<T0, T1, T2, T3, T4> value, String topic);

Future<Tuple5<T0, T1, T2, T3, T4>> getTuple5Value<T0, T1, T2, T3, T4>(String topic);

Stream<Tuple5<T0, T1, T2, T3, T4>> getTuple5ValueStream<T0, T1, T2, T3, T4>(String topic);

複製程式碼

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

頁面棧管理 1.0

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

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

flutter_boost 的優勢:

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

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

hybrid_stack

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

flutter_boost

架構 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

方案可以參考 ThrioModuleThrioModule 的 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,還複用了原生的頁面容器,頁面棧結構如下:

thrio_stack

thrio 的路由

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

頁面的 push
  • 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 端開啟頁面
[ThrioNavigator pushUrl:@"flutter1"];
// 接收所開啟頁面的關閉回撥
[ThrioNavigator pushUrl:@"biz2/flutter2" poppedResult:^(id _Nonnull params) {
    ThrioLogV(@"biz2/flutter2 popped: %@", params);
}];
複製程式碼
  • Android 端開啟頁面
ThrioNavigator.push(this, "biz1/flutter1",
        mapOf("k1" to 1),
        false,
        poppedResult = {
            Log.e("Thrio", "native1 popResult call params $it")
        }
)
複製程式碼
頁面的 pop
  • dart 端關閉頂層頁面
ThrioNavigator.pop(params: 'popped flutter1'),
複製程式碼
  • iOS 端關閉頂層頁面
[ThrioNavigator popParams:@{@"k1": @3}];
複製程式碼
  • Android 端關閉頂層頁面
ThrioNavigator.pop(this, params, animated)
複製程式碼
頁面的 popTo
  • dart 端關閉到頁面
ThrioNavigator.popTo(url: 'flutter1');
複製程式碼
  • iOS 端關閉到頁面
[ThrioNavigator popToUrl:@"flutter1" animated:NO];
複製程式碼
  • Android 端關閉到頁面
ThrioNavigator.popTo(context, url, index)
複製程式碼
頁面的 remove
  • dart 端關閉特定頁面
ThrioNavigator.remove(url: 'flutter1', animated: true);
複製程式碼
  • iOS 端關閉特定頁面
[ThrioNavigator removeUrl:@"flutter1" animated:NO];
複製程式碼
  • Android 端關閉特定頁面
ThrioNavigator.remove(context, url, index)
複製程式碼

thrio 的頁面通知

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

傳送頁面通知
  • dart 端給特定頁面發通知
ThrioNavigator.notify(url: 'flutter1', name: 'reload');
複製程式碼
  • iOS 端給特定頁面發通知
[ThrioNavigator notifyUrl:@"flutter1" name:@"reload"];
複製程式碼
  • Android 端給特定頁面發通知
ThrioNavigator.notify(url, index, params)
複製程式碼
接收頁面通知
  • dart 端接收頁面通知

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

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

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

- (void)onNotify:(NSString *)name params:(id)params {
  ThrioLogV(@"native1 onNotify: %@, %@", name, params);
}
複製程式碼
  • Android 端接收頁面通知

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

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 記憶體,模擬器上跑出來的資料大致如下:

demo 啟動 頁面 1 頁面 2 頁面 3 頁面 4 頁面 5
thrio 8.56 37.42 38.88 42.52 42.61 42.76
boost 6.81 36.08 50.96 66.18 78.86 91.67

thrio 在 Android 上的頁面開啟速度

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

demo 頁面 1 頁面 2 頁面 3 頁面 4 頁面 5
thrio 242 45 39 31 37
boost 247 169 196 162 165

總結

總的來說,我們在 B 端引入 Flutter 總結下來人效提升是非常明顯的。當然過程中也遇到了非常多的問題,但相對於人效提升來說,解決這些問題的成本都是可接受的。

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

相關文章