為什麼寫thrio?
在早期Flutter釋出的時候,谷歌雖然提供了iOS和Android App上的Flutter嵌入方案,但主要針對的是純Flutter的情形,混合開發支援的並不友好。
所謂的純RN、純weex應用的生命週期都不存在,所以也不會存在一個純Flutter的App的生命週期,因為我們總是有需要複用現有模組。
所以我們需要一套足夠完整的Flutter嵌入原生App的路由解決方案,所以我們自己造了個輪子 thrio ,現已開源,遵循MIT協議。
thrio的設計原則
- 原則一,dart端最小改動接入
- 原則二,原生端最小侵入
- 原則三,三端保持一致的API
thrio所有功能的設計,都會遵守這三個原則。下面會逐步對功能層面一步步展開進行說明,後面也會有原理性的解析。
thrio的頁面路由
以dart中的 Navigator
為主要參照,提供以下路由能力:
- push,開啟一個頁面並放到路由棧頂
- pop,關閉路由棧頂的頁面
- popTo,關閉到某一個頁面
- remove,刪除任意頁面
Navigator中的API幾乎都可以通過組合以上方法實現,replace
方法暫未提供。
不提供iOS中存在的 present
功能,因為會導致原生路由棧被覆蓋,維護複雜度會非常高,如確實需要可以通過修改轉場動畫實現。
頁面的索引
要路由,我們需要對頁面建立索引,通常情況下,我們只需要給每個頁面設定一個 url
就可以了,如果每個頁面都只開啟一次的話,不會有任何問題。但是當一個頁面被開啟多次之後,僅僅通過url是無法定位到明確的頁面例項的,所以在 thrio
中我們增加了頁面索引的概念,具體在API中都會以 index
來表示,同一個url第一個開啟的頁面的索引為 1
,之後同一個 url
的索引不斷累加。
如此,唯一定位一個頁面的方式為 url
+ index
,在dart中 route
的 name
就是由 '$url.$index'
組合而成。
很多時候,使用者不需要關注 index
,只有當需要定位到多開的 url
的頁面中的某一個時才需要關注 index
。最簡單獲取 index
的方式為 push
方法的回撥返回值。
頁面的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) => ThrioLogger.v('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")
}
)
複製程式碼
- 連續開啟頁面
- dart端只需要await push,就可以連續開啟頁面
- 原生端需要等待push的result回撥返回才能開啟第二個頁面
- 獲取所開啟頁面關閉後的回撥引數
- 三端都可以通過閉包 poppedResult 來獲取
頁面的pop
- dart 端關閉頂層頁面
// 預設動畫開啟
ThrioNavigator.pop();
// 不開啟動畫,原生和dart頁面都生效
ThrioNavigator.pop(animated: false);
// 關閉當前頁面,並傳遞引數給push這個頁面的回撥
ThrioNavigator.pop(params: 'popped flutter1'),
複製程式碼
- iOS 端關閉頂層頁面
// 預設動畫開啟
[ThrioNavigator pop];
// 關閉動畫
[ThrioNavigator popAnimated:NO];
// 關閉當前頁面,並傳遞引數給push這個頁面的回撥
[ThrioNavigator popParams:@{@"k1": @3}];
複製程式碼
- Android 端關閉頂層頁面
ThrioNavigator.pop(this, params, animated)
複製程式碼
頁面的popTo
- dart 端關閉到頁面
// 預設動畫開啟
ThrioNavigator.popTo(url: 'flutter1');
// 不開啟動畫,原生和dart頁面都生效
ThrioNavigator.popTo(url: 'flutter1', animated: false);
複製程式碼
- iOS 端關閉到頁面
// 預設動畫開啟
[ThrioNavigator popToUrl:@"flutter1"];
// 關閉動畫
[ThrioNavigator popToUrl:@"flutter1" animated:NO];
複製程式碼
- Android 端關閉到頁面
ThrioNavigator.popTo(context, url, index)
複製程式碼
頁面的remove
- dart 端關閉特定頁面
ThrioNavigator.remove(url: 'flutter1');
// 只有當頁面是頂層頁面時,animated引數才會生效
ThrioNavigator.remove(url: 'flutter1', animated: true);
複製程式碼
- iOS 端關閉特定頁面
[ThrioNavigator removeUrl:@"flutter1"];
// 只有當頁面是頂層頁面時,animated引數才會生效
[ThrioNavigator removeUrl:@"flutter1" animated:NO];
複製程式碼
- Android 端關閉特定頁面
ThrioNavigator.remove(context, url, index)
複製程式碼
thrio的頁面通知
頁面通知一般來說並不在路由的範疇之內,但我們在實際開發中卻經常需要使用到,由此產生的各種模組化框架一個比一個複雜。
那麼問題來了,這些模組化框架很難在三端互通,所有的這些模組化框架提供的能力無非最終是一個頁面通知的能力,而且頁面通知我們可以非常簡單的在三端打通。
鑑於此,頁面通知作為thrio的一個必備能力被引入了thrio。
傳送頁面通知
- 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) =>
ThrioLogger.v('flutter1 receive notify: $params'),
child: Xxxx());
複製程式碼
- iOS 端接收頁面通知
UIViewController
實現協議NavigatorPageNotifyProtocol
,通過 onNotify
來接收頁面通知
- (void)onNotify:(NSString *)name params:(NSDictionary *)params {
ThrioLogV(@"native1 onNotify: %@, %@", name, params);
}
複製程式碼
- Android 端接收頁面通知
Activity
實現協議OnNotifyListener
,通過 onNotify
來接收頁面通知
class Activity : AppCompatActivity(), OnNotifyListener {
override fun onNotify(name: String, params: Any?) {
}
}
複製程式碼
因為Android activity在後臺可能會被銷燬,所以頁面通知實現了一個懶響應的行為,只有當頁面呈現之後才會收到該通知,這也符合頁面需要重新整理的場景。
thrio的模組化
模組化在thrio裡面只是一個非核心功能,僅僅為了實現原則二而引入原生端。
thrio的模組化能力由一個類提供,ThrioModule
,很小巧,主要提供了 Module
的註冊鏈和初始化鏈,讓程式碼可以根據路由url進行檔案分級分類。
註冊鏈將所有模組串起來,字母塊由最近的父一級模組註冊,新增模組的耦合度最低。
初始化鏈將所有模組需要初始化的程式碼串起來,同樣是為了降低耦合度,在初始化鏈上可以就近註冊模組的頁面的構造器,頁面路由觀察者,頁面生命週期觀察者等,也可以在多引擎模式下提前啟動某一個引擎。
模組間通訊的能力由頁面通知實現。
mixin ThrioModule {
/// A function for registering a module, which will call
/// the `onModuleRegister` function of the `module`.
///
void registerModule(ThrioModule module);
/// A function for module initialization that will call
/// the `onPageRegister`, `onModuleInit` and `onModuleAsyncInit`
/// methods of all modules.
///
void initModule();
/// A function for registering submodules.
///
void onModuleRegister() {}
/// A function for registering a page builder.
///
void onPageRegister() {}
/// A function for module initialization.
///
void onModuleInit() {}
/// A function for module asynchronous initialization.
///
void onModuleAsyncInit() {}
/// Register an page builder for the router.
///
/// Unregistry by calling the return value `VoidCallback`.
///
VoidCallback registerPageBuilder(String url, NavigatorPageBuilder builder);
/// Register observers for the life cycle of Dart pages.
///
/// Unregistry by calling the return value `VoidCallback`.
///
/// Do not override this method.
///
VoidCallback registerPageObserver(NavigatorPageObserver pageObserver);
/// Register observers for route action of Dart pages.
///
/// Unregistry by calling the return value `VoidCallback`.
///
/// Do not override this method.
///
VoidCallback registerRouteObserver(NavigatorRouteObserver routeObserver);
}
複製程式碼
thrio的頁面生命週期
原生端可以獲得所有頁面的生命週期,Dart 端只能獲取自身頁面的生命週期
- dart 端獲取頁面的生命週期
class Module with ThrioModule, NavigatorPageObserver {
@override
void onPageRegister() {
registerPageObserver(this);
}
@override
void didAppear(RouteSettings routeSettings) {}
@override
void didDisappear(RouteSettings routeSettings) {}
@override
void onCreate(RouteSettings routeSettings) {}
@override
void willAppear(RouteSettings routeSettings) {}
@override
void willDisappear(RouteSettings routeSettings) {}
}
複製程式碼
- iOS 端獲取頁面的生命週期
@interface Module1 : ThrioModule<NavigatorPageObserverProtocol>
@end
@implementation Module1
- (void)onPageRegister {
[self registerPageObserver:self];
}
- (void)onCreate:(NavigatorRouteSettings *)routeSettings { }
- (void)willAppear:(NavigatorRouteSettings *)routeSettings { }
- (void)didAppear:(NavigatorRouteSettings *)routeSettings { }
- (void)willDisappear:(NavigatorRouteSettings *)routeSettings { }
- (void)didDisappear:(NavigatorRouteSettings *)routeSettings { }
@end
複製程式碼
thrio的頁面路由觀察者
原生端可以觀察所有頁面的路由行為,dart 端只能觀察 dart 頁面的路由行為
- dart 端獲取頁面的路由行為
class Module with ThrioModule, NavigatorRouteObserver {
@override
void onModuleRegister() {
registerRouteObserver(this);
}
@override
void didPop(
RouteSettings routeSettings,
RouteSettings previousRouteSettings,
) {}
@override
void didPopTo(
RouteSettings routeSettings,
RouteSettings previousRouteSettings,
) {}
@override
void didPush(
RouteSettings routeSettings,
RouteSettings previousRouteSettings,
) {}
@override
void didRemove(
RouteSettings routeSettings,
RouteSettings previousRouteSettings,
) {}
}
複製程式碼
- iOS 端獲取頁面的路由行為
@interface Module2 : ThrioModule<NavigatorRouteObserverProtocol>
@end
@implementation Module2
- (void)onPageRegister {
[self registerRouteObserver:self];
}
- (void)didPop:(NavigatorRouteSettings *)routeSettings
previousRoute:(NavigatorRouteSettings * _Nullable)previousRouteSettings {
}
- (void)didPopTo:(NavigatorRouteSettings *)routeSettings
previousRoute:(NavigatorRouteSettings * _Nullable)previousRouteSettings {
}
- (void)didPush:(NavigatorRouteSettings *)routeSettings
previousRoute:(NavigatorRouteSettings * _Nullable)previousRouteSettings {
}
- (void)didRemove:(NavigatorRouteSettings *)routeSettings
previousRoute:(NavigatorRouteSettings * _Nullable)previousRouteSettings {
}
@end
複製程式碼
thrio的額外功能
iOS 顯隱當前頁面的導航欄
原生的導航欄在 dart 上一般情況下是不需要的,但切換到原生頁面又需要把原生的導航欄置回來,thrio 不提供的話,使用者較難擴充套件,我之前在目前一個主流的Flutter接入庫上進行此項功能的擴充套件,很不流暢,所以這個功能最好的效果還是 thrio 直接內建,切換到 dart 頁面預設會隱藏原生的導航欄,切回原生頁面也會自動恢復。另外也可以手動隱藏原生頁面的導航欄。
viewController.thrio_hidesNavigationBar = NO;
複製程式碼
支援頁面關閉前彈窗確認的功能
如果使用者正在填寫一個表單,你可能經常會需要彈窗確認是否關閉當前頁面的功能。
在 dart 中,有一個 Widget
提供了該功能,thrio 完好的保留了這個功能。
WillPopScope(
onWillPop: () async => true,
child: Container(),
);
複製程式碼
在 iOS 中,thrio 提供了類似的功能,返回 NO
表示不會關閉,一旦設定會將側滑返回手勢禁用
viewController.thrio_willPopBlock = ^(ThrioBoolCallback _Nonnull result) {
result(NO);
};
複製程式碼
關於 FlutterViewController
的側滑返回手勢,Flutter 預設支援的是純Flutter應用,僅支援單一的 FlutterViewController
作為整個App的容器,內部已經將 FlutterViewController
的側滑返回手勢去掉。但 thrio 要解決的是 Flutter 與原生應用的無縫整合,所以必須將側滑返回的手勢加回來。
thrio的設計解析
目前開源 Flutter 嵌入原生的庫,主要的還是通過切換 FlutterEngine 上的原生容器來實現的,這是 Flutter 原本提供的原生容器之上最小改動而實現,需要小心處理好容器切換的時序,否則在頁面導航時會產生崩潰。基於 Flutter 提供的這個功能, thrio 構建了三端一致的頁面管理API。
dart 的核心類
dart 端只管理 dart頁面
- 基於
RouteSettings
進行擴充套件,複用現有的欄位
- name = url.index
- isInitialRoute = !isNested
- arguments = params
- 基於
MaterialPageRoute
擴充套件的NavigatorPageRoute
- 主要提供頁面描述和轉場動畫的是否配置的功能
- 基於
Navigator
擴充套件,封裝NavigatorWidget
,提供以下方法
Future<bool> push(RouteSettings settings, {
bool animated = true,
NavigatorParamsCallback poppedResult,
});
Future<bool> pop(RouteSettings settings, {bool animated = true});
Future<bool> popTo(RouteSettings settings, {bool animated = true});
Future<bool> remove(RouteSettings settings, {bool animated = false});
複製程式碼
- 封裝
ThrioNavigator
路由API
abstract class ThrioNavigator {
/// Push the page onto the navigation stack.
///
/// If a native page builder exists for the `url`, open the native page,
/// otherwise open the flutter page.
///
static Future<int> push({
@required String url,
params,
bool animated = true,
NavigatorParamsCallback poppedResult,
});
/// Send a notification to the page.
///
/// Notifications will be triggered when the page enters the foreground.
/// Notifications with the same `name` will be overwritten.
///
static Future<bool> notify({
@required String url,
int index,
@required String name,
params,
});
/// Pop a page from the navigation stack.
///
static Future<bool> pop({params, bool animated = true})
static Future<bool> popTo({
@required String url,
int index,
bool animated = true,
});
/// Remove the page with `url` in the navigation stack.
///
static Future<bool> remove({
@required String url,
int index,
bool animated = true,
});
}
複製程式碼
iOS 的核心類
NavigatorRouteSettings
對應於 dart 的RouteSettings
類,並提供相同資料結構
@interface NavigatorRouteSettings : NSObject
@property (nonatomic, copy, readonly) NSString *url;
@property (nonatomic, strong, readonly) NSNumber *index;
@property (nonatomic, assign, readonly) BOOL nested;
@property (nonatomic, copy, readonly, nullable) id params;
@end
複製程式碼
NavigatorPageRoute
對應於 dart 的NavigatorPageRoute
類
- 儲存通知、頁面關閉回撥、NavigatorRouteSettings
- route的雙向連結串列
- 基於
UINavigationController
擴充套件,功能類似 dart 的NavigatorWidget
- 提供一些列的路由內部介面
- 並能相容非 thrio 體系內的頁面
- 基於
UIViewController
擴充套件
- 提供
FlutterViewController
容器上的 dart 頁面的管理功能 - 提供 popDisable 等功能
- 封裝
ThrioNavigator
路由API
@interface ThrioNavigator : NSObject
/// Push the page onto the navigation stack.
///
/// If a native page builder exists for the url, open the native page,
/// otherwise open the flutter page.
///
+ (void)pushUrl:(NSString *)url
params:(id)params
animated:(BOOL)animated
result:(ThrioNumberCallback)result
poppedResult:(ThrioIdCallback)poppedResult;
/// Send a notification to the page.
///
/// Notifications will be triggered when the page enters the foreground.
/// Notifications with the same name will be overwritten.
///
+ (void)notifyUrl:(NSString *)url
index:(NSNumber *)index
name:(NSString *)name
params:(id)params
result:(ThrioBoolCallback)result;
/// Pop a page from the navigation stack.
///
+ (void)popParams:(id)params
animated:(BOOL)animated
result:(ThrioBoolCallback)result;
/// Pop the page in the navigation stack until the page with `url`.
///
+ (void)popToUrl:(NSString *)url
index:(NSNumber *)index
animated:(BOOL)animated
result:(ThrioBoolCallback)result;
/// Remove the page with `url` in the navigation stack.
///
+ (void)removeUrl:(NSString *)url
index:(NSNumber *)index
animated:(BOOL)animated
result:(ThrioBoolCallback)result;
@end
複製程式碼
dart 與 iOS 路由棧的結構
- 一個應用允許啟動多個Flutter引擎,可讓每個引擎執行的程式碼物理隔離,按需啟用,劣勢是啟動多個Flutter引擎可能導致資源消耗過多而引起問題;
- 一個Flutter引擎通過切換可以匹配到多個FlutterViewController,這是Flutter優雅嵌入原生應用的前提條件
- 一個FlutterViewController可以內嵌多個Dart頁面,有效減少單個FlutterViewController只開啟一個Dart頁面導致的記憶體消耗過多問題,關於記憶體消耗的問題,後續會有提到。
dart 與 iOS push的時序圖
- 所有路由操作最終匯聚於原生端開始,如果始於 dart 端,則通過 channel 呼叫原生端的API
- 通過
url+index
定位到頁面 - 如果頁面是原生頁面,則直接進行相關操作
- 如果頁面是 Flutter 容器,則通過 channel 呼叫 dart 端對應的路由 API
- 接4步,如果 dart 端對應的路由 API 操作完成後回撥,如果成功,則執行原生端的路由棧同步,如果失敗,則回撥入口 API 的result
- 接4不,如果 dart 端對應的路由 API操作成功,則通過 route channel 呼叫原生端對應的 route observer,通過 page channel 呼叫原生端對應的 page observer。
dart 與 iOS pop的時序圖
- pop 的流程與 push 基本一致;
- pop 需要考慮頁面是否可關閉的問題;
- 但在 iOS 中,側滑返回手勢會導致問題,
popViewControllerAnimated:
會在手勢開始的時候呼叫,導致 dart 端的頁面已經被 pop 掉,但如果手勢被放棄了,則導致兩端的頁面棧不一致,thrio 已經解決了這個問題,具體流程稍複雜,原始碼可能更好的說明。
dart 與 iOS popTo的時序圖
- popTo 的流程與 push 基本一致;
- 但在多引擎模式下,popTo需要處理多引擎的路由棧同步的問題;
- 另外在 Dart 端,popTo實際上是多個pop或者remove構成的,最終產生多次的didPop或didRemove行為,需要將多個pop或remove組合起來形成一個didPopTo行為。
dart 與 iOS remove的時序圖
- remove 的流程與 push 基本一致。
總結
目前 Flutter 接入原生應用主流的解決方案應該是boost,筆者的團隊在專案深度使用過 boost,也積累了很多對 boost 改善的需求,遇到的最大問題是記憶體問題,每開啟一個 Flutter 頁面的記憶體開銷基本到了很難接受的程度,thrio把解決記憶體問題作為頭等任務,最終效果還是不錯的,比如以連續開啟 5 個 Flutter 頁面計算,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 |
當然,thrio 跟 boost 的定位還是不太一樣的,thrio 更多的偏向於解決我們業務上的需求,儘量做到開箱即用。