Airing

Airing

哲学系学生 / 小学教师 / 程序员,个人网站: ursb.me
github
email
zhihu
medium
tg_channel
twitter_id

Flutter 混合開発フレームワークモードの探索

Google の公式提供の Flutter 混合開発ソリューションはあまりにもシンプルで、Flutter View を開く機能のみをサポートし、ルーティング間のパラメータの受け渡し、統一されたライフサイクル、ルーティングスタックの管理など、ビジネス開発に必要な機能をサポートしていないため、Flutter 混合開発モードを生産環境に投入するためには、Flutter Boost、Thrio、QFlutter などのサードパーティの混合開発フレームワークの統合能力を借りる必要があります。本記事では、このような混合開発フレームワークの機能、アーキテクチャ、ソースコードについて研究します。

1. コア機能とフレームワークの目標#

15867692757044.jpg

適切な混合開発フレームワークは、少なくとも以下の機能をサポートする必要があります:

  1. 混合ルーティングスタックの管理:任意の Flutter または Native ページを開くことをサポート。
  2. 完全な通知メカニズム:統一されたライフサイクル、ルーティング関連のイベント通知メカニズム。

これらの目標について、iOS を例にとって、Flutter 混合開発モードの最良の実装を段階的に掘り下げていきます。

注:本文は長さの問題から Android の実装を探求せず、iOS から切り込むのは問題分析の一つの視点であり、Android と iOS の実装原理は一致しており、具体的な実装は大同小異です。また、Channel 通信層のコード実装は比較的複雑なため、本記事では Channel 通信層の説明は使用レベルにとどめ、具体的な実装は読者が自分で研究できます。
注:本文の Flutter Boost のバージョンは 1.12.13、Thrio のバージョンは 0.1.0 です。

2. FlutterViewController から始める#

混合開発では、Flutter をプラグイン化された開発として使用し、FlutterViewController を起動する必要があります。これは UIViewController の実装で、FlutterEngine に依存し、UIKit の入力イベントを Flutter に渡し、FlutterEngine によってレンダリングされた各フレームの Flutter ビューを表示します。この FlutterEngine は Dart VM と Flutter ランタイムの環境を担っています。

注意が必要なのは、1 つの Flutter Engine は同時に最大 1 つの FlutterViewController しか実行できないことです。

FlutterEngine: FlutterEngine クラスは、FlutterDartProject の単一の実行インスタンスを調整します。同時に 0 または 1 の FlutterViewController を持つことができます。

エンジンの起動:

self.flutterEngine = [[FlutterEngine alloc] initWithName:@"my flutter engine"];
[self.flutterEngine run];

FlutterViewController を作成して表示する

FlutterViewController *flutterViewController =
        [[FlutterViewController alloc] initWithEngine:flutterEngine nibName:nil bundle:nil];
[self presentViewController:flutterViewController animated:YES completion:nil];

FlutterViewController を作成する際には、すでに実行中の FlutterEngine を使用して初期化することも、作成時に同時に暗黙的に FlutterEngine を起動することもできます(ただし、これは推奨されません。必要に応じて FlutterEngine を作成する場合、FlutterViewController が表示された後、最初のフレームの画像がレンダリングされる前に明らかな遅延が発生します)。ここでは前者の方法で作成します。

FlutterEngine: https://api.flutter.dev/objcdoc/Classes/FlutterEngine.html
FlutterViewController: https://api.flutter.dev/objcdoc/Classes/FlutterViewController.html

これで、公式提供のソリューションを使用して Native プロジェクト内で Flutter Engine を起動し、FlutterViewController を介して Flutter ページを表示することができました。

Flutter ページ内では、Navigator.pushを使用して別の Flutter ページ(Route)を開くことができます:

image.png

したがって、このようなルーティングスタックは非常に簡単に実現できます:

image.png

つまり、全体の Flutter は単一の FlutterViewController コンテナ内で実行され、Flutter 内部のすべてのページはこのコンテナ内で管理されます。しかし、Native と Flutter の混合遷移のような混合ルーティングスタックを実現するには、どうすればよいのでしょうか?

15867693180281.jpg

最も基本的な解決策は、この FlutterViewController と NativeViewController を混合し、FlutterViewController を iOS のルーティングスタック内で自由に移動させることです。以下の図のように:

image.png

このソリューションは比較的複雑で、上記の混合スタックのシナリオに戻ると、各 Flutter ページと Native コンテナの位置を正確に記録する必要があります。自分が pop した後、前の Flutter ページに戻るべきか、別の NativeViewController に切り替えるべきかを知る必要があり、ページインデックスを適切に管理し、ネイティブの pop イベントとNavigator.popイベントを統一する必要があります。

業界のいくつかの解決策を見てみましょう!

3. Flutter Boost#

混合スタックの問題に対して、Flutter Boost は各 Flutter ページを FlutterViewController でラップし、複数のインスタンスとして扱います。使用感は Webview のようです:

image.png

Flutter Boost のソースコードは、別の記事で整理されています《Flutter Boost 混合開発実践とソースコード解析(Android を例に)》。その記事では、Android 側のページを開くプロセスのソースコードを整理しました。本記事では、ソースコードを重複して紹介せず、iOS 側に重点を置いて Flutter Boost がどのように実現されているかを整理します。

3.1 Native からページを開く#

このセクションでは、Flutter Boost が Native からページを開く方法を分析します。つまり、以下の 2 つのケースを含みます:

  1. Native -> Flutter
  2. Native -> Native

プロジェクト内で、Flutter Boost を統合するために以下のコードを接続する必要があります:

// AppDelegate.m

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    PlatformRouterImp *router = [PlatformRouterImp new];
    [FlutterBoostPlugin.sharedInstance startFlutterWithPlatform:router
                                                        onStart:^(FlutterEngine *engine) {
                                                        }];
    self.window = [[UIWindow alloc] initWithFrame: [UIScreen mainScreen].bounds];

    [self.window makeKeyAndVisible];
   
    UINavigationController *rvc = [[UINavigationController alloc] initWithRootViewController:tabVC];

    router.navigationController = rvc;
    self.window.rootViewController = rvc;

    return YES;
}


