DEV Community

Cover image for The Ultimate Showdown: MethodChannel vs. JNI - Unraveling the Secrets of Native Integration in Flutter
Marco Domingos
Marco Domingos

Posted on

The Ultimate Showdown: MethodChannel vs. JNI - Unraveling the Secrets of Native Integration in Flutter

We recently had Google I/O 2023 and with them we received incredible news for Flutter, in one of them, we were introduced to a new approach to System Interoperability with Native Android, more specific: JNIgen.

What is JNIgen?

For developers familiar with Java, the name JNI is not really new, JNI **being a **Java Native Interface that allows Java/Kotlin code to interact with written native code in C/C++ and even Assembly in some cases.

In a more visual way, in the image below we can see that the JNI works as a bridge between the codes:
JNI Bridge

Therefore, JNIgen follows the same principle plus the generator, that is, this Dart Interoperability tool was designed to generate all the necessary bindings by analyzing the java code, generating the native code in C/C++ and the bindings in Dart, all this automatically.

Ok, but why use JNIgen and not just use MethodChannel?

To answer this question, we first need to see the differences between each in practice.

For this example, we will use the ML Kit Google Code Scanner API. This should be the end result:
GIF of final result

So we'll do it both ways: MethodChannel and JNIgen.

1 - MethodChannel

There is no need to define these basic steps:

  1. Creating a flutter project(Read more here)
  2. Adding the necessary gradle dependencies for the ML Kit API (Read more here )

After following the basic steps, this should be your MainActivity:

class MainActivity: FlutterActivity() {

  override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)
  }
}
Enter fullscreen mode Exit fullscreen mode

I used Kotlin as my native language, for some it might look different(More here)

Let's continue.

Steps:

1 - Creating the communication channel:
Now we must define the name of our channel like this:

private val CHANNEL = "com.example.com/Scanner"
Enter fullscreen mode Exit fullscreen mode

Then we need to define the function responsible for the handler that the method will call, like this:

MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
      call, result ->
      // This method is invoked on the main thread.
      // TODO
    }
Enter fullscreen mode Exit fullscreen mode

In the end, the code will look like this;

class MainActivity: FlutterActivity() {
private val CHANNEL = "com.example.com/Scanner"

  override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)
    MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
      call, result ->
      // This method is invoked on the main thread.
      // TODO
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

2- Adding barcode function:
Now, if you've read the ML Kit guide above, the code below is self-explanatory:

val scanner = GmsBarcodeScanning.getClient(this)
scanner.startScan()
    .addOnSuccessListener { barcode ->
        // Task completed successfully
    }
    .addOnCanceledListener {
        // Task canceled
    }
    .addOnFailureListener { e ->
        // Task failed with an exception
    }
Enter fullscreen mode Exit fullscreen mode

Well, we know that the response we will get from this method is not synchronous, so we will launch this method inside a new Thread so that we can handle the response:

val scanner = GmsBarcodeScanning.getClient(this)
object : Thread() {
scanner.startScan()
    .addOnSuccessListener { barcode ->
        // Task completed successfully
    }
    .addOnCanceledListener {
        // Task canceled
    }
    .addOnFailureListener { e ->
        // Task failed with an exception
    }
}.start()
Enter fullscreen mode Exit fullscreen mode

Now we can get the response and return it as a response from the channel we created:

class MainActivity: FlutterActivity() {
private val CHANNEL = "com.example.com/Scanner"

  override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)
    MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
      call, result ->
      if (call.method == "getBarCode") {
                val scanner = GmsBarcodeScanning.getClient(this)
                object : Thread() {
                    override fun run() {
                        scanner.startScan()
                            .addOnSuccessListener { barcode ->
                                result.success(barcode.rawValue)
                            }
                            .addOnCanceledListener { ->
                                result.success("")
                            }
                            .addOnFailureListener { e ->
                                result.error("Exception", "Found Exception", e)
                            };
                    }
                }.start()
            }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Almost forgot, getBarCode is the method we'll call to access the Barcode Scanner from our dart code.

3- Configuring the communication Dart Channel:
You probably got a main.dart file with a default example code, you can remove any unnecessary code, for this case we will work with the StatefulWidget class, first let's define the methodChannel with the same channel name as we gave it in our native code:

static const platform = MethodChannel('com.example.com/Scanner');
Enter fullscreen mode Exit fullscreen mode

So let's create a method that will handle the communication, we can call this function _getBarCode, this method must be a Future, as we mentioned before, the scanner is not synchronous, so the return will not be immediate:

Future<void> _getScanner() async {}
Enter fullscreen mode Exit fullscreen mode

Within this method we have the code responsible for calling and handling the response from the channel:

await platform.invokeMethod('getBarCode');
Enter fullscreen mode Exit fullscreen mode

