Plugin: Communicating with Native Code for Efficient Flutter App Maintenance
In many scenarios, accessing native operating system APIs is necessary to take advantage of functionalities that are not available in Flutter. To achieve this, we can create a plugin to communicate with native code using MethodChannel. This plugin allows Flutter to send requests to native code and receive responses from iOS and Android.
Of course, you can implement this directly in the native code of your project. However, separating it into a plugin makes maintaining and fixing the application when issues arise significantly easier.
Below, I will explain how I create a plugin for my applications. Let me know if you'd like further details or examples!
Overview of Creating a Plugin
Create a Flutter Plugin Library
Run the following command to create a new plugin:
flutter create --template=plugin your_plugin
Ensure that your plugin avoids, or minimizes as much as possible, the use of third-party libraries. This approach helps to maintain simplicity and reduces external dependencies. Let me know if you'd like guidance on the next steps!
Plugin Structure
The folder structure of a Flutter plugin is as follows:
In the file src/your_plugin_function.dart, define the communication between Dart and native code. Ensure that all the necessary files are exported in your_plugin.dart. This way, you can simply import the plugin library once when using it in your project, improving maintainability and clarity. Let me know if you'd like an example of defining the communication!
Create a Method for the Plugin
To call native code, you need to use a channel. Through this channel, you can invoke native functions and receive results.
Create TalkingWithFlutter.java: Handles data transmission between Flutter and native Android through MethodChannel.
package com.example.your_plugin;
import android.os.Handler;
import android.os.Looper;
import java.util.HashMap;
import io.flutter.plugin.common.MethodChannel;
public class TalkingWithFlutter {
TalkingWithFlutter(Utils utils) {
this.utils = utils;
}
public MethodChannel methodChannel;
public void remove() {
methodChannel.setMethodCallHandler(null);
methodChannel = null;
}
private final Utils utils;
// Send data back to Flutter plugin
public void invokeMethodUIThread(final String method, HashMap<String, Object> data) {
new Handler(Looper.getMainLooper()).post(() -> {
// Could already be torn down at this moment
if (methodChannel != null) {
methodChannel.invokeMethod(method, data);
} else {
utils.log("invokeMethodUIThread: tried to call method on closed channel: " + method);
}
});
}
public static final int WORKING = 0;
public static final int FREE = 1;
public void onListenStateChanged(int state) {
HashMap<String, Object> map = new HashMap<>();
map.put("state", state);
invokeMethodUIThread("OnListenStateChanged", map);
}
}
Translation:
This function is used to send data from native code to Flutter. It can be very useful for listening to data, such as over Bluetooth.
public void onListenStateChanged(int state) {
HashMap<String, Object> map = new HashMap<>();
map.put("state", state);
invokeMethodUIThread("OnListenStateChanged", map);
}
In the YourPlugin.java file: The main plugin handles methods called from Flutter and communicates with TalkingWithFlutter.
package com.example.your_plugin;
public class YourPlugin implements FlutterPlugin, MethodCallHandler, PluginRegistry.ActivityResultListener {
/**
* Step 1: Setup method
*/
private Context context;
private ActivityPluginBinding activityBinding;
private final Utils utils = new Utils();
private final TalkingWithFlutter talkingWithFlutter = new TalkingWithFlutter(utils);
@Override
public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) {
context = flutterPluginBinding.getApplicationContext();
talkingWithFlutter.methodChannel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "{{name.snakeCase()}}/methods");
talkingWithFlutter.methodChannel.setMethodCallHandler(this);
}
@Override
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
context = null;
talkingWithFlutter.remove();
}
@Override
public boolean onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
return false;
}
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
switch (call.method) {
case "exTalk": {
exTalk(call, result);
break;
}
default:
result.notImplemented();
break;
}
}
private void exTalk(@NonNull MethodCall call, @NonNull Result result) {
HashMap<String, Object> data = call.arguments();
int copies = (int) data.get("ex_message");
utils.log(copies + "");
talkingWithFlutter.onListenStateChanged(TalkingWithFlutter.FREE);
result.success(TalkingWithFlutter.WORKING);
}
}
This code processes the data sent from Flutter, executes it, and returns the result to Flutter.
private void exTalk(@NonNull MethodCall call, @NonNull Result result) {
HashMap<String, Object> data = call.arguments();
int copies = (int) data.get("ex_message");
utils.log(copies + "");
talkingWithFlutter.onListenStateChanged(TalkingWithFlutter.FREE);
result.success(TalkingWithFlutter.WORKING);
}
Create a Utils.java file in android/src/main/kotlin/com/example/your_plugin:
package com.example.your_plugin;
import android.util.Log;
public class Utils {
private static final String TAG = "[YourPlugin-Android]";
void log(String message) {
Log.d(TAG, message);
}
}
Create Native Code for iOS
Similar to Android, create a Utils.swift file in the ios/Classes directory.
Then, create TalkingWithFlutter.swift: This handles data transmission between Flutter and native iOS through MethodChannel.
import Flutter
import Foundation
import CoreBluetooth
class TalkingWithFlutter : NSObject{
// Shared instance
static let shared = TalkingWithFlutter()
override private init() {
super.init()
}
deinit {
}
init(methodChannel: FlutterMethodChannel? = nil) {
self.methodChannel = methodChannel
}
var methodChannel: FlutterMethodChannel?
let WORKING = 0
let FREE = 1
func onListenStateChanged(state: Int) {
let response = [
"state": state,
]
if methodChannel != nil {
methodChannel!.invokeMethod("OnListenStateChanged", arguments: response)
Utils.log("OnListenStateChanged \(response)")
}
}
}
Explanation of Functionality:
This function is used to send data from native code to Flutter. It is particularly useful when listening to data, such as via Bluetooth.
In the YourPlugin.swift file: This main plugin processes the methods called from Flutter and communicates with TalkingWithFlutter.
import Flutter
import UIKit
public class YourPlugin: NSObject, FlutterPlugin {
// MARK: Step 1: Setup method
var talking = TalkingWithFlutter.shared
public static func register(with registrar: FlutterPluginRegistrar) {
let instance = YourPlugin()
instance.talking.methodChannel = FlutterMethodChannel.init(name: "{{name.snakeCase()}}/methods", binaryMessenger: registrar.messenger())
registrar.addMethodCallDelegate(instance, channel: instance.talking.methodChannel!)
}
// FlutterResult returns a result
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
Utils.log(call.method)
switch call.method {
case "exTalk":
exTalk(call: call, result: result)
default:
result(FlutterMethodNotImplemented)
}
}
func exTalk(call: FlutterMethodCall, result: @escaping FlutterResult) {
let args = call.arguments as? NSDictionary
let copies = args?["ex_message"] as? Int
Utils.log("copies \(copies) ")
talking.onListenStateChanged(state: talking.FREE)
result(NSNumber(value: talking.WORKING))
}
}
Explanation:
This code receives data sent from Flutter, processes it, and sends the result back to Flutter.
func exTalk(call: FlutterMethodCall, result: @escaping FlutterResult) {
let args = call.arguments as? NSDictionary
let copies = args?["ex_message"] as? Int
Utils.log("copies \(copies) ")
talking.onListenStateChanged(state: talking.FREE)
result(NSNumber(value: talking.WORKING))
}
Data Transmission
Active Data Transmission, Commanding Native Code
There are two main ways to actively communicate with native code. The first and most common method involves Flutter actively sending data to the native layer, which then processes it and returns a result.
In this example, the communication with the native code is facilitated using a method called 'exTalk'.
/// STEP II | Talk (and get) to native
/// 2.1 | Type 1 Flutter -> Native -> Flutter
Future<int> exTalk({
int exMessage = 1,
}) async {
final Map<dynamic, dynamic> data = {};
data['ex_message'] = exMessage;
return await _invokeMethod('exTalk', data);
}
This method ensures smooth communication between Flutter and the native layer while enabling result processing.
Passive Data Transmission: Listening to Native Code
In certain cases, we need to listen for results from the native layer and send them back to Flutter. For example, if a command is sent from a printer to a device, the native layer will transmit it to Flutter to decode the command.
This approach is referred to as passive communication. We use a stream to listen for a method called "OnListenStateChanged":
Because native and Flutter use different data types, JSON is employed for communication. The native layer converts its data into JSON format and sends it to Flutter. Flutter then interprets the JSON and converts it into the desired data type.
Decoding Data from Native to Flutter
In the file lib/src/message_entity.dart, let's assume there is a ListenStateEnum that manages states in Flutter. It listens for JSON data with the key 'state'.
For instance, if the native layer sends {"state": 0}, Flutter will receive it and decode it into ListenStateEnum.working.
I will send a command from Flutter to the native layer, which will return a response immediately. I will then display the result on the screen as soon as it's available.
final a = await _yourPlugin.exTalk(exMessage: 3);
print(a);
4.3. Passive Communication: Listening to Native Code
My idea is to use a Stream to listen for data. Below, I present two approaches to retrieve data from a Stream:
Using StreamBuilder for Displaying Results
The simplest way to display results is by utilizing a StreamBuilder. It renders the output directly on the screen.
If you don't need to display the results but instead wish to handle more complex operations, you should use a StreamSubscription. Below is an example of declaring and implementing it within a StatefulWidget:
To display the result, the implementation is as simple as:
Text("Way 2 - listenState: $_listenState"),
Faster Setup with Mason
If you're unfamiliar with Mason, refer to my article on Mason.
Above, I have detailed the process of setting up a plugin I use and the two methods of communication between native and Flutter. As you can see, the process involves quite a few steps, doesn't it?
If we had to repeat all these steps every time we created a new plugin, it would take a lot of time. My solution is to use Mason. I create template code files, so whenever I need to build a new plugin, I only have to use Mason, and all the required code templates are automatically included in the project.