// PlatformRouterImp.m

- (void)openNativeVC:(NSString *)name
           urlParams:(NSDictionary *)params
                exts:(NSDictionary *)exts{
    UIViewController *vc = UIViewControllerDemo.new;
    BOOL animated = [exts[@"animated"] boolValue];
    if([params[@"present"] boolValue]){
        [self.navigationController presentViewController:vc animated:animated completion:^{
        }];
    }else{
        [self.navigationController pushViewController:vc animated:animated];
    }
}

- (void)open:(NSString *)name
   urlParams:(NSDictionary *)params
        exts:(NSDictionary *)exts
  completion:(void (^)(BOOL))completion
{
    if ([name isEqualToString:@"native"]) { // ネイティブページを開くシミュレーション
        [self openNativeVC:name urlParams:params exts:exts];
        return;
    }
    
    BOOL animated = [exts[@"animated"] boolValue];
    FLBFlutterViewContainer *vc = FLBFlutterViewContainer.new;
    [vc setName:name params:params];
    [self.navigationController pushViewController:vc animated:animated];
    if(completion) completion(YES);
}

プロジェクト内でエンジンを最初に起動する必要があります。対応する Flutter Boost のソースコードは以下の通りです:

// FlutterBoostPlugin.m
- (void)startFlutterWithPlatform:(id<FLBPlatform>)platform
                          engine:(FlutterEngine *)engine
           pluginRegisterred:(BOOL)registerPlugin
                         onStart:(void (^)(FlutterEngine * _Nonnull))callback{
    static dispatch_once_t onceToken;
    __weak __typeof__(self) weakSelf = self;
    dispatch_once(&onceToken, ^{
        __strong __typeof__(weakSelf) self = weakSelf;
        FLBFactory *factory = FLBFactory.new;
        self.application = [factory createApplication:platform];
        [self.application startFlutterWithPlatform:platform
                                     withEngine:engine
                                      withPluginRegisterred:registerPlugin
                                       onStart:callback];
    });
}


// FLBFlutterApplication.m

- (void)startFlutterWithPlatform:(id<FLBPlatform>)platform
                      withEngine:(FlutterEngine* _Nullable)engine
                        withPluginRegisterred:(BOOL)registerPlugin
                         onStart:(void (^)(FlutterEngine *engine))callback
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        self.platform = platform;
        self.viewProvider = [[FLBFlutterEngine alloc] initWithPlatform:platform engine:engine];
        self.isRunning = YES;
        if(registerPlugin){
            Class clazz = NSClassFromString(@"GeneratedPluginRegistrant");
            FlutterEngine *myengine = [self.viewProvider engine];
            if (clazz && myengine) {
                if ([clazz respondsToSelector:NSSelectorFromString(@"registerWithRegistry:")]) {
                    [clazz performSelector:NSSelectorFromString(@"registerWithRegistry:")
                                withObject:myengine];
                }
            }
        }
        if(callback) callback(self.viewProvider.engine);
    });
}

エンジンを起動する際、startFlutterWithPlatform にはルーティング管理クラスを渡す必要があります。FLBPlatform.h では、open インターフェースがビジネス側の Native から渡される実装であることがわかります。

// PlatformRouterImp.m

- (void)open:(NSString *)name
   urlParams:(NSDictionary *)params
        exts:(NSDictionary *)exts
  completion:(void (^)(BOOL))completion
{
    if ([name isEqualToString:@"native"]) { // ネイティブページを開くシミュレーション
        [self openNativeVC:name urlParams:params exts:exts];
        return;
    }
    
    BOOL animated = [exts[@"animated"] boolValue];
    FLBFlutterViewContainer *vc = FLBFlutterViewContainer.new;
    [vc setName:name params:params];
    [self.navigationController pushViewController:vc animated:animated];
    if(completion) completion(YES);
}

その後、ビジネス側の AppDelegate.m で UINavigationController を初期化し、ルーティング管理クラスで open メソッドを実装します。つまり、この navigationContainer 内で FLBFlutterViewContainer コンテナを push します。その親クラスは実際には第一章で述べた FlutterViewController です。コアプロセスのコードは以下の通りです:


// FLBFlutterViewContainer.m

- (instancetype)init
{
    [FLUTTER_APP.flutterProvider prepareEngineIfNeeded];
    if(self = [super initWithEngine:FLUTTER_APP.flutterProvider.engine
                            nibName:_flbNibName
                            bundle:_flbNibBundle]){
        self.modalPresentationStyle = UIModalPresentationFullScreen;

        [self _setup];
    }
    return self;
}

そうです、ここで呼び出されるのは、第一章で述べた FlutterViewController を initWithEngine を介して作成することです。

Native ページはどのように Flutter ページを開くのでしょうか?ビジネス側は FlutterBoostPlugin の open メソッドを呼び出します:

- (IBAction)pushFlutterPage:(id)sender {
    [FlutterBoostPlugin open:@"first" urlParams:@{kPageCallBackId:@"MycallbackId#1"} exts:@{@"animated":@(YES)} onPageFinished:^(NSDictionary *result) {
        NSLog(@"ページが終了したときに呼び出され、結果は:%@", result);
    } completion:^(BOOL f) {
        NSLog(@"ページが開かれました");
    }];
}

Flutter Boost の対応する処理は以下の通りです:


// FlutterBoostPlugin.m
+ (void)open:(NSString *)url urlParams:(NSDictionary *)urlParams exts:(NSDictionary *)exts onPageFinished:(void (^)(NSDictionary *))resultCallback completion:(void (^)(BOOL))completion{
    id<FLBFlutterApplicationInterface> app = [[FlutterBoostPlugin sharedInstance] application];
    [app open:url urlParams:urlParams exts:exts onPageFinished:resultCallback completion:completion];
}

