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#
A qualified hybrid development framework must at least support the following capabilities:
- Management of hybrid route stacks: Support opening any Flutter or Native page.
- 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):
Therefore, we can easily implement this route stack:
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?
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:
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:
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:
- Native -> Flutter
- 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:
- Native opens Native: Intercept the registered Native route through the routing management class, instantiate the viewController, and then push it.
- Native opens Flutter: Instantiate
FLBFlutterViewContainer
and then push it, whereFLBFlutterViewContainer
is essentially aFlutterViewController
.
3.2 Opening Pages from Flutter#
This section analyzes how Flutter Boost opens pages from Flutter, which includes the following two cases:
- Flutter -> Flutter
- 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.
- Flutter opens Flutter: After the iOS side receives the message, it executes
open
. That is, instantiateFLBFlutterViewContainer
and then push it, whereFLBFlutterViewContainer
is essentially aFlutterViewController
. This is the same as Native opening Flutter. - 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 incontainer_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:
- Container change listener:
BoostContainerObserver
- Lifecycle listener:
BoostContainerLifeCycleObserver
- 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:
Note: This solution frequently creates
FlutterViewController
. After popping certainFlutterViewController
, 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:
- Management of hybrid route stacks: Support opening any Flutter or Native page.
- 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:
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:
- If the viewController exists, it means the page to be opened is a Native page.
- 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:
- Call
thrio_pushUrl
. - 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:
- Flutter opens Flutter.
- 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];
}
}
- Call
thrio_pushUrl
, which also involves the notification logic, which we will not elaborate on here. - Call
pushViewController
, which directly pushesFlutterViewController
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:
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:
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:
- Starting and running require additional resources: This can be optimized by trimming the engine.
- 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.
- 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:
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:
- Adding Flutter to existing apps: https://flutter.dev/docs/development/add-to-app
- Xianyu's mobile cross-platform application practice based on Flutter: http://www.cocoachina.com/cms/wap.php?action=article&id=24859
- Today's Headlines | Making Flutter Truly Support View-Level Hybrid Development: https://www.msup.com.cn/share/details?id=226
- hellobike/thrio: https://github.com/hellobike/thrio/blob/master/doc/Feature.md
- alibaba/flutter_boost: https://github.com/alibaba/flutter_boost