Airing

Airing

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

Exploration of Flutter Hybrid Development Framework Model

Due to the overly simplistic Flutter hybrid development solution provided by Google, which only supports the ability to open a Flutter View without supporting necessary capabilities such as parameter passing between routes, unified lifecycle management, and route stack management, we need to rely on the integration capabilities of third-party hybrid development frameworks (such as Flutter Boost, Thrio, QFlutter, etc.) to implement the Flutter hybrid development model in a production environment. In this article, we will explore the functions, architecture, and source code of such hybrid development frameworks.

1. Core Functions and Framework Goals#

15867692757044.jpg

A qualified hybrid development framework must at least support the following capabilities:

  1. Management of hybrid route stacks: Support opening any Flutter or Native page.
  2. A complete notification mechanism: Such as a unified lifecycle and route-related event notification mechanisms.

For the above goals, we will take iOS as an example to gradually explore the best implementation of the Flutter hybrid development model.

Note: Due to space constraints, this article does not explore the Android implementation. The analysis from the iOS perspective is just one angle of the problem, as the implementation principles for Android and iOS are consistent, with specific implementations being largely similar. Additionally, due to the complexity of the Channel communication layer code, this article will only cover the usage aspect of the Channel communication layer; readers can study the specific implementation themselves.
Note: The version of Flutter Boost in this article is 1.12.13, and the version of Thrio is 0.1.0.

2. Starting from FlutterViewController#

In hybrid development, we use Flutter as a plugin-based development approach, which requires a FlutterViewController. This is an implementation of UIViewController that is attached to a FlutterEngine, passing UIKit input events to Flutter and displaying each frame of Flutter views rendered by the FlutterEngine. The FlutterEngine serves as the environment for the Dart VM and Flutter runtime.

It is important to note that a Flutter Engine can only run one FlutterViewController at most at the same time.

FlutterEngine: The FlutterEngine class coordinates a single instance of execution for a FlutterDartProject. It may have zero or one FlutterViewController at a time.

Starting the Engine:

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

Creating and displaying FlutterViewController:

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

When creating a FlutterViewController, we can either initialize it with an already running FlutterEngine or create it while implicitly starting a new FlutterEngine (though this is not recommended, as creating a FlutterEngine on demand will introduce noticeable delays before the first frame is rendered after the FlutterViewController is presented). Here, we use the former method to create it.

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

Thus, we have started the Flutter Engine in the Native project using the official solution and displayed the Flutter page through FlutterViewController.

In the Flutter page, we can use Navigator.push to open another Flutter page (Route):

image.png

Therefore, we can easily implement this route stack:

image.png

That is, the entire Flutter runs in a singleton FlutterViewController container, and all internal pages of Flutter are managed within this container. But how do we implement a hybrid route stack that allows for mixed navigation between Native and Flutter, as shown below?

15867693180281.jpg

The most basic solution is to mix the FlutterViewController with the NativeViewController, allowing the FlutterViewController to move back and forth within the iOS route stack, as shown in the diagram below:

image.png

This solution is relatively complex. Returning to the mixed stack scenario we mentioned earlier, it requires precise tracking of the position of each Flutter page and Native container, knowing whether to return to the previous Flutter page or switch to another NativeViewController after popping. This necessitates maintaining a proper page index and modifying the native pop timing and Navigator.pop events to unify the two.

Let's take a look at some industry solutions!

3. Flutter Boost#

For the mixed stack problem, Flutter Boost wraps each Flutter page in a FlutterViewController, making it behave like multiple instances, similar to a Webview:

image.png

The source code of Flutter Boost was previously summarized in another article, “Flutter Boost Hybrid Development Practice and Source Code Analysis (Taking Android as an Example)”. That article outlined the source code for opening pages on the Android side. This article will try not to repeat the source code introduction and will focus on how Flutter Boost is implemented on the iOS side.

3.1 Opening Pages from Native#

This section analyzes how Flutter Boost opens pages from Native, which includes the following two cases:

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