// FLBFlutterApplication.m
- (void)open:(NSString *)url
   urlParams:(NSDictionary *)urlParams
        exts:(NSDictionary *)exts
       onPageFinished:(void (^)(NSDictionary *))resultCallback
  completion:(void (^)(BOOL))completion
{
    NSString *cid = urlParams[kPageCallBackId];
   
    if(!cid){
        static int64_t sCallbackID = 1;
        cid = @(sCallbackID).stringValue;
        sCallbackID += 2;
        NSMutableDictionary *newParams = [[NSMutableDictionary alloc]initWithDictionary:urlParams];
        [newParams setObject:cid?cid:@"__default#0__" forKey:kPageCallBackId];
        urlParams = newParams;
    }
    _previousViewController = [self flutterViewController];
    _callbackCache[cid] = resultCallback;
    if([urlParams[@"present"]respondsToSelector:@selector(boolValue)] && [urlParams[@"present"] boolValue] && [self.platform respondsToSelector:@selector(present:urlParams:exts:completion:)]){
        [self.platform present:url
                  urlParams:urlParams
                       exts:exts
                 completion:completion];
    }else{
        [self.platform open:url
                  urlParams:urlParams
                       exts:exts
                 completion:completion];
    }
}

Platform の open はビジネス側から渡されたルーティングクラスで実装され、最終的には本章の冒頭で分析した部分に戻ります。前文で分析したように、後文にもまとめがありますので、ここでは繰り返しません。

要約すると、Native が Native を開く場合、登録された Native ルーティングをルーティング管理クラスでインターセプトし、viewController をインスタンス化した後に push します。
Native が Flutter を開く場合、FLBFlutterViewContainer をインスタンス化して push しますが、FLBFlutterViewContainer は本質的に FlutterViewController です。

3.2 Flutter からページを開く#

このセクションでは、Flutter Boost が Flutter からページを開く方法を分析します。つまり、以下の 2 つのケースを含みます:

  1. Flutter -> Flutter
  2. Flutter -> Native

Dart ビジネス側は直接 open メソッドを呼び出して Native または Flutter ページを開きます:

FlutterBoost.singleton.open("native").then((Map value) {
    print("ページが終了したときに呼び出されました。ネイティブルートの結果を受け取りました$value");
});

FlutterBoost.singleton.open("flutterPage").then((Map value) {
    print("ページが終了したときに呼び出されました。ネイティブルートの結果を受け取りました$value");
});

open のソースコードは以下の通りで、コアは MethodChannel を使用して Native 側に openPage メッセージを送信することです:


// flutter_boost.dart
Future<Map<dynamic, dynamic>> open(String url,
  {Map<dynamic, dynamic> urlParams, Map<dynamic, dynamic> exts}) {
    Map<dynamic, dynamic> properties = new Map<dynamic, dynamic>();
    properties["url"] = url;
    properties["urlParams"] = urlParams;
    properties["exts"] = exts;
    return channel.invokeMethod<Map<dynamic, dynamic>>('openPage', properties);
}

// boost_channel.dart
final MethodChannel _methodChannel = MethodChannel("flutter_boost");

Future<T> invokeMethod<T>(String method, [dynamic arguments]) async {
    assert(method != "__event__");
    
    return _methodChannel.invokeMethod<T>(method, arguments);
}

iOS 側は Dart 側からのメッセージをリスンし、openPage に対して処理を行います。コアは FLBFlutterApplication 内の open メソッドを呼び出すことです:


- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
    if([@"openPage" isEqualToString:call.method]){
        NSDictionary *args = [FLBCollectionHelper deepCopyNSDictionary:call.arguments
        filter:^bool(id  _Nonnull value) { 
            return ![value isKindOfClass:NSNull.class];
        }];
        NSString *url = args[@"url"];
        NSDictionary *urlParams = args[@"urlParams"];
        NSDictionary *exts = args[@"exts"];
        NSNull2Nil(url);
        NSNull2Nil(urlParams);
        NSNull2Nil(exts);
        [[FlutterBoostPlugin sharedInstance].application open:url
                                                    urlParams:urlParams
                                                         exts:exts
                                                        onPageFinished:result
                                                   completion:^(BOOL r) {}];
    }
}

// FLBFlutterApplication.m

- (FlutterViewController *)flutterViewController
{
    return self.flutterProvider.engine.viewController;
}

- (void)open:(NSString *)url
   urlParams:(NSDictionary *)urlParams
        exts:(NSDictionary *)exts
       onPageFinished:(void (^)(NSDictionary *))resultCallback
  completion:(void (^)(BOOL))completion
{
    NSString *cid = urlParams[kPageCallBackId];
   
    if(!cid){
        static int64_t sCallbackID = 1;
        cid = @(sCallbackID).stringValue;
        sCallbackID += 2;
        NSMutableDictionary *newParams = [[NSMutableDictionary alloc]initWithDictionary:urlParams];
        [newParams setObject:cid?cid:@"__default#0__" forKey:kPageCallBackId];
        urlParams = newParams;
    }
    _previousViewController = [self flutterViewController];
    _callbackCache[cid] = resultCallback;
    if([urlParams[@"present"]respondsToSelector:@selector(boolValue)] && [urlParams[@"present"] boolValue] && [self.platform respondsToSelector:@selector(present:urlParams:exts:completion:)]){
        [self.platform present:url
                  urlParams:urlParams
                       exts:exts
                 completion:completion];
    }else{
        [self.platform open:url
                  urlParams:urlParams
                       exts:exts
                 completion:completion];
    }
}

前文で分析したように、openPage をリスンすると、Flutter Boost の open メソッドが呼び出され、最終的には Native ビジネス側から送られたルーティング管理クラスの open メソッドに渡されます。つまり、Flutter からページを開く場合も最終的には Native が push を担当します。

