29 Mixed Development Which Solution Should Be Used to Manage Navigation Stacks

29 Mixed Development Which Solution Should Be Used to Manage Navigation Stacks #

Hello, I am Chen Hang.

In order to introduce Flutter into a native project, we need to transform the Flutter project into a component dependency of the native project and manage the build artifacts of Flutter for different platforms in a component-based way. This means using AAR for the Android platform and Cocoapods for the iOS platform for dependency management. In this way, we can use FlutterView in the Android project and FlutterViewController in the iOS project to establish the application entries for Flutter and achieve hybrid development between Flutter and native by rendering and switching between multiple complex pages.

In article 26, I mentioned that FlutterView and FlutterViewController are the places where Flutter is initialized and also the entry points of the application. As you can see, integrating Flutter in a hybrid development manner is no different from developing a pure Flutter application in terms of the runtime mechanism. The only difference is that the native project provides a canvas container (FlutterView for Android and FlutterViewController for iOS) for it. Flutter can then manage the page navigation stack by itself, thus achieving the rendering and switching of multiple complex pages.

I have already introduced the page routing and navigation in a pure Flutter application in article 21. In today’s article, I will tell you how to manage the hybrid navigation stack in hybrid development.

For hybrid development applications, we usually only modify some modules of the application to use Flutter, while keeping other modules in native development. Therefore, besides Flutter pages, there will also be native Android and iOS pages within the application. In this case, Flutter pages may need to navigate to native pages, and native pages may also need to navigate to Flutter pages. This raises a new question: how to uniformly manage the hybrid navigation stack for interaction between native and Flutter pages.

Next, we will start today’s learning from this question.

Hybrid Navigation Stack #

The hybrid navigation stack refers to the coexistence of native pages and Flutter pages in the user’s perspective of page navigation stack view.

Take the following diagram as an example. Flutter and native Android and iOS each implement a set of page mapping mechanisms that are independent of each other. Native pages adopt a single container single page mechanism (one ViewController/Activity corresponds to one native page), while Flutter adopts a single container multiple pages mechanism (one ViewController/Activity corresponds to multiple Flutter pages). Flutter builds its own Flutter navigation stack on top of the native navigation stack, which requires us to handle cross-engine page transitions when switching between Flutter and native pages.

Figure 1: Illustration of hybrid navigation stack

Next, let’s take a look at how to handle transitioning from a native page to a Flutter page and from a Flutter page to a native page.

Transitioning from a Native Page to a Flutter Page #

Transitioning from a native page to a Flutter page is relatively simple to implement.

Because Flutter relies on the native container (FlutterViewController for iOS, FlutterView in Activity for Android), we can use the native way to navigate to Flutter pages by initializing the Flutter container and setting the initial route page.

The code example below shows how to do this. For iOS, we initialize a FlutterViewController instance, set the initial route page, and then push it onto the native view navigation stack to complete the transition.

For Android, an additional step is needed. Since the entry point of the Flutter page is not the smallest unit of the native view navigation stack, which is an Activity, but a View (FlutterView), we also need to wrap this View in the contentView of the Activity. After setting the page’s initial route inside the Activity, we can open the Flutter page using the standard method of opening a normal native view externally.

// Transition from a native page to a Flutter page on iOS
FlutterViewController *vc = [[FlutterViewController alloc] init];
[vc setInitialRoute:@"defaultPage"];// set the initial Flutter route
[self.navigationController pushViewController:vc animated:YES];// complete the page transition

// Transition from a native page to a Flutter page on Android
// Create an Activity as a Flutter page container
public class FlutterHomeActivity extends AppCompatActivity {
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    // Set the initial Flutter route
    View FlutterView = Flutter.createView(this, getLifecycle(), "defaultRoute"); // pass the route identifier
    setContentView(FlutterView);// replace the Activity's ContentView with FlutterView
  }
}
// Use FlutterPageActivity to perform page transition
Intent intent = new Intent(MainActivity.this, FlutterHomeActivity.class);
startActivity(intent);

Transitioning from a Flutter Page to a Native Page #

Transitioning from a Flutter page to a native page is relatively more complicated. We need to consider the following two scenarios:

  • Opening a new native page from a Flutter page;
  • Navigating back to an existing native page from a Flutter page.

First, let’s take a look at how Flutter opens a native page.

Since Flutter does not provide direct access to native pages, we cannot call them directly. We need to provide a method to operate native pages at both the Flutter and native ends when initializing Flutter, and register the method channels. When the native end receives a method call from Flutter, it can open a new native page.

Next, let’s see how to navigate back to a native page from a Flutter page.