In the project, we need to integrate the following code to use 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"]) { // Simulate opening a native page
        [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);
}

We can see that we first need to start the engine in the project, and the corresponding source code for Flutter Boost is as follows:

// 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);
    });
}

We can see that when starting the engine, startFlutterWithPlatform requires passing in the routing management class, and in FLBPlatform.h, we can see that the open interface is implemented by the Native side passed from the business side.

// PlatformRouterImp.m

- (void)open:(NSString *)name
   urlParams:(NSDictionary *)params
        exts:(NSDictionary *)exts
  completion:(void (^)(BOOL))completion
{
    if ([name isEqualToString:@"native"]) { // Simulate opening a native page
        [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);
}

Next, we need to initialize a UINavigationController in the business side AppDelegate.m, and then implement the open method in the routing management class, which pushes a FLBFlutterViewContainer into this navigationContainer. Its parent class is actually the FlutterViewController we mentioned in the first chapter. The core process code is as follows:


// 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;
}

Yes, it calls the initWithEngine method to create a FlutterViewController, as we mentioned in the first chapter.

So how does a Native page open a Flutter page? The business side calls the open method of FlutterBoostPlugin:

- (IBAction)pushFlutterPage:(id)sender {
    [FlutterBoostPlugin open:@"first" urlParams:@{kPageCallBackId:@"MycallbackId#1"} exts:@{@"animated":@(YES)} onPageFinished:^(NSDictionary *result) {
        NSLog(@"call me when page finished, and your result is:%@", result);
    } completion:^(BOOL f) {
        NSLog(@"page is opened");
    }];
}

The corresponding handling in Flutter Boost is as follows:


// 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];
    }
}

The open method of the Platform is implemented in the routing class passed from the business side. It then goes back to the part we analyzed at the beginning of this chapter. As previously analyzed, this part will not be repeated.

To summarize, whether Native opens Native or Flutter, the business side needs to call the open method of Flutter Boost, and the implementation of Flutter Boost's open method ultimately returns to the open method implemented in the routing management class on the business side:

  1. Native opens Native: Intercept the registered Native route through the routing management class, instantiate the viewController, and then push it.
  2. Native opens Flutter: Instantiate FLBFlutterViewContainer and then push it, where FLBFlutterViewContainer is essentially a FlutterViewController.

3.2 Opening Pages from Flutter#

This section analyzes how Flutter Boost opens pages from Flutter, which includes the following two cases:

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

The Dart business side directly calls the open method to open a Native or Flutter page:

FlutterBoost.singleton.open("native").then((Map value) {
    print("call me when page is finished. did receive native route result $value");
});

FlutterBoost.singleton.open("flutterPage").then((Map value) {
    print("call me when page is finished. did receive native route result $value");
});

The source code for open shows that its core is to use MethodChannel to send an openPage message to the Native side:


// 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);
}

The iOS side listens for messages coming from Dart and handles openPage, primarily calling the open method in FLBFlutterApplication:


- (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];
    }
}

As previously analyzed, after receiving the openPage message, it will call the open method of Flutter Boost, and it ultimately goes to the open method implemented in the routing management class on the Native side. This means that opening a page from Flutter is ultimately handled by Native.

To summarize, whether Flutter opens a Flutter or Native page, it needs to send an openPage message to the iOS side. After receiving the message, the iOS side will execute the open method of Flutter Boost, and its implementation is the open method in the routing management class on the business side, meaning that it is ultimately handled by the routing on the business side.

  1. Flutter opens Flutter: After the iOS side receives the message, it executes open. That is, instantiate FLBFlutterViewContainer and then push it, where FLBFlutterViewContainer is essentially a FlutterViewController. This is the same as Native opening Flutter.
  2. Flutter opens Native: After the iOS side receives the message, it executes open. It intercepts the registered Native route through the routing management class, instantiates the viewController, and then pushes it. This is the same as Native opening Native.

3.3 Flutter Container Switching#