要約すると、Flutter が Flutter または Native ページを開く場合、iOS 側に openPage メッセージを送信する必要があります。iOS 側がメッセージを受信すると、Flutter Boost の open メソッドを実行し、その実装はビジネス側のルーティング管理クラスの open メソッドであり、最終的にはビジネス側のルーティングが実装を担当します。

  1. Flutter が Flutter を開く:iOS 側がメッセージを受信すると open を実行します。つまり、FLBFlutterViewContainer をインスタンス化して push しますが、FLBFlutterViewContainer は本質的に FlutterViewController です。Native が Flutter を開くのと同じです。
  2. Flutter が Native を開く:iOS 側がメッセージを受信すると open を実行します。登録された Native ルーティングをルーティング管理クラスでインターセプトし、viewController をインスタンス化した後に push します。Native が Native を開くのと同じです。

3.3 Flutter コンテナの切り替え#

前文で述べたように、ルーティング管理は統一して Native 側に実装させ、各ページを push するたびにコンテナを push します。統一された管理の利点は、Native ビジネス側が制御し、直接的でシンプルに使用できることです。各ページを push するたびにコンテナを push する利点は、直感的でシンプルであることです。

しかし、以前に述べたように、Flutter Engine は同時に最大 1 つの FlutterViewController しかマウントできないため、Flutter ページを開くたびに vc が生成されることは問題になるのでしょうか?Flutter Boost がどのように処理しているか見てみましょう:


// FLBFlutterViewContainer.m
- (void)viewWillAppear:(BOOL)animated
{
    //新しいページの場合、表示される前にflutterビューをアタッチする必要があります
    [self attatchFlutterEngine];
    [BoostMessageChannel willShowPageContainer:^(NSNumber *result) {}
                                            pageName:_name
                                              params:_params
                                            uniqueId:self.uniqueIDString];
    //最初のページ情報を保存します。
    [FlutterBoostPlugin sharedInstance].fPagename = _name;
    [FlutterBoostPlugin sharedInstance].fPageId = self.uniqueIDString;
    
    [super bridge_viewWillAppear:animated];
    [self.view setNeedsLayout];
}

- (void)attatchFlutterEngine
{
    [FLUTTER_APP.flutterProvider atacheToViewController:self];
}

コンテナが表示されるときにattatchFlutterEngineメソッドが呼び出され、エンジンの viewController を切り替えます。つまり、Flutter ページを開くたびに、新しく生成された FlutterViewController コンテナがエンジンにマウントされます。そうです、Flutter Boost はエンジンの viewController を切り替えることで Flutter コンテナとページを表示しています。

// FLBFlutterEngine.m
- (BOOL)atacheToViewController:(FlutterViewController *)vc
{
    if(_engine.viewController != vc){
        _engine.viewController = vc;
        return YES;
    }
    return NO;
}

3.4 統一されたライフサイクルとルーティングイベント通知#

Flutter Boost は、Native と Dart ページのライフサイクルの不一致をどのように解決しているのでしょうか?

2.4 のFLBFlutterViewController viewWillAppearを例に挙げると、この関数内でwillShowPageContainerが実行されます。その実装は BoostMessageChannel.m 内にあります。

// BoostMessageChannel.m

 + (void)willShowPageContainer:(void (^)(NSNumber *))result pageName:(NSString *)pageName params:(NSDictionary *)params uniqueId:(NSString *)uniqueId
 {
     if ([pageName isEqualToString:kIgnoreMessageWithName]) {
         return;
     }
     
     NSMutableDictionary *tmp = [NSMutableDictionary dictionary];
     if(pageName) tmp[@"pageName"] = pageName;
     if(params) tmp[@"params"] = params;
     if(uniqueId) tmp[@"uniqueId"] = uniqueId;
     [self.methodChannel invokeMethod:@"willShowPageContainer" arguments:tmp result:^(id tTesult) {
         if (result) {
             result(tTesult);
         }
     }];
 }

このメソッドは、methodChannel を介して Dart 側に willShowPageContainer というメッセージを送信するだけです。Dart 側は container_coordinator.dart 内でメッセージを受信します:

Flutter Boost の Dart 側のコードは比較的シンプルで、container_coordinator.dart は、Native 側のコンテナを協調するクラスです。Native からのメッセージをリスンし、container_manager.dart コンテナ管理クラスを使用していくつかの処理を行います。

// container_coordinator.dart

Future<dynamic> _onMethodCall(MethodCall call) {
    Logger.log("onMetohdCall ${call.method}");

    switch (call.method) {
      case "willShowPageContainer":
        {
          String pageName = call.arguments["pageName"];
          Map params = call.arguments["params"];
          String uniqueId = call.arguments["uniqueId"];
          _nativeContainerWillShow(pageName, params, uniqueId);
        }
        break;
    }
}

bool _nativeContainerWillShow(String name, Map params, String pageId) {
    if (FlutterBoost.containerManager?.containsContainer(pageId) != true) {
      FlutterBoost.containerManager
          ?.pushContainer(_createContainerSettings(name, params, pageId));
    }
    // ...省略いくつかの最適化コード
    return true;
}

コアはFlutterBoost.containerManager?.pushContainerを実行することです。これは container_manager.dart コンテナ管理クラスで実装されています:

// container_manager.dart

final List<BoostContainer> _offstage = <BoostContainer>[];
BoostContainer _onstage;
enum ContainerOperation { Push, Onstage, Pop, Remove }

void pushContainer(BoostContainerSettings settings) {
    assert(settings.uniqueId != _onstage.settings.uniqueId);
    assert(_offstage.every((BoostContainer container) =>
        container.settings.uniqueId != settings.uniqueId));

    _offstage.add(_onstage);
    _onstage = BoostContainer.obtain(widget.initNavigator, settings);

    setState(() {});

    for (BoostContainerObserver observer in FlutterBoost
        .singleton.observersHolder
        .observersOf<BoostContainerObserver>()) {
      observer(ContainerOperation.Push, _onstage.settings);
    }
    Logger.log('ContainerObserver#2 didPush');
}

