如何無縫的將Flutter引入現有應用?

稻子_Aadan發表於2020-05-06

為什麼寫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中 routename 就是由 '$url.$index' 組合而成。

很多時候,使用者不需要關注 index,只有當需要定位到多開的 url 的頁面中的某一個時才需要關注 index。最簡單獲取 index 的方式為 push 方法的回撥返回值。

頁面的push

  1. 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'),
);
複製程式碼
  1. iOS 端開啟頁面
[ThrioNavigator pushUrl:@"flutter1"];
// 接收所開啟頁面的關閉回撥
[ThrioNavigator pushUrl:@"biz2/flutter2" poppedResult:^(id _Nonnull params) {
    ThrioLogV(@"biz2/flutter2 popped: %@", params);
}];
複製程式碼
  1. Android 端開啟頁面
ThrioNavigator.push(this, "biz1/flutter1",
        mapOf("k1" to 1),
        false,
        poppedResult = {
            Log.e("Thrio", "native1 popResult call params $it")
        }
)
複製程式碼
  1. 連續開啟頁面
  • dart端只需要await push,就可以連續開啟頁面
  • 原生端需要等待push的result回撥返回才能開啟第二個頁面
  1. 獲取所開啟頁面關閉後的回撥引數
  • 三端都可以通過閉包 poppedResult 來獲取

頁面的pop

  1. dart 端關閉頂層頁面
// 預設動畫開啟
ThrioNavigator.pop();
// 不開啟動畫,原生和dart頁面都生效
ThrioNavigator.pop(animated: false);
// 關閉當前頁面,並傳遞引數給push這個頁面的回撥
ThrioNavigator.pop(params: 'popped flutter1'),
複製程式碼
  1. iOS 端關閉頂層頁面
// 預設動畫開啟
[ThrioNavigator pop];
// 關閉動畫
[ThrioNavigator popAnimated:NO];
// 關閉當前頁面,並傳遞引數給push這個頁面的回撥
[ThrioNavigator popParams:@{@"k1": @3}];
複製程式碼
  1. Android 端關閉頂層頁面
ThrioNavigator.pop(this, params, animated)
複製程式碼

頁面的popTo

  1. dart 端關閉到頁面
// 預設動畫開啟
ThrioNavigator.popTo(url: 'flutter1');
// 不開啟動畫,原生和dart頁面都生效
ThrioNavigator.popTo(url: 'flutter1', animated: false);
複製程式碼
  1. iOS 端關閉到頁面
// 預設動畫開啟
[ThrioNavigator popToUrl:@"flutter1"];
// 關閉動畫
[ThrioNavigator popToUrl:@"flutter1" animated:NO];
複製程式碼
  1. Android 端關閉到頁面
ThrioNavigator.popTo(context, url, index)
複製程式碼

頁面的remove

  1. dart 端關閉特定頁面
ThrioNavigator.remove(url: 'flutter1');
// 只有當頁面是頂層頁面時,animated引數才會生效
ThrioNavigator.remove(url: 'flutter1', animated: true);
複製程式碼
  1. iOS 端關閉特定頁面
[ThrioNavigator removeUrl:@"flutter1"];
// 只有當頁面是頂層頁面時,animated引數才會生效
[ThrioNavigator removeUrl:@"flutter1" animated:NO];
複製程式碼
  1. Android 端關閉特定頁面
ThrioNavigator.remove(context, url, index)
複製程式碼

thrio的頁面通知

頁面通知一般來說並不在路由的範疇之內,但我們在實際開發中卻經常需要使用到,由此產生的各種模組化框架一個比一個複雜。

那麼問題來了,這些模組化框架很難在三端互通,所有的這些模組化框架提供的能力無非最終是一個頁面通知的能力,而且頁面通知我們可以非常簡單的在三端打通。

鑑於此,頁面通知作為thrio的一個必備能力被引入了thrio。

傳送頁面通知

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

