44 How to Build Your Own Flutter Hybrid Development Framework Ii

44 How to Build Your Own Flutter Hybrid Development Framework II #

Hello, I’m Chen Hang.

In the previous article, I introduced the basic design principles to consider when designing a Flutter hybrid framework from the perspectives of project architecture and work mode, which include determining the boundary of responsibilities.

In terms of project architecture, since the Flutter module is a business dependency of the native project and its runtime environment is provided by the native project, we need to abstract them as dependencies of the corresponding technology stack and determine the boundary between them through layered dependencies.

In terms of work mode, considering that Flutter module development is upstream of native development, we only need to start from the process of building the Flutter module’s output, abstract the key and frequent nodes in the development process, and manage them in a unified way through the command line. The build output is the output of the Flutter module and the input of the native project. Once the output is built, we can integrate it into the native development workflow.

As you can see, in the Flutter hybrid framework, the Flutter module and the native project have a mutually dependent and mutually beneficial relationship:

  • Flutter has high cross-platform development efficiency, good rendering performance, and consistent multi-platform experience, so it mainly focuses on implementing the rendering loop of independent business (pages) at the application layer.
  • Native development has high stability, strong fine-grained control, and rich underlying capabilities, so it mainly focuses on providing the overall application architecture and stable runtime environment with corresponding underlying capability support for the Flutter module.

So, when providing underlying capability support for the Flutter module in the native project, how should we follow the principles in handling dependencies across different technology stacks? How should we package the Flutter module and its native plugins as standard native project dependencies?

In today’s article, I will explain the solution to these two questions through a typical case study.

Principles of Native Plugin Dependency Management #

In the previous articles 26 and 31, I discussed two ways to provide native capabilities support for Dart code in Flutter applications. The first is a lightweight solution where the Flutter application entry point is registered for native code callbacks in the native project. The second is an engineering decoupling solution using a plugin project for independent splitting and packaging.

Regardless of which method is used, the Flutter application project provides an integrated standard solution that can automatically manage the native code host and its corresponding native dependencies during integration building. Therefore, we only need to use the pubspec.yaml file to manage the Dart dependencies at the application level.

However, for hybrid projects, dependency management becomes more complex. This is because, unlike the simple and clear one-way dependency relationship of native components in Flutter application projects, the dependency relationship of native components in hybrid projects is multi-directional: the Flutter module project depends on native components, and the native components in the native project also depend on each other.

If we continue to let Flutter’s tools manage the dependency relationships of native components, the entire project will become unstable. Therefore, for the native dependencies of hybrid projects, the Flutter module does not intervene and instead delegates the management to the native project. The dependency of the Flutter module project on the native project is reflected in the native plugins that provide the underlying capabilities of the native code host.

Next, I will use network communication as an example to explain how the dependency relationship should be managed between the native project and the Flutter module project.

Network Plugin Dependency Management Practice #

In the 24th article “HTTP Networking and JSON Parsing,” I introduced to you the three ways of communication in Flutter, which are HttpClient, http, and dio, to exchange data with the server.

However, in hybrid engineering, considering that other native components also need to use network communication capabilities, it is usually the responsibility of the native project to provide network communication functionality. This not only enables more reasonable functional division at the architectural level, but also unifies the behavior of data exchange throughout the entire app. For example, common parameters can be added to interface requests in the network engine, or errors can be centrally intercepted.

As for native network communication functionality, there are currently many excellent third-party open-source SDKs on the market, such as AFNetworking and Alamofire for iOS, and OkHttp and Retrofit for Android. Considering the high level of activity in the communities of AFNetworking and OkHttp on their respective platforms, I will use these two as examples to demonstrate the management method of native plugins in hybrid engineering.

Networking Plugin Interface Encapsulation #

To understand how to manage native plugins, we first need to establish a connection between the Dart layer and the native code host using method channels.

Native projects provide native code capabilities for Flutter modules, and we need to use a Flutter plugin project for encapsulation. I have demonstrated the encapsulation method for push and data reporting plugins in articles 31 and 39, respectively. You can review the relevant content. So today, I won’t go into too much detail about the general process and fixed code declaration, but I will focus on the implementation details related to interfaces.

First, let’s take a look at the Dart code.

For the Dart layer code of the plugin project, since it is only a proxy for the native project, the interface design at this layer is relatively simple. We only need to provide a method doRequest that can accept a request URL and parameters and return the interface response data:

class FlutterPluginNetwork {
  ...
  static Future<String> doRequest(url,params)  async {
    // Use method channels to call the native interface `doRequest`, passing in the `URL` and `params` parameters
    final String result = await _channel.invokeMethod('doRequest', {
      "url": url,
      "param": params,
    });
    return result;
  }
}

With the Dart layer interface encapsulated, let’s see how the Android and iOS code hosts responsible for actual network calls respond to the Dart layer interface calls.

As I mentioned earlier, the basic communication capability provided by the native code host is encapsulated based on AFNetworking (iOS) and OkHttp (Android). Therefore, to use them in the native code, first, we need to explicitly declare the project’s dependencies on them in the flutter_plugin_network.podspec and build.gradle files respectively:

In the flutter_plugin_network.podspec file, declare the project’s dependency on AFNetworking:

Pod::Spec.new do |s|
  ...
  s.dependency 'AFNetworking'
end

In the build.gradle file, declare the project’s dependency on OkHttp:

dependencies {
    implementation "com.squareup.okhttp3:okhttp:4.2.0"
}

Then, we need to complete the routine tasks of initializing the plugin instance and binding the method channel in the native interface FlutterPluginNetworkPlugin class.

Finally, we need to retrieve the corresponding URL and query parameters from the method channel, and provide the AFNetworking and OkHttp implementation versions for doRequest.

For iOS, the AFNetworking network call object is the AFHTTPSessionManager class. Therefore, we need to instantiate this class and define the serialization method (in this case, it’s a string) for its interface returns. The remaining work is to use it to make network requests and use the method channel to notify the Dart layer of the execution result:

@implementation FlutterPluginNetworkPlugin
...
// Method channel callback
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
    // Respond to the `doRequest` method call
    if ([@"doRequest" isEqualToString:call.method]) {
        // Retrieve the query parameters and URL
        NSDictionary *arguments = call.arguments[@"param"];
        NSString *url = call.arguments[@"url"];
        [self doRequest:url withParams:arguments andResult:result];
    } else {
        // Other methods not implemented
        result(FlutterMethodNotImplemented);
    }
}
// Process network calls
- (void)doRequest:(NSString *)url withParams:(NSDictionary *)params andResult:(FlutterResult)result {
    // Initialize the network call instance
    AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
    // Define the data serialization method as a string
    manager.responseSerializer = [AFHTTPResponseSerializer serializer];
    NSMutableDictionary *newParams = [params mutableCopy];
    // Add custom parameters
    newParams[@"ppp"] = @"yyyy";
    // Make the network call
    [manager GET:url parameters:params progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id  _Nullable responseObject) {
        // Retrieve the response data and respond to the Dart call
        NSString *string = [[NSString alloc] initWithData:responseObject encoding:NSUTF8StringEncoding];
        result(string);
    } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
        // Notify Dart of the call failure
        result([FlutterError errorWithCode:@"Error" message:error.localizedDescription details:nil]);
    }];
}
@end

The Android implementation is similar. The OkHttp network call object is the OkHttpClient class, so we also need to instantiate this class. The default serialization method of OkHttp is already a string, so we don’t need to do anything. We just need to format the URL parameters correctly for OkHttp and then use it to make network requests and use the method channel to notify the Dart layer of the execution result:

public class FlutterPluginNetworkPlugin implements MethodCallHandler {
  ...
  @Override
  // Method channel callback
  public void onMethodCall(MethodCall call, Result result) {
    // Respond to the `doRequest` method call
    if (call.method.equals("doRequest")) {
      // Retrieve the query parameters and URL
      HashMap param = call.argument("param");
      String url = call.argument("url");
      doRequest(url,param,result);
    } else {
      // Other methods not implemented
      result.notImplemented();
    }
  }
  // Process network calls
  void doRequest(String url, HashMap<String, String> param, final Result result) {
    // Initialize the network call instance
    OkHttpClient client = new OkHttpClient();
    // Format the URL and query parameters
    HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder();
    for (String key : param.keySet()) {
      String value = param.get(key);
      urlBuilder.addQueryParameter(key,value);
    }
    // Add common custom parameters
    urlBuilder.addQueryParameter("ppp", "yyyy");
    String requestUrl = urlBuilder.build().toString();

    // Make the network call
    final Request request = new Request.Builder().url(requestUrl).build();
    client.newCall(request).enqueue(new Callback() {
      @Override
      public void onFailure(Call call, final IOException e) {
        // Switch to the main thread and notify Dart of the call failure
        registrar.activity().runOnUiThread(new Runnable() {
          @Override
          public void run() {
            result.error("Error", e.toString(), null);
          }
        });
      }
      
      @Override
      public void onResponse(Call call, final Response response) throws IOException {
        // Retrieve the response data
        final String content = response.body().string();
        // Switch to the main thread and respond to the Dart call
        registrar.activity().runOnUiThread(new Runnable() {
            @Override
            public void run() {
              result.success(content);
            }
        });
      }
    });
  }
}