We mentioned earlier that the unified management of routes is handled by the Native side. Each time a page (whether Flutter or Native) is pushed, a container is pushed. The benefit of unified management is that it is controlled by the Native business side, making it direct and simple; the benefit of pushing a container each time is that it is intuitive and straightforward.

However, we previously stated that a Flutter Engine can only mount one FlutterViewController at most at the same time. Will generating a view controller each time we open a Flutter page cause problems? Let's see how Flutter Boost handles this:


// FLBFlutterViewContainer.m
- (void)viewWillAppear:(BOOL)animated
{
    // For new page we should attach flutter view in view will appear
    [self attatchFlutterEngine];
    [BoostMessageChannel willShowPageContainer:^(NSNumber *result) {}
                                            pageName:_name
                                              params:_params
                                            uniqueId:self.uniqueIDString];
    // Save some first time page info.
    [FlutterBoostPlugin sharedInstance].fPagename = _name;
    [FlutterBoostPlugin sharedInstance].fPageId = self.uniqueIDString;
    [FlutterBoostPlugin sharedInstance].fParams = _params;
    
    [super bridge_viewWillAppear:animated];
    [self.view setNeedsLayout];
}

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

We can see that when the container appears, it calls the attatchFlutterEngine method, which is used to switch the viewController of the engine. That is, each time a Flutter page is opened, the newly generated container FlutterViewController will be mounted on the engine. Yes, Flutter Boost displays Flutter containers and pages by continuously switching the viewController of the engine.

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

3.4 Unified Lifecycle and Route Event Notification#

So how does Flutter Boost solve the inconsistency between the lifecycle of Native and Dart pages?

We can take the FLBFlutterViewController viewWillAppear function as an example. In this function, it executes willShowPageContainer, which is implemented in 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);
         }
     }];
 }

It only does one thing, which is to send the willShowPageContainer message to the Dart side through the method channel. The Dart side receives the message in container_coordinator.dart:

The Dart side code of Flutter Boost is relatively simple. As the name suggests, container_coordinator.dart is the class that coordinates the Native side container. It is responsible for listening to messages from Native and using the container management class in container_manager.dart to perform some processing.

// 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));
    }
    // ... omitted some optimization code
    return true;
}

The core is to execute FlutterBoost.containerManager?.pushContainer, which is implemented in the container_manager.dart container management class:

// 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');
}

During the execution of BoostContainer.obtain, lifecycle listening occurs internally. In addition, it also executes observer(ContainerOperation.Push, _onstage.settings); to trigger the Push event notification.

In fact, in FlutterBoost, the framework registers a total of three types of event listeners:

  1. Container change listener: BoostContainerObserver
  2. Lifecycle listener: BoostContainerLifeCycleObserver
  3. Navigator push and pop listener: ContainerNavigatorObserver

They are triggered along with the transitions of the mixed route stack. As for the source code of the communication layer, this article will not analyze it further, leaving it for interested readers to explore.

The following diagram summarizes the process of opening pages with Flutter Boost in this chapter:

image.png

Note: This solution frequently creates FlutterViewController. After popping certain FlutterViewController, the memory is not released by the engine, causing memory leaks: https://github.com/flutter/flutter/issues/25255. This is a bug in the engine, which seems to remain unresolved to this day.

4. Thrio#

Thrio is another Flutter hybrid stack framework open-sourced by Hellobike last month (March 2020). The core issues addressed by this framework are still the two points raised in the first chapter:

  1. Management of hybrid route stacks: Support opening any Flutter or Native page.
  2. A complete notification mechanism: Such as a unified lifecycle and route-related event notification mechanisms.

In this article, we will mainly look at how Thrio implements hybrid stack management. As for the logic of the communication layer, we will only briefly explain some aspects, as the specific implementation is quite complex and will not be analyzed in detail in this article.

We can first look at the sequence diagram to get an overview of the process:

thrio-push.png

4.1 Invocation#

4.1.1 Opening Pages from Native#

To open Native or Flutter pages from the iOS business side, simply call openUrl:

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

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

openUrl ultimately calls 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 Opening Pages from Flutter#