Inside the invokeMethod we pass as an argument the method that we added in the native code that calls the Barcode function. Now, we know that the method will return a string with the code value we read, so we can wrap it in a String:

String scanned = await platform.invokeMethod('getBarCode');
Enter fullscreen mode Exit fullscreen mode

Now we must wrap this inside a function and we must add a way to handle any exceptions that may occur, in the end this should be the end result:

void _getScanner() async {
  String? scanned;
  try {
    scanned = await platform.invokeMethod('getBarCode');
  } on PlatformException catch (e) {
    scanned = "";
  }

  setState(() {
    result = scanned ?? "";
  });
}
Enter fullscreen mode Exit fullscreen mode

Then we can call this function in our code, which should look like this:

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyScannerPage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyScannerPage extends StatefulWidget {
  const MyScannerPage({super.key, required this.title});

  final String title;

  @override
  State<MyScannerPage> createState() => _MyScannerPageState();
}

class _MyScannerPageState extends State<MyScannerPage> {
  static const platform = MethodChannel('com.example.com/Scanner');
  String result = "";

  void _getScanner() async {
    String? scanned;
    try {
      scanned = await platform.invokeMethod('getBarCode');
    } on PlatformException catch (e) {
      scanned = "";
    }

    setState(() {
      result = scanned ?? "";
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text(
              "ScanResult: ",
              style: TextStyle(
                color: Colors.black,
                fontWeight: FontWeight.bold,
              ),
            ),
            Text(
              result,
              style: const TextStyle(color: Colors.black),
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          _getScanner();
        },
        tooltip: 'Scan',
        child: const Icon(Icons.qr_code_scanner),
      ), 
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

And with that we arrive at the result we were looking for:
Image description

2- JNIGen

Now, let's get the same result but with a different approach, again, no need to explain the basic steps:

  1. Creating a flutter project(Read more here)
  2. Adding the necessary gradle dependencies for the ML Kit API (Read more here )

Steps:

1- Creating a class with scanner code
As with MethodChannel, you'll create a native class with the code that will handle the QrCode scan, something like this:

class Scanner {
    suspend fun getCode(context: Context): String? {
        val scanner = GmsBarcodeScanning.getClient(context)
        return suspendCoroutine<String?> { continuation ->
            scanner.startScan()
                .addOnSuccessListener { barcode ->
                    continuation.resume(barcode.rawValue)
                }
                .addOnCanceledListener {
                    continuation.resume("")
                }
                .addOnFailureListener { e ->
                    continuation.resumeWithException(e)
                }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The difference here is that we can do this in a normal class, normal function without the need for a channel or a handler.

2- Adding JNIgen to the project
To work with JNIgen, we need to add 2 libs to our pubspec.yaml, we can do this with just a few commands:

flutter pub add jni dev:jnigen
Enter fullscreen mode Exit fullscreen mode

This will add jni as a dependency and jnigen as a dev_dependence.

3- Creating the configuration file
Now that we have the JNIgen dependency in our project, we can define the configuration we want, for this we need to create a new yaml file in the root of the project, you can define the name whatever you want, like *jnigen.yaml *, inside this file we would have the code below:

android_sdk_config:
  # The add_gradle_deps: true property allow the bindings to compile the Android dependencies and classpath to the jnigen
  add_gradle_deps: true

  # The suspend_fun_to_async: true property is pretty explanatory itself, but to resume, it will convert Kotlin suspend functions to Dart async functions
suspend_fun_to_async: true

output:
  # In the output we set two languages, the first will be native code in "c"  and the other will be the one we use in Flutter, meaning "Dart"
  c:
    # In this property you will set the name of the library we are creating, this way jnigen will set the generated code according to this name.
    library_name: scanner
    # For this property, you first need to create this path in your project, it will the path were the generated native code will be deployed.
    path: src/scanner/
  dart:
    # For this property, you first need to create this path in your project, it will the path were the generated bindings code will be deployed.
    path: lib/scanner.dart
    # This set the structure of the bindings that will be generated in dart, it can be file-per-class(package_structure) or all wrapped in a single file(single_file)
    structure: single_file

  # In this property we set the directory to the class we will need
source-path:
  - 'android/app/src/main/kotlin/ao/marco_domingos/scanner_api_example'

  # In this property we set the exact class we will need, we set it with his package_name only this way the jnigen will be able to find it
classes:
  - 'ao.marco_domingos.scanner_api_example.Scanner'
Enter fullscreen mode Exit fullscreen mode

In this file you can read the comments to understand all its properties.

4- Generating the bindings
Now that we have the configuration file ready, all that remains is to generate the bindings, we can do it with this command:

dart run jnigen --config jni.yaml
Enter fullscreen mode Exit fullscreen mode

But, jnigen needs to scan compiled jar files to generate the bindings, so you might get an error if you just run the above command without a compiled jar file, so first you need to run the below command and only after the above:

flutter build apk
Enter fullscreen mode Exit fullscreen mode

If everything worked fine, you can check the path you set in the configuration file and you will find all the generated bindings.

5- Adding generated native code
Now we need to change our app/build.gradle with the following line of code inside the android property:

externalNativeBuild {
    cmake {
        path "../../src/scanner/CMakeLists.txt"
    }
}
Enter fullscreen mode Exit fullscreen mode

This will add the CMakeList file that was generated by JNIgen into our build.gradle.

6- Calling native function getCode
Now we need to call our native function, but before that we need to initialize the JNI communication channel between Dart and native code, we can do this by adding the code below to our main:

Jni.initDLApi();
Enter fullscreen mode Exit fullscreen mode

Now we can call our native function like this:

  void _getScanner() async {
    String? scanned;
    try {
      final scanner = await Scanner().getCode(JObject.fromRef(Jni.getCachedApplicationContext()));
      scanned = scanner.toDartString();
    } catch (e) {
      scanned = "";
    }

    setState(() {
      result = scanned ?? "";
    });
  }
Enter fullscreen mode Exit fullscreen mode

Looking at our function, we can see that we can call the native class and function directly from our Dart code without having to define a channel and method as we would have to with MethodChannel. And when we run it, the result is:
Image description
Exactly what we wanted!

If we go to see both codes, we can see that in both we use 50% Kotlin and 50% Dart, but the JNI facilitates the interoperation between the native code that we have and the dart without the need to create a channel, a platform to manipulate it, calling native code just like other Dart code. If that doesn't convince you, then let's talk about Game Changer.

Game Changer

So with JNIgen we have a game changer, in MethodChannel we can't reduce the % of native code we use to work with a native Feature, we can just increase it, but with JNIgen we can reduce, like now , I'll just use 10% Kotlin and 90% Dart to get the same result.

First, we can go to our native class Scanner.kt and remove any traces of it and we will have our class like this:

class Scanner {}
Enter fullscreen mode Exit fullscreen mode

Okay, now let's just add a small function to handle a task and convert it to a suspend function:

class Scanner {
    suspend fun <T> Task<T>.await(): T {
        return suspendCancellableCoroutine { continuation ->
            addOnSuccessListener { result ->
                continuation.resume(result)
            }

            addOnFailureListener { exception ->
                continuation.resumeWithException(exception)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now you might ask: If there is no native code to call the scanner, how can we scan your code?

Well, this is where the magic starts, let's go back to our jnigen configuration file and add some classes responsible for accessing the Scanner:

classes:
  - 'ao.marco_domingos.scanner_api_example.Scanner'
  - 'com.google.mlkit.vision.codescanner.GmsBarcodeScanning'
  - 'com.google.mlkit.vision.codescanner.GmsBarcodeScanner'
  - 'com.google.mlkit.vision.barcode.common.Barcode'
  - 'com.google.mlkit.vision.barcode.common.internal.BarcodeSource'
  - 'com.google.android.gms.tasks.Task'
Enter fullscreen mode Exit fullscreen mode

Now, remember that jnigen only generates the bindings according to a compiled jar file, we already ran flutter build apk in the beginning, but we made changes in our native code, so we need to run flutter build apk ** again to compile the latest changes in the jar file and only then run **dart run jnigen --config jnigen.yaml again to update the bindings.

After finishing this part let's go to what really matters, our function and change our old code to this one:

  void _getScanner() async {
    String? scanned;
    try {
      final scanner = GmsBarcodeScanning.getClient(JObject.fromRef(Jni.getCachedApplicationContext()));
      final result = await Scanner().await0(scanner.startScan());
      scanned = result.getRawValue().toDartString();
    } catch (e) {
      scanned = "";
    }

    setState(() {
      result = scanned ?? "";
    });
  }
Enter fullscreen mode Exit fullscreen mode

Looking at this code you can see that the code we had in our native Kotlin class is now in our Dart function and the only code we have in our native class is the function that converts the scanner task to a suspend function which is converted to an async function in our Dart code as defined in the config file.

Now you might ask Does it work?

Here is your answer:
Image description

This is the next level of Dart interop tools, remembering that JNIgen is still actively under development, this is what the tool can do in its early stages, what will the tool be able to do in the later stages? Perhaps we can develop features with 0% native code, which is actually already possible with some API's and JNIgen.

You can see all the code used in this article in here

And if you are curious about the link that is returned when reading the QRCode, here it is:

Hope you enjoyed the reading.

Follow my profile for more articles and interesting projects:
Github
LinkedIn

Top comments (0)