BoostContainer.obtainを実行する過程で、内部でライフサイクルのリスニングが行われます。さらに、observer(ContainerOperation.Push, _onstage.settings);を実行して Push イベントの通知をトリガーします。

実際、FlutterBoost では、フレームワークは合計 3 種類のイベントリスナーを登録しています:

  1. コンテナの変化リスナー:BoostContainerObserver
  2. ライフサイクルリスナー:BoostContainerLifeCycleObserver
  3. Navigator の push と pop リスナー:ContainerNavigatorObserver

これらはすべて混合ルーティングスタックの遷移に伴って関連するイベントをトリガーします。通信層のソースコードについては、本文では詳細に研究しないことにします。興味のある方は自分で見てみてください。

以下の図は、本章の Flutter Boost によるページを開くプロセスの要約です:

image.png

注:このソリューションは、FlutterViewController を頻繁に作成し、特定の FlutterViewController を pop した後、これらのメモリがエンジンによって解放されないため、メモリリークを引き起こします:https://github.com/flutter/flutter/issues/25255。これはエンジンのバグであり、現在も良好な解決策が見つかっていないようです。

4. Thrio#

Thrioは先月(2020.03)に Hellobike がオープンソースしたもう一つの Flutter 混合スタックフレームワークで、このフレームワークが処理するコア問題も依然として第一章で提起した 2 つの点です:

  1. 混合ルーティングスタックの管理:任意の Flutter または Native ページを開くことをサポート
  2. 完全な通知メカニズム:統一されたライフサイクル、ルーティング関連のイベント通知メカニズム。

本文では、Thrio がどのように混合スタック管理を実現しているかを主に見ていきます。通信層のロジックについては、あくまで簡単に説明します。具体的な実装は比較的複雑なため、本文ではそのソースコードの分析は行いません。

まず、時系列図を見て、全体の流れを把握しましょう:

thrio-push.png

4.1 呼び出し#

4.1.1 Native からページを開く#

iOS ビジネス側からopenUrlを呼び出すことで、Native または Flutter ページを開くことができます:

- (IBAction)pushNativePage:(id)sender {
  [ThrioNavigator pushUrl:@"native1"];
}

- (IBAction)pushFlutterPage:(id)sender {
  [ThrioNavigator pushUrl:@"biz1/flutter1"];
}

openUrlは最終的にthrio_pushUrlを呼び出します:

// ThrioNavigator.m
+ (void)_pushUrl:(NSString *)url
         params:(id _Nullable)params
       animated:(BOOL)animated
         result:(ThrioNumberCallback _Nullable)result
   poppedResult:(ThrioIdCallback _Nullable)poppedResult {
  [self.navigationController thrio_pushUrl:url
                                    params:params
                                  animated:animated
                            fromEntrypoint:nil
                                    result:^(NSNumber *idx) {
    if (result) {
      result(idx);
    }
  } poppedResult:poppedResult];
}

4.1.2 Flutter からページを開く#

次に、Thrio が Dart ビジネス側からページを開く方法を見てみましょう:

InkWell(
  onTap: () => ThrioNavigator.push(
    url: 'biz1/flutter1',
    params: {
      '1': {'2': '3'}
    },
    poppedResult: (params) =>
    ThrioLogger.v('biz1/flutter1 popped:$params'),
  ),
  child: Container(
    padding: const EdgeInsets.all(8),
    margin: const EdgeInsets.all(8),
    color: Colors.yellow,
    child: Text(
      'push flutter1',
      style: TextStyle(fontSize: 22, color: Colors.black),
    )),
),

Thrio は、Native 側に push メッセージを MethodChannel を介して送信します:

// thrio_navigator.dart
static Future<int> push({
    @required String url,
    params,
    bool animated = true,
    NavigatorParamsCallback poppedResult,
  }) =>
      ThrioNavigatorImplement.push(
        url: url,
        params: params,
        animated: animated,
        poppedResult: poppedResult,
      );



// thrio_navigator_implement.dart
static Future<int> push({
    @required String url,
    params,
    bool animated = true,
    NavigatorParamsCallback poppedResult,
  }) {
    if (_default == null) {
      throw ThrioException('Must call the `builder` method first');
    }
    return _default._sendChannel
        .push(url: url, params: params, animated: animated)
        .then<int>((index) {
      if (poppedResult != null && index != null && index > 0) {
        _default._pagePoppedResults['$url.$index'] = poppedResult;
      }
      return index;
    });
  }


// navigator_route_send_channel.dart
Future<int> push({
    @required String url,
    params,
    bool animated = true,
  }) {
    final arguments = <String, dynamic>{
      'url': url,
      'animated': animated,
      'params': params,
    };
    return _channel.invokeMethod<int>('push', arguments);
  }

Native 側は push メッセージを受信すると、同様にthrio_pushUrlを呼び出します。つまり、Native または Flutter からページを開くロジックは、すべて Native 側で処理されます:

- (void)_onPush {
  __weak typeof(self) weakself = self;
  [_channel registryMethodCall:@"push"
                        handler:^void(NSDictionary<NSString *,id> * arguments,
                                      ThrioIdCallback _Nullable result) {
    NSString *url = arguments[@"url"];
    if (url.length < 1) {
      if (result) {
        result(nil);
      }
      return;
    }
    id params = [arguments[@"params"] isKindOfClass:NSNull.class] ? nil : arguments[@"params"];
    BOOL animated = [arguments[@"animated"] boolValue];
    ThrioLogV(@"on push: %@", url);
    __strong typeof(weakself) strongSelf = weakself;
    [ThrioNavigator.navigationController thrio_pushUrl:url
                                                params:params
                                              animated:animated
                                        fromEntrypoint:strongSelf.channel.entrypoint
                                                result:^(NSNumber *idx) { result(idx); }
                                          poppedResult:nil];
  }];
}

4.1.3 thrio_pushUrl#

このthrio_pushUrlが実際に何をしているのか見てみましょう:

// UINavigationController+Navigator.m
- (void)thrio_pushUrl:(NSString *)url
               params:(id _Nullable)params
             animated:(BOOL)animated
       fromEntrypoint:(NSString * _Nullable)entrypoint
               result:(ThrioNumberCallback _Nullable)result
         poppedResult:(ThrioIdCallback _Nullable)poppedResult {
  @synchronized (self) {
    UIViewController *viewController = [self thrio_createNativeViewControllerWithUrl:url params:params];
    if (viewController) {
    	// 4.2で分析
    } else {
      	// 4.3で分析
    }
  }
}

最初に行うのは、thrio_createNativeViewControllerWithUrlを呼び出して viewController を作成することです。thrio_createNativeViewControllerWithUrlの実装は以下の通りです:

- (UIViewController * _Nullable)thrio_createNativeViewControllerWithUrl:(NSString *)url params:(NSDictionary *)params {
  UIViewController *viewController;
  NavigatorPageBuilder builder = [ThrioNavigator pageBuilders][url];
  if (builder) {
    viewController = builder(params);
    // 省略いくつかの追加処理のコード
  }
  return viewController;
}

このコードを理解するには、Thrio のルーティング登録プロセスと組み合わせる必要があります。Native ビジネス側がルーティングを登録すると、Thrio はこれらの登録されたルーティングを管理するマップを維持します。キーは登録されたルーティング名で、値は対応するビルダーです。したがって、thrio_createNativeViewControllerWithUrlは、ルーティング名に基づいて NativeViewController コンテナを作成しようとします。登録されている場合は viewController が返され、登録されていない場合は nil が返されます。したがって、以下の 2 つのロジックが存在します。成功するのはいつでしょうか?それは、ビジネス側が pushUrl を開くときに、Native で登録されたページを開く場合です。

  1. viewController が存在する場合、Native ページを開くことになります。
  2. viewController が存在しない場合、Flutter ページを開くことになります(注:ここでは、Flutter 側でも登録されていないロジックが含まれています。このように書かれているのは、ルーティングが Native 側で登録されているかどうかを判断するためです。登録されていれば原生ページを開き、そうでなければ Flutter が処理します)。

したがって:

  1. viewController が存在する場合、Native ページを開くことになります。
  2. viewController が存在しない場合、Flutter ページを開くことになります(注:ここでは、Flutter 側でも登録されていないロジックが含まれています。このように書かれているのは、ルーティングが Native 側で登録されているかどうかを判断するためです。登録されていれば原生ページを開き、そうでなければ Flutter が処理します)。

次に、これらの 2 つのロジックを見ていきましょう。

注:Thrio はコンテナを 2 種類に分けています。1 つは NativeViewController で、Native ページを保持するコンテナです。もう 1 つは FlutterViewController で、Flutter ページを保持するコンテナです。

4.2 Native ページを開く#

viewController が存在する場合、つまり Native ページを開くことになります:

if (viewController) {
      [self thrio_pushViewController:viewController
                                 url:url
                              params:params
                            animated:animated
                      fromEntrypoint:entrypoint
                              result:result
                        poppedResult:poppedResult];
 }

thrio_pushViewController の実装は以下の通りです:

- (void)thrio_pushViewController:(UIViewController *)viewController animated:(BOOL)animated {
  // ...ナビゲーションバーの処理コードを省略
  [self thrio_pushViewController:viewController animated:animated];
}

NativeViewController を開くため、以下の分岐が実行されます。thrio_pushViewControllerを呼び出すことで、Native ページが開かれます。

- (void)thrio_pushViewController:(UIViewController *)viewController
                             url:(NSString *)url
                          params:(id _Nullable)params
                        animated:(BOOL)animated
                  fromEntrypoint:(NSString * _Nullable)entrypoint
                          result:(ThrioNumberCallback _Nullable)result
                    poppedResult:(ThrioIdCallback _Nullable)poppedResult {
  if (viewController) {
    NSNumber *index = @([self thrio_getLastIndexByUrl:url].integerValue + 1);
    __weak typeof(self) weakself = self;
    [viewController thrio_pushUrl:url
                            index:index
                           params:params
                         animated:animated
                   fromEntrypoint:entrypoint
                           result:^(NSNumber *idx) {
      if (idx && [idx boolValue]) {
        __strong typeof(weakself) strongSelf = weakself;
        [strongSelf pushViewController:viewController animated:animated];
      }
      if (result) {
        result(idx);
      }
    } poppedResult:poppedResult];
  }
}

ここで行われる主な処理は、thrio_pushUrlを呼び出すことです。これにより、ページの通知処理が行われます。ここでは詳細に分析しません。次に、thrio_pushUrlが何をしているのか見てみましょう:

- (void)thrio_pushUrl:(NSString *)url
                index:(NSNumber *)index
               params:(id _Nullable)params
             animated:(BOOL)animated
       fromEntrypoint:(NSString * _Nullable)entrypoint
               result:(ThrioNumberCallback _Nullable)result
         poppedResult:(ThrioIdCallback _Nullable)poppedResult {
  NavigatorRouteSettings *settings = [NavigatorRouteSettings settingsWithUrl:url
                                                                       index:index
                                                                      nested:self.thrio_firstRoute != nil
                                                                      params:params];
  if (![self isKindOfClass:NavigatorFlutterViewController.class]) { // 現在のページが原生ページの場合
    [ThrioNavigator onCreate:settings];
  }
  NavigatorPageRoute *newRoute = [NavigatorPageRoute routeWithSettings:settings];
  newRoute.fromEntrypoint = entrypoint;
  newRoute.poppedResult = poppedResult;
  if (self.thrio_firstRoute) {
    NavigatorPageRoute *lastRoute = self.thrio_lastRoute;
    lastRoute.next = newRoute;
    newRoute.prev = lastRoute;
  } else {
    self.thrio_firstRoute = newRoute;
  }
  if ([self isKindOfClass:NavigatorFlutterViewController.class]) {
  	// Flutterページを開くロジックは4.3.1で分析
  }
}