Since the Flutter container itself is part of the native navigation stack, when the root page inside the Flutter container (i.e., the initial route page) needs to be returned to, we need to close the Flutter container to achieve the closure of the Flutter root page. Similarly, Flutter does not provide methods to manipulate the Flutter container, so we still need to use method channels to provide methods for manipulating the Flutter container in the native code host. With these methods, we can close the Flutter page when returning from a page.

The two scenarios for navigating from a Flutter page to a native page are shown in the following diagram:

Figure 2: Illustration of navigating from a Flutter page to a native page

Next, let’s take a look at how to implement these two methods, openNativePage for opening a native page and closeFlutterPage for closing a Flutter page, using method channels on both Android and iOS platforms.

The most appropriate place to register a method channel is the entry point of the Flutter application, that is, inside the FlutterViewController (for iOS) and the FlutterView (for Android) when initializing the container. In order to encapsulate Flutter-related behaviors inside the container, we need to inherit from FlutterViewController and Activity respectively. When initializing the view in viewDidLoad and onCreate, we register the openNativePage and closeFlutterPage methods.

The implementation code on iOS side is as follows:

@interface FlutterHomeViewController : FlutterViewController
@end

@implementation FlutterHomeViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    // Declare a method channel
    FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"samples.chenhang/navigation" binaryMessenger:self];
    // Register method callbacks
    [channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
        // If the method is to open a new page
        if([call.method isEqualToString:@"openNativePage"]) {
            // Initialize and open the native page
            SomeOtherNativeViewController *vc = [[SomeOtherNativeViewController alloc] init];
            [self.navigationController pushViewController:vc animated:YES];
            result(@0);
        }
        // If the method is to close the Flutter page
        else if([call.method isEqualToString:@"closeFlutterPage"]) {
            // Close itself (FlutterHomeViewController)
            [self.navigationController popViewControllerAnimated:YES];
            result(@0);
        }
        else {
            result(FlutterMethodNotImplemented);// Other methods not implemented
        }
    }];
}
@end

The implementation code for the Android side is as follows:

// Extend AppCompatActivity as the container for Flutter
public class FlutterHomeActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // Initialize the Flutter container
        FlutterView flutterView = Flutter.createView(this, getLifecycle(), "defaultPage"); // Pass the route identifier
        // Register method channels
        new MethodChannel(flutterView, "samples.chenhang/navigation").setMethodCallHandler(
            new MethodCallHandler() {
                @Override
                public void onMethodCall(MethodCall call, Result result) {
                    // If the method name is "openNativePage"
                    if(call.method.equals("openNativePage")) {
                        // Create an Intent to open the native page
                        Intent intent = new Intent(FlutterHomeActivity.this, SomeNativePageActivity.class);
                        startActivity(intent);
                        result.success(0);
                    }
                    // If the method name is "closeFlutterPage"
                    else if(call.method.equals("closeFlutterPage")) {
                        // Finish self (Flutter container)
                        finish();
                        result.success(0);
                    }
                    else {
                        // The method is not implemented
                        result.notImplemented();
                    }
                }
            });
        // Replace flutterView with the contentView of the Activity
        setContentView(flutterView);
    }
}

After the method registration above, we can switch between the Flutter page and the native page in the Flutter layer using the openNativePage and closeFlutterPage methods, respectively.

In the example below, the root view DefaultPage in the Flutter container contains two buttons:

  • Clicking the button in the top-left will return to the native page using closeFlutterPage.
  • Clicking the button in the middle will open a new Flutter page, PageA. PageA also has a button, and clicking it will call openNativePage to open a new native page.
void main() => runApp(_widgetForRoute(window.defaultRouteName));
// Get the method channel
const platform = MethodChannel('samples.chenhang/navigation');

// Return the application entry view based on the route identifier
Widget _widgetForRoute(String route) {
  switch (route) {
    default:// Return the default view
      return MaterialApp(home:DefaultPage());
  }
}

class PageA extends StatelessWidget {
  ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
            body: RaisedButton(
                    child: Text("Go PageB"),
                    onPressed: ()=>platform.invokeMethod('openNativePage')// Open the native page
            ));
  }
}

class DefaultPage extends StatelessWidget {
  ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
            title: Text("DefaultPage Page"),
            leading: IconButton(icon:Icon(Icons.arrow_back), onPressed:() => platform.invokeMethod('closeFlutterPage')// Close the Flutter page
        )),
        body: RaisedButton(
                  child: Text("Go PageA"),
                  onPressed: ()=>Navigator.push(context, MaterialPageRoute(builder: (context) => PageA())),// Open the Flutter page PageA
        ));
  }
}