It should be noted that since method channels are not thread-safe, all interface calls between native code and Flutter must occur on the main thread. When OkHttp processes the network request, because it involves non-main thread switching, we need to call the runOnUiThread method to ensure that the callback process is executed in the UI thread. Otherwise, the application may experience strange bugs or even crashes.

Some of you may wonder, why do we need to manually switch back to the UI thread in the Android implementation of doRequest, but not in the iOS implementation? This is because the iOS implementation of doRequest is dependent on AFNetworking, which already switches to the UI thread for us in the data callback interface, so we naturally don’t need to do it again.

After completing the encapsulation of the native interfaces, the implementation of the network communication functionality required by the Flutter project is complete.

Flutter Module Project Dependency Management #

By following the above steps, we have provided the encapsulation of native network functionality in the form of a plugin. Next, we need to use this plugin in the Flutter module project and provide the corresponding build artifacts for use in the native project. This section mainly includes the following three parts:

  • First, how to use the FlutterPluginNetworkPlugin plugin, that is, how to implement the functionality of the module project;
  • Second, how to package the iOS build artifacts of the module project, that is, how the native iOS project manages the dependencies of the Flutter module project;
  • Third, how to package the Android build artifacts of the module project, that is, how the native Android project manages the dependencies of the Flutter module project.

Next, let’s take a closer look at how each part should be implemented.

Implementation of Module Engineering Functions #

In order to use the FlutterPluginNetworkPlugin to achieve the ability to exchange data with the server, we first need to explicitly declare the dependency of the project on it in the pubspec.yaml file:

flutter_plugin_network:
    git:
      url: https://github.com/cyndibaby905/44_flutter_plugin_network.git

Next, we need to provide a trigger entry point for it in the main.dart file. In the code below, we display a RaisedButton button on the UI, and when its onPress callback function is triggered, we use the FlutterPluginNetworkPlugin to make a network interface call and print the returned data to the console:

RaisedButton(
  child: Text("doRequest"),
  //Click the button to initiate a network request and print the data
  onPressed:()=>FlutterPluginNetwork.doRequest("https://jsonplaceholder.typicode.com/posts", {'userId':'2'}).then((s)=>print('Result:$s')),
)

Run this code, click the doRequest button, and observe the console output. You can see that the returned data from the interface can be printed correctly, proving that the functionality of the Flutter module is working as expected.

Figure 1: Example of running the Flutter module project

How should the build artifacts be packaged? #

We all know that the Android build artifact of a module project is an AAR, while the iOS build artifact is a Framework. In the 28th and 42nd articles, I introduced two packaging solutions for module project build artifacts without plugin dependencies: manual packaging and automated packaging. Both of these packaging solutions ultimately result in the same organization (AAR for Android and Framework with a podspec for iOS).

If you are not familiar with the specific steps of these two packaging methods, you can review the relevant content of these two articles. Now, let’s focus on the question: Is there any difference in the packaging process if our module project has plugin dependencies?

The answer is that there is no difference in the process for the module project itself, but for the plugin dependencies of the module project, we need to actively inform the native project which dependencies it needs to manage.

Since the Flutter module project delegates all native dependencies to the native project for management, the build artifact of the Flutter module project does not contain any native plugin packaging implementation. Therefore, we need to traverse the native dependencies used by the module project, and generate native component wrappers for their corresponding plugin code one by one.

In the 18th article “Dependency Management (2): How to Manage Third-Party Libraries in Flutter?”, I introduced the implementation mechanism of managing third-party dependencies in a Flutter project. The .packages file stores the package names and the file paths of the cached packages in the system.

Similarly, there is a similar file for managing plugin dependencies, namely .flutter-plugins. We can use this file to find the corresponding plugin name (in this example, it is flutter_plugin_network) and its cache path:

flutter_plugin_network=/Users/hangchen/Documents/flutter/.pub-cache/git/44_flutter_plugin_network-9b4472aa46cf20c318b088573a30bc32c6961777/

The plugin cache itself can also be considered as a Flutter module project, so we can use a similar approach to generate the corresponding native component wrappers for it.

For iOS, this process is relatively simple, so let’s first look at the packaging process for the iOS build artifact of the module project.

How should the iOS build artifact be packaged? #

In the ios directory of the plugin project, there is a source component with a podspec file, which provides the declaration (and its dependencies) of the component. Therefore, we can copy the files in this directory together with the Flutter module component and place them in a dedicated directory in the native project, and write them into the Podfile.

The native project will recognize the component itself and its dependencies, and install them automatically according to the declared dependency relationship:

# Podfile
target 'iOSDemo' do
  pod 'Flutter', :path => 'Flutter'
  pod 'flutter_plugin_network', :path => 'flutter_plugin_network'
end

Then, we can use the module project with plugin dependencies in the same way as the module project without plugin dependencies, import it into the native project, and set it as the entry point to display the Flutter module’s pages in the FlutterViewController.

However, it should be noted that since the FlutterViewController is not aware of this process, it will not initialize the plugins in the project by default. Therefore, we still need to manually declare all the plugins in the project at the entry point:

// AppDelegate.m
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
    // Initialize the Flutter entry point
    FlutterViewController *vc = [[FlutterViewController alloc]init];
    // Initialize the plugins
    [FlutterPluginNetworkPlugin registerWithRegistrar:[vc registrarForPlugin:@"FlutterPluginNetworkPlugin"]];
    // Set the route identifier
    [vc setInitialRoute:@"defaultRoute"]; 
    self.window.rootViewController = vc;
    [self.window makeKeyAndVisible];
    return YES;
}

Running this code in Xcode, and clicking the “doRequest” button, we can see that the returned data is printed correctly, proving that we can successfully use the Flutter module in the native iOS project.

Figure 2: Example of running a native iOS project

Now let’s take a look at how the Android build artifact of the module project should be packaged.

How to package Android build artifacts? #

Just like the iOS plugin project components are located in the ios directory, the Android plugin project components are located in the android directory. For iOS plugin projects, we can directly provide the source code components to the native project. However, for Android plugin projects, we can only provide the aar (Android Archive) components to the native project. Therefore, we not only need to enter the component directory of the plugin like the steps for iOS, but we also need to use the build command to generate the aar for the plugin project:

cd android
./gradlew flutter_plugin_network:assRel

After the command is executed, the aar is generated. The aar is located in the android/build/outputs/aar directory. We open the corresponding path of the plugin cache and extract the corresponding aar file (in this example, it is flutter_plugin_network-debug.aar).

We put the generated plugin aar, along with the Flutter module aar, in the libs directory of the native project. Finally, we explicitly declare it in the build.gradle file to import the plugin project.

//build.gradle
dependencies {
    ...
    implementation(name: 'flutter-debug', ext: 'aar')
    implementation(name: 'flutter_plugin_network-debug', ext: 'aar')
    implementation "com.squareup.okhttp3:okhttp:4.2.0"
    ...
}

Then, we can set it as the entry point in the native project and display the Flutter page in the FlutterView, happily using the efficient development and high-performance rendering capabilities provided by the Flutter module:

//MainActivity.java
public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        View FlutterView = Flutter.createView(this, getLifecycle(), "defaultRoute"); 
        setContentView(FlutterView);
    }
}

However, it should be noted that, unlike the podspec of iOS plugin projects, the aar package of Android plugin projects itself does not carry any configuration information. Therefore, if the plugin project itself has native dependencies (such as flutter_plugin_network depends on OkHttp), we cannot use the aar to inform the native project of its native dependencies.

In this case, we need to manually declare the plugin project’s dependency (i.e., OkHttp) in the build.gradle file of the native project.

//build.gradle
dependencies {
    ...
    implementation(name: 'flutter-debug', ext: 'aar')
    implementation(name: 'flutter_plugin_network-debug', ext: 'aar')
    implementation "com.squareup.okhttp3:okhttp:4.2.0"
    ...
}

Thus, all the work of packaging the module project and its plugin dependencies as native components is completed. The native project can use the functionality of the Flutter module component just like using a normal native component.