接收頁面通知

  1. dart 端接收頁面通知

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

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

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

- (void)onNotify:(NSString *)name params:(NSDictionary *)params {
  ThrioLogV(@"native1 onNotify: %@, %@", name, params);
}
複製程式碼
  1. 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 端只能獲取自身頁面的生命週期

  1. 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) {}
}
複製程式碼
  1. 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 頁面的路由行為

  1. 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,
  ) {}
}
複製程式碼
  1. 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頁面

  1. 基於 RouteSettings 進行擴充套件,複用現有的欄位
  • name = url.index
  • isInitialRoute = !isNested
  • arguments = params
  1. 基於 MaterialPageRoute 擴充套件的 NavigatorPageRoute
  • 主要提供頁面描述和轉場動畫的是否配置的功能
  1. 基於 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});
  
複製程式碼
  1. 封裝 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 的核心類

  1. 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

複製程式碼
  1. NavigatorPageRoute 對應於 dart 的 NavigatorPageRoute
  • 儲存通知、頁面關閉回撥、NavigatorRouteSettings
  • route的雙向連結串列
  1. 基於 UINavigationController 擴充套件,功能類似 dart 的 NavigatorWidget
  • 提供一些列的路由內部介面
  • 並能相容非 thrio 體系內的頁面
  1. 基於 UIViewController 擴充套件
  • 提供 FlutterViewController 容器上的 dart 頁面的管理功能
  • 提供 popDisable 等功能
  1. 封裝 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 路由棧的結構

thrio-architecture

  1. 一個應用允許啟動多個Flutter引擎,可讓每個引擎執行的程式碼物理隔離,按需啟用,劣勢是啟動多個Flutter引擎可能導致資源消耗過多而引起問題;
  2. 一個Flutter引擎通過切換可以匹配到多個FlutterViewController,這是Flutter優雅嵌入原生應用的前提條件
  3. 一個FlutterViewController可以內嵌多個Dart頁面,有效減少單個FlutterViewController只開啟一個Dart頁面導致的記憶體消耗過多問題,關於記憶體消耗的問題,後續會有提到。

dart 與 iOS push的時序圖

thrio-push

  1. 所有路由操作最終匯聚於原生端開始,如果始於 dart 端,則通過 channel 呼叫原生端的API
  2. 通過 url+index 定位到頁面
  3. 如果頁面是原生頁面,則直接進行相關操作
  4. 如果頁面是 Flutter 容器,則通過 channel 呼叫 dart 端對應的路由 API
  5. 接4步,如果 dart 端對應的路由 API 操作完成後回撥,如果成功,則執行原生端的路由棧同步,如果失敗,則回撥入口 API 的result
  6. 接4不,如果 dart 端對應的路由 API操作成功,則通過 route channel 呼叫原生端對應的 route observer,通過 page channel 呼叫原生端對應的 page observer。

dart 與 iOS pop的時序圖

thrio-pop

  1. pop 的流程與 push 基本一致;
  2. pop 需要考慮頁面是否可關閉的問題;
  3. 但在 iOS 中,側滑返回手勢會導致問題, popViewControllerAnimated: 會在手勢開始的時候呼叫,導致 dart 端的頁面已經被 pop 掉,但如果手勢被放棄了,則導致兩端的頁面棧不一致,thrio 已經解決了這個問題,具體流程稍複雜,原始碼可能更好的說明。

dart 與 iOS popTo的時序圖

thrio-popTo.png

  1. popTo 的流程與 push 基本一致;
  2. 但在多引擎模式下,popTo需要處理多引擎的路由棧同步的問題;
  3. 另外在 Dart 端,popTo實際上是多個pop或者remove構成的,最終產生多次的didPop或didRemove行為,需要將多個pop或remove組合起來形成一個didPopTo行為。

dart 與 iOS remove的時序圖

thrio-remove.png

  1. 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 更多的偏向於解決我們業務上的需求,儘量做到開箱即用。

相關文章