The code flow for the entire example of a hybrid navigation stack is shown in the following diagram. You can see how the entire example code is connected.

Figure 3: Example of a hybrid navigation stack

In our hybrid app, RootViewController and MainActivity are the native page entry points for the iOS and Android applications, respectively, and can be initialized as the Flutter container FlutterHomeViewController (iOS) and FlutterHomeActivity (Android).

After setting the initial route to DefaultPage, we can navigate to the Flutter page in a native way. However, Flutter does not provide an interface to return from the DefaultPage in Flutter to the native page. Therefore, we use method channels to register the closeFlutterPage method, allowing the Flutter container to receive this method call and close itself.

Inside the Flutter container, we can use Flutter’s internal page routing mechanism to navigate from DefaultPage to PageA using the Navigator.push method. When we want to navigate from the PageA in Flutter to a native page, we still need to use the method channel to register the openNativePage method, allowing the Flutter container to receive this method call. The native code host then initializes the native pages SomeOtherNativeViewController (iOS) and SomeNativePageActivity (Android) and completes the page navigation.

Summary #

Alright, that’s all for today’s presentation. Let’s summarize the main points we covered today.

For native Android and iOS project mixed with Flutter development, since the application will have Android, iOS, and Flutter pages at the same time, we need to handle the navigation between different rendering engines and solve the problem of switching from native pages to Flutter pages, as well as from Flutter pages to native pages.

When switching from a native page to a Flutter page, we usually wrap the Flutter container in a separate ViewController (iOS) or Activity (Android). After setting up the initial route (root view) for the Flutter container, the native code can open the Flutter page just like opening a normal native page.

If we want to navigate from a Flutter page to a native page, we need to handle both opening a new native page and closing the current Flutter container to go back to the previous native page. In both scenarios, we need to register relevant handling methods using method channels to implement opening a new page in the native code host and closing the Flutter container.

It’s important to note that unlike pure Flutter applications, in mixed projects where native and Flutter are combined, multiple Flutter containers (i.e., multiple Flutter instances) may exist in the navigation stack.

Initializing a Flutter instance is very resource-intensive. Every time a Flutter instance is started, a new rendering mechanism, called the Flutter Engine, and a lower-level Isolate are created. The memory between these instances is not shared, resulting in significant system resource consumption.

Therefore, in practical business development, we should try to develop closed-loop business modules using Flutter. Native code only needs to navigate to the Flutter module, and the rest of the business logic should be completed within Flutter. We should try to avoid scenarios where the Flutter page navigates back to a native page, and the native page starts a new Flutter instance.

To solve the problem of multiple Flutter instances in mixed projects, there are two solutions in the industry:

  • The solution represented by ByteDance’s modified Flutter Engine source code, which allows multiple FlutterView instances to share Isolates at the lower level.
  • The solution represented by Xianyu’s shared FlutterView, which involves hacking across rendering engines, including mechanisms for creating, caching, and garbage collecting Flutter pages. However, it may lead to rendering bugs, especially on low-end devices or when handling page transition animations.

To be honest, both of these solutions have their drawbacks:

  • The first solution involves modifying the Flutter source code, which not only increases the development and maintenance costs but also raises the probability of thread model and memory recycling exceptions, making it less stable.
  • The second solution involves cross-rendering engine hacks, including mechanisms for creating, caching, and garbage collecting Flutter pages. Therefore, it may introduce rendering bugs in some cases, such as on low-end devices or during page transition animations.
  • In addition, both of these solutions are closely tied to the internal implementation of Flutter, which means significant adaptation costs are required when dealing with Flutter SDK version upgrades.

Overall, both of these solutions are not perfect at the moment. Therefore, until Flutter officially supports multiple instances with a single engine, we should try to ensure that our application does not have multiple instances of Flutter containers at the product module level.

I have packaged the knowledge points covered in today’s presentation on GitHub (flutter_module_page, android_demo, iOS_demo). You can download them and run them multiple times to deepen your understanding and memory.

Thought Questions #

Finally, I’ll leave you with two thought questions.

  1. On the basis of the openNativePage method, please add a feature for page ID, which can support opening any native page in Flutter.
  2. In a mixed engineering project, there are two types of page transition animations: transitions between native pages and transitions between Flutter pages. Please think about how to ensure that these two types of page transition animations have consistent effects throughout the application.

Feel free to leave me a comment in the comments section to share your thoughts. I’ll be waiting for you in the next article! Thank you for listening, and feel free to share this article with more friends to read together.