Now let's see how Thrio implements opening pages from the Dart business side:

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 handles this by sending a push message to the Native side via 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);
  }

The Native side receives the push message and similarly calls thrio_pushUrl, meaning that the logic for opening pages from Native or Flutter is unified and handled by the Native side:

- (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#

Now let's see what thrio_pushUrl does:

// 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) {
    	// Analysis in 4.2
    } else {
      	// Analysis in 4.3
    }
  }
}

The first thing it does is call thrio_createNativeViewControllerWithUrl to create a viewController. The implementation is as follows:

- (UIViewController * _Nullable)thrio_createNativeViewControllerWithUrl:(NSString *)url params:(NSDictionary *)params {
  UIViewController *viewController;
  NavigatorPageBuilder builder = [ThrioNavigator pageBuilders][url];
  if (builder) {
    viewController = builder(params);
    // Omitted some extra processing code
  }
  return viewController;
}

Understanding this code requires combining it with Thrio's route registration process. After the Native business side registers a route, Thrio maintains a map to manage these registered routes, where the key is the registered route name and the value is the corresponding builder. Thus, thrio_createNativeViewControllerWithUrl attempts to create a NativeViewController container based on the route name. If it has been registered, it will return the viewController; if not, it will return nil. Therefore, we have the two logic branches based on whether the viewController exists. Let's see when it will be successfully created. If the page to be opened is one that has been registered on the Native side, it will return a NativeController; otherwise, if the pushUrl is called to open a route registered from the Flutter side, it will open a FlutterViewController.

Thrio author @Daizi pointed out: This also includes logic for routes that have not been registered on the Flutter side. This is written to determine whether the route is registered on the Native side. If it is registered, it opens the native page; otherwise, it is handed over to Flutter for processing. If the Flutter side has registered it, it will open the Flutter page; otherwise, it will return null.

Thus:

  1. If the viewController exists, it means the page to be opened is a Native page.
  2. If the viewController does not exist, it means the page to be opened is a Flutter page (note: this is mainly to hand over to Flutter for processing; Flutter may also not have registered this route).

Next, let's continue analyzing these two logic branches.

Note: Thrio divides containers into two types: NativeViewController, which carries Native pages, and FlutterViewController, which carries Flutter pages.

4.2 Opening Native Pages#

If the viewController exists, it means we are opening a Native page:

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

The implementation of thrio_pushViewController is as follows:

- (void)thrio_pushViewController:(UIViewController *)viewController animated:(BOOL)animated {
  // ... omitted code for handling navigator bar
  [self thrio_pushViewController:viewController animated:animated];
}

We are opening a NativeViewController, so we call thrio_pushViewController:

- (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];
  }
}

Here, two things are done:

  1. Call thrio_pushUrl.
  2. Call pushViewController, which directly pushes a container in Native.

In fact, the container has already been opened here, but we still need to see what the first step of calling thrio_pushUrl does:

- (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]) { // Current page is a native page
    [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]) {
  	// Logic for opening Flutter pages, analyzed in 4.3.1
  }
}

The key point is that it calls the onCreate function, and the remaining business is about handling page pointers, which we will not analyze here. Let's see what onCreate does:

- (void)onCreate:(NavigatorRouteSettings *)routeSettings {
  NSDictionary *arguments = [routeSettings toArguments];
  [_channel invokeMethod:@"__onOnCreate__" arguments:arguments];
}

Here, it uses MethodChannel to send an onOnCreate message to the Dart side. The Dart side receives it and processes it with the relevant events:

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();
      });

Thus, Thrio completes the unified lifecycle handling here, and its implementation method is actually consistent with FlutterBoost, both of which notify relevant events during the transitions of the hybrid stack. As for the specific logic of the communication layer, we will not analyze it further here. Additionally, the Dart side code of Thrio is relatively concise, and interested readers are recommended to read it themselves.

4.3 Opening Flutter Pages#

If the viewController does not exist, it means the business side wants to open a Flutter page:

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]) {
         	// Analyzed in 4.3.1
        } else {
         	// Analyzed in 4.3.2
      };

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

