The Ultimate Showdown: MethodChannel vs. JNI - Unraveling the Secrets of Native Integration in Flutter
Marco Domingos
Posted on June 14, 2023
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:
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:
So we'll do it both ways: MethodChannel and JNIgen.
1 - MethodChannel
There is no need to define these basic steps:
- Creating a flutter project(Read more here)
- 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)
}
}
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"
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
}
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
}
}
}
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
}
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()
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()
}
}
}
}
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');
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 {}
Within this method we have the code responsible for calling and handling the response from the channel:
await platform.invokeMethod('getBarCode');
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');
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 ?? "";
});
}
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),
),
);
}
}
And with that we arrive at the result we were looking for:
2- JNIGen
Now, let's get the same result but with a different approach, again, no need to explain the basic steps:
- Creating a flutter project(Read more here)
- 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)
}
}
}
}
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
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'
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
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
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"
}
}
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();
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 ?? "";
});
}
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:
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 {}
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)
}
}
}
}
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'
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 ?? "";
});
}
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?
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
Posted on June 14, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.