Run this code in Android Studio and click the “doRequest” button. You will see that we can use the Flutter wrapper page component in the native Android project.

Native Android project running example

Figure 3: Native Android project running example

Of course, considering the cumbersome and error-prone process of manually packaging module projects and their build artifacts, we can abstract these steps into a command-line script and deploy it on Travis. This way, when Travis detects code changes, it will automatically package the build artifacts of the Flutter module into the desired native project component format.

Regarding this part, you can refer to the implementation in the generate_aars.sh and generate_pods.sh scripts in my flutter_module_demo. If you have any questions about this part, you can leave a comment for me directly.

Summary #

Alright, that’s all for the dependency management part of Flutter hybrid development framework. Next, let’s summarize the main content of today together.

The encapsulated form of native components in Flutter module projects is aar (for Android) and Framework (for Pod). Unlike pure Flutter application projects that can automatically manage native dependencies for plugins, this work is completely handed over to the native projects in module projects for management. Therefore, we need to search for the .flutter-plugins file that records the mapping relationship between plugin names and cache paths, extract the native component encapsulation corresponding to each plugin, and integrate it into the native project.

From today’s sharing, it can be seen that for Android component encapsulation with plugin dependencies, since the aar itself does not carry any configuration information, manual operations are mainly required: not only do we need to execute build commands to generate aar corresponding to each plugin, but we also need to copy the native dependencies of the plugin to the native project. The steps are relatively more cumbersome compared to the encapsulation of iOS components.

To solve this problem, the industry has introduced a packaging method called fat-aar, which can package the module project itself and its related plugin dependencies into a single big aar, thus eliminating the process of dependency traversal and declaration, and achieving better functional autonomy. However, this solution has some obvious drawbacks:

  • Dependency conflict. If both the native project and the plugin project reference the same native dependency component (OkHttp), there will be merge conflicts when the native project references its dependency, so the component dependency of the native project must be manually removed before release.
  • Nested dependency. Fat-aar will only handle the immediate level of dependencies pointed to by the “embedded” keyword, and will not handle dependencies in the next level. Therefore, for plugins with complex dependency relationships, we still need to manually handle dependency issues.
  • Gradle version limitations. The fat-aar solution has limitations on the Gradle plugin version, and its implementation is not a point considered in the official design. Coupled with the rapid changes of Gradle API, there may be difficulties in subsequent maintenance.
  • Other unknown issues. The fat-aar project is no longer maintained, and the last update was 2 years ago. Using a “long-unmaintaned” project in practical projects carries significant risks.

Considering these factors, fat-aar is not a good solution for managing plugin project dependencies, so it is best for us to traverse the plugin dependencies and generate aar automatically in a continuous delivery manner.

I have packaged the knowledge points related to today’s sharing and uploaded them to GitHub. You can download the plugin project, Flutter module project, native Android project, and iOS project. Feel free to check the build execution commands in the Travis continuous delivery configuration files to understand how to manage cross-technology-stack component dependencies in hybrid frameworks.

Thought-provoking question #

Finally, I will leave you with a thought-provoking question.

The development of native plugins is a long process that requires encapsulating Dart layer code and implementing native Android and iOS code. If there are many basic capabilities to support, the process of developing plugins can become tedious and error-prone. It is known that Dart does not support reflection, but native code does. Can we use native reflection to implement standardization defined by plugins?

Hint: When calling a non-existent interface (or unimplemented interface) in the Dart layer, it can be handled uniformly using the noSuchMethod method.

class FlutterPluginDemo {
  // Method channel
  static const MethodChannel _channel =
      const MethodChannel('flutter_plugin_demo');

  // When a non-existent interface is called, it will be handled by this method in Dart
  @override
  Future<dynamic> noSuchMethod(Invocation invocation) {
    // Extract the method name from the string Symbol("methodName")
    String methodName = invocation.memberName.toString().substring(8, string.length - 2);
    // Arguments
    dynamic args = invocation.positionalArguments;
    print('methodName:$methodName');
    print('args:$args');
    return methodTemplate(methodName, args);
  }
  
  // An unimplemented method
  Future<dynamic> someMethodNotImplemented();
  
  // An unimplemented method with parameter
  Future<dynamic> someMethodNotImplementedWithParameter(param);
}

Feel free to leave a comment in the comment section to share your thoughts. I will 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.