Here, there is some handling for the multi-engine flag, as Thrio's multi-engine is still under development. We will not look at the multi-engine part of the code here; let's focus on the main part. Depending on the container type, if the current (topmost) viewController is a FlutterViewController (where NavigatorFlutterViewController is a layer of encapsulation), it will follow one logic; otherwise, it will follow another logic for NativeViewController.

Thus, when the page to be opened is a Flutter page, Thrio, unlike Flutter Boost, does not create containers all at once but distinguishes the situation for processing. This is actually the biggest difference between Thrio and Flutter Boost:

  1. Flutter opens Flutter.
  2. Native opens Flutter.

4.3.1 Flutter Opens 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);
            }
          } poppedResult:poppedResult];
} else {
 	// Analyzed in 4.3.2, Native opens Flutter
}

Here, it will again go to the thrio_pushUrl we analyzed in 4.2:

- (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];
   // ... omitted code from 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];
    }
  }
}

The core is to send an onPush message to the Dart side using MethodChannel:

- (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];
}

The Dart side receives the message and generates a Route based on the route name, then uses Flutter's Navigator to push this widget:

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;
        });
      });

Thus, in this scenario, Thrio does not create a FlutterViewController but uses the existing container to push with Navigator. Therefore, in scenarios where Flutter pages are opened consecutively, Thrio's memory usage will be lower.

4.3.2 Native Opens Flutter#

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

When opening from a Native page to Flutter, it first executes thrio_createFlutterViewControllerWithEntrypoint, which, as the name suggests, creates a FlutterViewController:

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

It is important to note that the framework maintains an object here. If a FlutterViewController has been created before, it will retrieve it from the cache; otherwise, it will create a new FlutterViewController.

Then it calls thrio_pushViewController, and this logic is the same as what we analyzed for opening Native pages in 4.2:

- (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. Call thrio_pushUrl, which also involves the notification logic, which we will not elaborate on here.
  2. Call pushViewController, which directly pushes FlutterViewController in Native.

Thrio's source code, of course, does not stop at the points analyzed here; there are many aspects such as index maintenance, boundary handling, multi-engine logic, scene performance optimization, lifecycle, and route event notifications that have not been analyzed. Given the length of this article, we will only analyze the main process to connect the framework's pulse, leaving the remaining details for further exploration.

The following diagram summarizes the process of opening pages with Thrio in this section:

image.png

Although we find that Thrio's implementation is somewhat more complex than Flutter Boost, the containers for the hybrid stack are more concise—consecutive Flutter pages (widgets) can be opened directly in the current FlutterViewController without needing to create new containers.

5. Multi-Engine Mode#

The architecture of the multi-engine mode is shown in the diagram below:

image

The official design is that each FlutterEngine corresponds to four threads (Task Runners):

  • Platform Task Runner
  • UI Task Runner
  • GPU Task Runner
  • IO Task Runner

Thus, the Engine is a relatively heavy object. Previously, I tested that starting an engine takes about 30ms on the main thread and increases memory usage by 30MB. Although it does not occupy memory advantageously, the main thread only occupies 30ms, which is much better compared to RN and Webview, which often initialize in 100-200 ms.

Multi-engine may bring some issues:

  1. Starting and running require additional resources: This can be optimized by trimming the engine.
  2. Redundant resource issues: In multi-engine mode, each engine maintains its own Isolate, which is not inherently problematic logically, but the engine's underlying maintenance of image caches and other memory-consuming objects means that each engine maintains its own image cache, increasing memory pressure.
  3. Communication layer chaos: Multi-engine complicates the logic of the communication layer, necessitating careful design of the communication layer logic. Here, we can learn from Thrio's implementation.

Thrio has a set of index maintenance mechanisms that, combined with multi-engine and multiple FlutterViewController, can locate each page's position:

thrio-architecture.png

Undeniably, the isolation brought by multi-engine is a benefit. As for how much performance improvement it brings, further testing is needed. However, the multi-engine mode is a promising framework model for hybrid development.

References:

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.