Plugin

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:

|--lib
    |-- src
        |-- message_entity.dart
        |-- your_plugin_function.dart
    |-- your_plugin.dart

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.

Set Up Method for Dart

part of YourPlugin;

class YourPlugin {

  /// STEP I | setup method
  static const String nameMethod = 'YourPlugin';
  static const MethodChannel _methods = MethodChannel('$nameMethod/methods');
  final StreamController<MethodCall> _methodStream = StreamController.broadcast();

  // 1.1: Create stream method
  bool _initialized = false;
    Future<dynamic> _initFlutterBluePlus() async {
    if (_initialized) {
      return;
    }
      _initialized = true;
      _methods.setMethodCallHandler((call) async {
        _updateMethodStream(call);
    });
  }

  // 1.2: Invoke a platform method
  Future<dynamic> _invokeMethod(String method, [dynamic arguments]) async {
    dynamic out;
    print("_invokeMethod | $method | $arguments");
    try {
      _initFlutterBluePlus();
      out = await _methods.invokeMethod(method, arguments);
    } catch (e) {
      rethrow;
    }
    return out;
  }

  // 1.3: Update Stream _methodStream on Flutter
  void _updateMethodStream(MethodCall methodCall) {
    _methodStream.add(methodCall);
  }
}

Create Native Code for Android

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.

func onListenStateChanged(state: Int) {
    let response = [
        "state": state,
    ]
    if methodChannel != nil {
        methodChannel!.invokeMethod("OnListenStateChanged", arguments: response)
        Utils.log("OnListenStateChanged \(response)")
    }
}

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":

/// 2.2 | Type 2 Native -> Flutter: Listen native
Stream<ListenState> get listenState async* {
  yield* _methodStream.stream
      .where((m) => m.method == "OnListenStateChanged")
      .map((m) => m.arguments)
      .map((args) => ListenState.fromMap(args));
}

Communication Using JSON

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.

part of YourPlugin;

class ListenState {
  ListenStateEnum state;

  ListenState({
    required this.state
  });

  Map<dynamic, dynamic> toMap() {
    final Map<dynamic, dynamic> data = {};
    data['state'] = state;
    return data;
  }

  factory ListenState.fromMap(Map<dynamic, dynamic> json) {
    return ListenState(
      state: ListenStateEnum.getEnum(json['state']),
    );
  }
}

enum ListenStateEnum {
  working(0),
  free(1);

  const ListenStateEnum(this.code);
  final int code;
  
  static ListenStateEnum getEnum(int code) {
    try {
      return ListenStateEnum.values.firstWhere((element) => element.code == code);
    } catch (e) {
      return ListenStateEnum.working;
    }
  }
}

Practical Implementation

Create Example Code and Test Plugin Features in Flutter

import 'dart:async';

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

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

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

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {

  final _yourPlugin = YourPlugin();

  ListenStateEnum _listenState = ListenStateEnum.working;
  late StreamSubscription<ListenState> _listenStateSubscription;

  @override
  void initState() {
    super.initState();
    _listenStateSubscription = _yourPlugin.listenState.listen((value) {
      _listenState = value.state;
      if (mounted) {
        setState(() {});
      }
    });
  }

  @override
  void dispose() {
    _listenStateSubscription.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Plugin Example App | YourPlugin'),
        ),
        body: Center(
          child: SingleChildScrollView(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                const Text("TYPE 1: Call Native and Get Data"),
                TextButton(
                    onPressed: () async {
                      final a = await _yourPlugin.exTalk(exMessage: 3);
                      print(a);
                    },
                    child: const Text("exTalk")),

                const Text("TYPE 2: Listen for Data from Native"),
                StreamBuilder(
                  stream: _yourPlugin.listenState,
                  builder: (context, s) {
                    return Text("Way 1 - listenState: ${s.data?.state.toString()}");
                  }
                ),
                Text("Way 2 - listenState: $_listenState"),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

Active Communication

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:

  1. Using StreamBuilder for Displaying Results

The simplest way to display results is by utilizing a StreamBuilder. It renders the output directly on the screen.

StreamBuilder(
    stream: _yourPlugin.listenState,
    builder: (context, s) {
      return Text("Way 1 - listenState: ${s.data?.state.toString()}");
    }
),
  1. Using StreamSubscription for Complex Tasks

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:

ListenStateEnum _listenState = ListenStateEnum.working;
late StreamSubscription<ListenState> _listenStateSubscription;

@override
void initState() {
  super.initState();
  _listenStateSubscription = _yourPlugin.listenState.listen((value) {
    _listenState = value.state;
    if (mounted) {
      setState(() {});
    }
  });
}

@override
void dispose() {
  _listenStateSubscription.cancel();
  super.dispose();
}

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.

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.

You can check out my dr_plugin brick at https://brickhub.dev/bricks/dr_plugin.

Buy Me a Coffee | Support Me on Ko-fi

Last updated