Flutter 在咱們團隊的起步算是比較晚的,直到 Flutter 要出 1.0 版本前夕纔開始實踐。git
大概的時間線以下:github
做爲一個創新業務的團隊,要作一門全新技術棧的技術儲備面臨如下幾個問題:架構
這三個問題都是很是現實的問題,若是沒有明確的路線規劃盲目的引入 Flutter 的,踩坑過多最終會導入投入產出比過低而在業務上沒法接受。異步
我把實踐路線主要分一下四個階段:ide
下面介紹在每一個階段咱們作了哪些事以及得到的成果和經驗。模塊化
目標設定:提高人效 50% ~ 100%組件化
關鍵行動:佈局
在技術儲備階段,主要是準備最小可驗證的 demo,驗證如下幾點:性能
沒有規範,會增長後續人員的入門成本測試
團隊分紅兩組,前後入坑 Flutter,主要作如下準備:
雖然咱們是創新業務,但出於對線上敬畏之心,咱們依然準備了降級的方案,一旦 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% 的目標以後,欠缺的持續集成須要被提上日程,最緊迫的兩個事情就是 插件發佈 和 編譯產物發佈。
做爲一個業務團隊,咱們依然沒有太多精力投入到工程建設上,因此不少工程化相關的能力,最開始都是手工的方式進行的,大概能夠分幾個階段:
這個階段主要是經過腳本實現 插件發佈 和 編譯產物發佈 的半自動化,但依然沒有集成到 App 發佈的 CI 系統。
這個階段也是在不斷完善發佈腳本,最終效果是根據 pubspec.yaml
文件的描述,自動發佈有更新的插件,並最終發佈編譯產物。
將現有的發佈腳本集成到 App 發佈的 CI 系統,效果就是一鍵打包,完全將這塊活自動化。
架構建設方面,咱們須要解決的三個主要問題:
在解決這三個問題的過程當中,咱們大體經歷了從 架構 1.0 到 架構 2.0,除了頁面模塊化基本保持不變,頁面間通訊、頁面棧管理從 架構 1.0 到 架構 2.0 的變化是很是大的。
頁面狀態管理 在咱們的業務上還不是一個主要問題,咱們也嘗試過引入 bloc,但還未進行足夠探索,因此這裏不作展開。
模塊化的定義,根據業務域劃分不一樣的業務模塊,爲了不與 WebComponent 的區別,不使用組件化這個名詞。
如何劃分模塊這可能須要另一篇文章來講明,簡單來講就是業務域的劃分。要保持模塊的內聚,每一個模塊的初始化須要獨立進行,要作到這點,咱們的方案是將全部模塊掛載到模塊樹上,相似文件夾的樹形結構。
頁面模塊化 1.0 主要提供如下能力:
掛載完成以後,初始化 root 模塊,會將全部掛載在樹上的模塊都進行初始化。這個樹形結構在葉子節點就是頁面,頁面的路徑自然可做爲頁面的 url。
模塊劃分本質上是根據業務域對頁面進行組織。無論是單一倉庫仍是多倉庫,均可以經過這種簡單的樹形結構來實現模塊掛載和初始化。
模塊間通訊,本質上主要是頁面間通訊。
移動端不少模塊化的方案,都會將模塊間通訊做爲主要能力進行建設,咱們在原生端也有一套這類方案,但在 Flutter 嵌入原生應用中,並不能簡單複用這套方案,若是生搬硬套會帶來不少的編碼量,並非一個很輕量的解決方案。
頁面間通訊的能力,須要重頭開始建設,早期咱們抽象了一個狀態同步的方案,開發一個插件 topic_center 專門用來給原生和 dart 進行狀態同步。
topic_center 提供的能力:
topic_center Flutter 端按需同步原生狀態的數據流:
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 時提供的頁面間通訊解決方案,後面會講到咱們在進行架構升級以後提供的更輕量級的解決方案。
若是沒有混合棧管理,咱們在原生應用上引入 Flutter 將是一個極爲麻煩的事情,咱們可能爲此維護比較混亂的 Channel 通訊層。
flutter_boost 是閒魚開源的優秀的 Flutter 混合棧管理解決方案,也是當時社區惟一可選的解決方案。
flutter_boost 的優點:
若是不使用 flutter_boost,咱們的頁面結構多是這樣的
使用了 flutter_boost 以後能夠是這樣的
topic_center 插件能解決頁面間通訊的問題,但有一個不算問題的問題,對 topic 的管理成本太高。 爲了不全局 topic 重複的問題,每一個頁面狀態的同步都須要在 topic 上帶上各類前綴,通常就是 模塊、子模塊、功能、頁面做爲前綴,而後這個 topic 最後長得跟頁面的 url 極爲類似。 爲了解決這個問題,須要想辦法去掉這個 topic 的管理成本。
topic_center 這個庫的投入產出比實在是不高,源碼過於複雜 帶來不僅是解決方案的複雜,也帶來 維護成本推高 不少。
好比,項目上須要實現關閉到某個頁面的場景,或者刪除當前頁面之下的某個頁面,咱們須要在 flutter_boost 上自行擴展,且難於維護,如何跟官方的 flutter_boost 保持代碼同步是一個艱難的事情。
咱們在項目上大量使用頁面回傳參數的能力,可是該 API 在新版本上被移除了。
flutter_boost iOS 端的實現方案,在實際項目上使用時,咱們只能將每個 Flutter 頁面都套在一個原生的 FlutterViewController 中 ,這直接致使每打開一個 Flutter 頁面的內存佔用高出 10M 左右。
爲了解決這些問題,咱們開始了 架構 2.0 的建設。
架構 2.0 主要是解決 頁面間通訊 1.0 和 頁面棧管理 2.0 的解決方案存在的一些問題而演變出來的,同時對 頁面模塊化 作更細緻的職能分解。
方案能夠參考 ThrioModule,ThrioModule
的 API 也遵照多端一致性。
相比於 頁面模塊化 1.0,功能的變遷以下:
以上功能均提供三端一致的 API 2.0
咱們開發了 thrio,主要是解決 頁面間通訊 1.0 和 頁面棧管理 1.0 中存在的問題。
thrio 的原理上改善點是除了複用 FlutterEngine,還複用了原生的頁面容器,頁面棧結構以下:
thrio 提供了三端一致的路由 API
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'),
);
複製代碼
[ThrioNavigator pushUrl:@"flutter1"];
// 接收所打開頁面的關閉回調
[ThrioNavigator pushUrl:@"biz2/flutter2" poppedResult:^(id _Nonnull params) {
ThrioLogV(@"biz2/flutter2 popped: %@", params);
}];
複製代碼
ThrioNavigator.push(this, "biz1/flutter1",
mapOf("k1" to 1),
false,
poppedResult = {
Log.e("Thrio", "native1 popResult call params $it")
}
)
複製代碼
ThrioNavigator.pop(params: 'popped flutter1'),
複製代碼
[ThrioNavigator popParams:@{@"k1": @3}];
複製代碼
ThrioNavigator.pop(this, params, animated)
複製代碼
ThrioNavigator.popTo(url: 'flutter1');
複製代碼
[ThrioNavigator popToUrl:@"flutter1" animated:NO];
複製代碼
ThrioNavigator.popTo(context, url, index)
複製代碼
ThrioNavigator.remove(url: 'flutter1', animated: true);
複製代碼
[ThrioNavigator removeUrl:@"flutter1" animated:NO];
複製代碼
ThrioNavigator.remove(context, url, index)
複製代碼
頁面通知做爲解決頁面間通訊的一個能力被引入 thrio,以一種很是輕量的方式解決了 topic_center 所要解決的問題,並且不須要管理 topic。
ThrioNavigator.notify(url: 'flutter1', name: 'reload');
複製代碼
[ThrioNavigator notifyUrl:@"flutter1" name:@"reload"];
複製代碼
ThrioNavigator.notify(url, index, params)
複製代碼
使用 NavigatorPageNotify
這個 Widget
來實如今任何地方接收當前頁面收到的通知。
NavigatorPageNotify(
name: 'page1Notify',
onPageNotify: (params) =>
verbose('flutter1 receive notify: $params'),
child: Xxxx());
複製代碼
UIViewController
實現協議NavigatorPageNotifyProtocol
,經過 onNotify
來接收頁面通知
- (void)onNotify:(NSString *)name params:(id)params {
ThrioLogV(@"native1 onNotify: %@, %@", name, params);
}
複製代碼
Activity
實現協議OnNotifyListener
,經過 onNotify
來接收頁面通知
class Activity : AppCompatActivity(), OnNotifyListener {
override fun onNotify(name: String, params: Any?) {
}
}
複製代碼
由於 Android activity 在後臺可能會被銷燬,因此頁面通知實現了一個懶響應的行爲,只有當頁面呈現以後纔會收到該通知,這也符合頁面須要刷新的場景。
在咱們的業務上存在不少模塊,進去以後是,首頁 -> 列表頁 -> 詳情頁 -> 處理頁 -> 結果頁,大體會是連續打開 5 個 Flutter 頁面的場景。
這裏會對 架構 1.0 和 架構 2.0 咱們所使用的解決方案作一些優劣對比,僅表示咱們業務場景下的結果,不同的場景不具有可參考性。
在此僅列出兩個比較明顯的改善措施,這些改善主要是原理層面的優點帶來的,不表明 thrio 的實現比 flutter_boost 高明,另外數據僅供參考,只是爲了說明原理帶來的優點。
一樣連續打開 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 |
一樣連續打開 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 貢獻代碼。