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

image

為什么寫thrio?

在早期Flutter發(fā)布的時候,谷歌雖然提供了iOS和Android App上的Flutter嵌入方案,但主要針對的是純Flutter的情形,混合開發(fā)支持的并不友好。

所謂的純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. 連續(xù)打開頁面
  • dart端只需要await push,就可以連續(xù)打開頁面
  • 原生端需要等待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的頁面通知

頁面通知一般來說并不在路由的范疇之內,但我們在實際開發(fā)中卻經常需要使用到,由此產生的各種模塊化框架一個比一個復雜。

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

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

發(fā)送頁面通知

  1. dart 端給特定頁面發(fā)通知
ThrioNavigator.notify(url: 'flutter1', name: 'reload');
  1. iOS 端給特定頁面發(fā)通知
[ThrioNavigator notifyUrl:@"flutter1" name:@"reload"];
  1. Android 端給特定頁面發(fā)通知
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優(yōu)雅嵌入原生應用的前提條件
  3. 一個FlutterViewController可以內嵌多個Dart頁面,有效減少單個FlutterViewController只打開一個Dart頁面導致的內存消耗過多問題,關于內存消耗的問題,后續(xù)會有提到。

dart 與 iOS push的時序圖

thrio-push
  1. 所有路由操作最終匯聚于原生端開始,如果始于 dart 端,則通過 channel 調用原生端的API
  2. 通過 url+index 定位到頁面
  3. 如果頁面是原生頁面,則直接進行相關操作
  4. 如果頁面是 Flutter 容器,則通過 channel 調用 dart 端對應的路由 API
  5. 接4步,如果 dart 端對應的路由 API 操作完成后回調,如果成功,則執(zhí)行原生端的路由棧同步,如果失敗,則回調入口 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把解決內存問題作為頭等任務,最終效果還是不錯的,比如以連續(xù)打開 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

同樣連續(xù)打開 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 更多的偏向于解決我們業(yè)務上的需求,盡量做到開箱即用。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

  • 親愛的 你可曾知道時光飛逝 你可曾知你之前所謂的未來 都已經變成了過去 你可曾想起你兒時夢想 你可曾記得最初的美好...
    秋艷洞見閱讀 202評論 0 0
  • 這是棗莊方糖讀書會第35場復盤。 2019年第二場。 這一場我們共讀《應用題的關鍵難點》 尋了一處好地方,...
    歡喜欣然閱讀 612評論 0 3
  • 西湖上的搖櫓人,保溫杯里一泡上好的獅峰龍井,悠然地搖著。 天南海北的人,云淡風輕地訴說,一個時辰,伴著夕照雷鋒,或...
    路燈下的雪人閱讀 390評論 0 3

友情鏈接更多精彩內容