重要なのは、onCreateメソッドを呼び出すことです。ここで、MethodChannel を介して Dart 側に__onOnCreate__メッセージを送信します。Dart 側が受信すると、関連するイベントが処理されます:

NavigatorPageObserverChannel() {
    _on(
      'onCreate',
      (pageObserver, routeSettings) => pageObserver.onCreate(routeSettings),
    );
}

void _on(String method, NavigatorPageObserverCallback callback) =>
      _channel.registryMethodCall(
          '__on${method[0].toUpperCase() + method.substring(1)}__', (
              [arguments]) {
        final routeSettings = NavigatorRouteSettings.fromArguments(arguments);
        final pageObservers = ThrioNavigatorImplement.pageObservers;
        for (final pageObserver in pageObservers) {
          if (pageObserver is NavigatorPageObserverChannel) {
            continue;
          }
          callback(pageObserver, routeSettings);
        }
        return Future.value();
      });

つまり、Thrio はここでライフサイクルの統一処理を完了します。その実装方法は FlutterBoost と実質的に一致しており、混合スタックの遷移の過程で関連するイベントを通知します。通信層の具体的なロジックについては、ここでは詳細に分析しません。Thrio の Dart 側のコードは比較的シンプルで、興味のある方は自分で読んでみることをお勧めします。

4.3 Flutter ページを開く#

viewController が存在しない場合、つまりビジネス側が開こうとしているのは Flutter ページです:

if (viewController) {
  // 4.2
} else {
      NSString *entrypoint = @"";
      if (ThrioNavigator.isMultiEngineEnabled) {
        entrypoint = [url componentsSeparatedByString:@"/"].firstObject;
      }

      __weak typeof(self) weakself = self;
      ThrioIdCallback readyBlock = ^(id _){
        ThrioLogV(@"push entrypoint: %@, url:%@", entrypoint, url);
        __strong typeof(weakself) strongSelf = weakself;
        if ([strongSelf.topViewController isKindOfClass:NavigatorFlutterViewController.class] &&
            [[(NavigatorFlutterViewController*)strongSelf.topViewController entrypoint] isEqualToString:entrypoint]) {
         	// 4.3.1で分析
        } else {
         	// 4.3.2で分析
      };

      [NavigatorFlutterEngineFactory.shared startupWithEntrypoint:entrypoint readyBlock:readyBlock];
};

ここでは、いくつかのマルチエンジンのフラグ処理が行われます。Thrio のマルチエンジンは現在開発中であり、詳細には見ませんが、主な部分を見ていきます。開こうとしているページが Flutter ページである場合、Thrio と Flutter Boost は異なり、すべてのコンテナを一度に作成するのではなく、状況に応じて処理します。これは、Thrio と Flutter Boost の最大の違いです:

  1. Flutter が Flutter を開く
  2. Native が Flutter を開く

4.3.1 Flutter が Flutter を開く#

if ([strongSelf.topViewController isKindOfClass:NavigatorFlutterViewController.class] &&
            [[(NavigatorFlutterViewController*)strongSelf.topViewController entrypoint] isEqualToString:entrypoint]) {
          NSNumber *index = @([strongSelf thrio_getLastIndexByUrl:url].integerValue + 1);
          [strongSelf.topViewController thrio_pushUrl:url
                                                index:index
                                               params:params
                                             animated:animated
                                       fromEntrypoint:entrypoint
                                               result:^(NSNumber *idx) {
            if (idx && [idx boolValue]) {
              [strongSelf thrio_removePopGesture];
            }
            if (result) {
              result(idx);
            }
          }];
} else {
 	// NativeがFlutterを開く4.3.2で分析
}

ここでも、thrio_pushUrlが呼び出されます:

- (void)thrio_pushUrl:(NSString *)url
                index:(NSNumber *)index
               params:(id _Nullable)params
             animated:(BOOL)animated
       fromEntrypoint:(NSString * _Nullable)entrypoint
               result:(ThrioNumberCallback _Nullable)result
         poppedResult:(ThrioIdCallback _Nullable)poppedResult {
  NavigatorRouteSettings *settings = [NavigatorRouteSettings settingsWithUrl:url
                                                                       index:index
                                                                      nested:self.thrio_firstRoute != nil
                                                                      params:params];
   // ...省略4.2のコード
  if ([self isKindOfClass:NavigatorFlutterViewController.class]) {
  	NSMutableDictionary *arguments = [NSMutableDictionary dictionaryWithDictionary:[settings toArguments]];
    [arguments setObject:[NSNumber numberWithBool:animated] forKey:@"animated"];
    NSString *entrypoint = [(NavigatorFlutterViewController*)self entrypoint];
    NavigatorRouteSendChannel *channel = [NavigatorFlutterEngineFactory.shared getSendChannelByEntrypoint:entrypoint];
    if (result) {
      [channel onPush:arguments result:^(id _Nullable r) {
        result(r && [r boolValue] ? index : nil);
      }];
    } else {
      [channel onPush:arguments result:nil];
    }
  }
}

コアは、MethodChannel を使用して Dart 側に onPush メッセージを送信することです:

- (void)onPush:(id _Nullable)arguments result:(FlutterResult _Nullable)callback {
  [self _on:@"onPush" arguments:arguments result:callback];
}

- (void)_on:(NSString *)method
  arguments:(id _Nullable)arguments
     result:(FlutterResult _Nullable)callback {
  NSString *channelMethod = [NSString stringWithFormat:@"__%@__", method];
  [_channel invokeMethod:channelMethod arguments:arguments result:callback];
}

Dart 側がメッセージを受信すると、ルーティング名に基づいてビルダーを見つけて Route を生成し、その後 Flutter の Navigator を使用してこのウィジェットを push します:

void _onPush() => _channel.registryMethodCall('__onPush__', ([arguments]) {
        final routeSettings = NavigatorRouteSettings.fromArguments(arguments);
        ThrioLogger.v('onPush:${routeSettings.name}');
        final animatedValue = arguments['animated'];
        final animated =
            (animatedValue != null && animatedValue is bool) && animatedValue;
        return ThrioNavigatorImplement.navigatorState
            ?.push(routeSettings, animated: animated)
            ?.then((it) {
          _clearPagePoppedResults();
          return it;
        });
      });

したがって、Thrio はこのシナリオでは FlutterViewController を新たに作成するのではなく、既存のコンテナで Navigator を使用して push します。したがって、連続して Flutter ページを開くシナリオでは、Thrio のメモリ使用量は少なくなります。

4.3.2 Native が Flutter を開く#

if ([strongSelf.topViewController isKindOfClass:NavigatorFlutterViewController.class] &&
            [[(NavigatorFlutterViewController*)strongSelf.topViewController entrypoint] isEqualToString:entrypoint]) {
	// 4.3.1、FlutterがFlutterを開く
} else {
          UIViewController *viewController = [strongSelf thrio_createFlutterViewControllerWithEntrypoint:entrypoint];
          [strongSelf thrio_pushViewController:viewController
                                           url:url
                                        params:params
                                      animated:animated
                                fromEntrypoint:entrypoint
                                        result:result
                                  poppedResult:poppedResult];
}

Native ページから Flutter を開く場合、最初にthrio_createFlutterViewControllerWithEntrypointを実行します。これは、FlutterViewController を作成することを意味します:

- (UIViewController *)thrio_createFlutterViewControllerWithEntrypoint:(NSString *)entrypoint {
  UIViewController *viewController;
  NavigatorFlutterPageBuilder flutterBuilder = [ThrioNavigator flutterPageBuilder];
  if (flutterBuilder) {
    viewController = flutterBuilder();
  } else {
    viewController = [[NavigatorFlutterViewController alloc] initWithEntrypoint:entrypoint];
  }
  return viewController;
}

ここで注意が必要なのは、フレームワークがオブジェクトを維持しており、以前に FlutterViewController を作成した場合はキャッシュから取得し、そうでなければ新たに FlutterViewController を作成するということです。

その後、thrio_pushViewControllerを呼び出します。このロジックは、以前に分析した 4.2 の Native ページを開く場合と同じです:

- (void)thrio_pushViewController:(UIViewController *)viewController
                             url:(NSString *)url
                          params:(id _Nullable)params
                        animated:(BOOL)animated
                  fromEntrypoint:(NSString * _Nullable)entrypoint
                          result:(ThrioNumberCallback _Nullable)result
                    poppedResult:(ThrioIdCallback _Nullable)poppedResult {
  if (viewController) {
    NSNumber *index = @([self thrio_getLastIndexByUrl:url].integerValue + 1);
    __weak typeof(self) weakself = self;
    [viewController thrio_pushUrl:url
                            index:index
                           params:params
                         animated:animated
                   fromEntrypoint:entrypoint
                           result:^(NSNumber *idx) {
      if (idx && [idx boolValue]) {
        __strong typeof(weakself) strongSelf = weakself;
        [strongSelf pushViewController:viewController animated:animated];
      }
      if (result) {
        result(idx);
      }
    } poppedResult:poppedResult];
  }
}
  1. thrio_pushUrlを呼び出すことで、通知のロジックが実行されます。ここでは詳細に分析しません。
  2. pushViewControllerを呼び出すことで、Native 内で FlutterViewController を直接 push します。

Thrio のソースコードは、ここで分析した内容だけではなく、インデックスの維持、境界処理、マルチエンジンのロジック、シーンのパフォーマンス最適化、ライフサイクルとルーティングイベント通知などのロジックがまだ分析されていません。本文の長さの問題から、主なプロセスを通じてフレームワークの流れを打通することに留め、残りの詳細については本文では一つ一つ分析しません。

以下の図は、本節 Thrio によるページを開くプロセスの要約です:

image.png

フレームワークの実装において、Thrio は Flutter Boost よりも複雑であることがわかりますが、混合スタックのコンテナはよりシンプルです。連続した Flutter ページ(ウィジェット)を開く場合、現在の FlutterViewController 内で開くだけで済み、新しいコンテナを作成する必要はありません。

5. マルチエンジンモード#

マルチエンジンのアーキテクチャは以下の図のようになります:

image

公式の設計では、FlutterEngine は 4 つのスレッド(タスクランナー)に対応しています:

  • プラットフォームタスクランナー
  • UI タスクランナー
  • GPU タスクランナー
  • IO タスクランナー

したがって、エンジンは比較的重いオブジェクトです。以前、筆者はエンジンを起動するのにメインスレッドで約 30ms かかり、メモリ使用量が 30MB 増加することをテストしました。メモリの占有は優れていませんが、メインスレッドが 30ms しか占有しないのは、RN や Webview が 100〜200ms も初期化にかかるのに比べてかなり良いです。

マルチエンジンは以下のような問題を引き起こす可能性があります:

  1. 起動と実行に追加のリソースを消費する:ここではエンジンを裁断することで最適化できます。
  2. 冗長なリソースの問題:マルチエンジンモードでは、各エンジン間の Isolate は相互に独立しており、論理的には問題ありませんが、エンジンの底層は画像キャッシュなどのメモリを消費するオブジェクトを維持しています。各エンジンは独自の画像キャッシュを維持し、メモリの圧力が増大します。
  3. 通信層の混乱問題:マルチエンジンは通信層のロジックを特に複雑にし、通信層のロジックを設計する必要があります。ここでは Thrio の実装を学ぶことができます。

Thrio にはインデックス維持メカニズムがあり、マルチエンジンとマルチ FlutterViewController を組み合わせることで、各ページの位置を特定できます:

thrio-architecture.png

マルチエンジンがもたらす隔離は確かに利点ですが、どれだけのパフォーマンス向上が得られるかは、さらにテストする必要があります。ただし、マルチエンジンモードは期待される混合開発フレームワークのモードです。

参考資料:

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。