How to create a custom plugin in Flutter to call native platform code

How to create a custom plugin in Flutter to call native platform code

·

15 min read

Introduction

In this tutorial, we will create a Flutter plugin which targets the Android and iOS platforms, and show how to invoke different methods from Dart, pass arguments of different types, and receive and parse results from the host platforms. The platform code won't actually call any real native APIs, but rather return some hard-coded data. But by the end of this tutorial, doing that part should hopefully be easy!

Flutter is mainly a UI framework, and so for a lot platform-specific functionality, we usually use plugins, typically created by the Dart and Flutter community, to achieve things such as getting the current battery level, or displaying local notifications. However, in some cases, there might not be a plugin already available, or the platform we are targeting might not be supported. In such cases, writing our own custom plugin (or contributing to an existing plugin), might be our only option.

[Updated July 9th 2022] Made changes to reflect the updated plugin templates which use platform interfaces, and added a small section on tests.

Starting with the plugin template

To get started, we'll create a Flutter plugin using flutter create with the plugin template.

flutter create --org dev.dartling --template=plugin --platforms=android,ios app_usage

This will generate the code for the plugin, as well as an example project that uses this plugin. By default, the generated Android code will be in Kotlin, and iOS in Swift, but you can specify either Java or Objective-C with the -a and -i flags respectively. (-a java and/or -i objc).

Of course, you could target more platforms if you want. For this tutorial, we will only be targeting Android and iOS.

Next, let's take a look at the generated Dart, Kotlin and Swift code after running flutter create.

Dart code

This auto-generated plugin comes with three Dart files. First is AppUsagePlatform, which extends PlatformInterface. This defines the interface, or API, of your plugin.

// app_usage_platform_interface.dart
abstract class AppUsagePlatform extends PlatformInterface {
  /// Constructs a AppUsagePlatform.
  AppUsagePlatform() : super(token: _token);

  static final Object _token = Object();

  static AppUsagePlatform _instance = MethodChannelAppUsage();

  /// The default instance of [AppUsagePlatform] to use.
  ///
  /// Defaults to [MethodChannelAppUsage].
  static AppUsagePlatform get instance => _instance;

  /// Platform-specific implementations should set this with their own
  /// platform-specific class that extends [AppUsagePlatform] when
  /// they register themselves.
  static set instance(AppUsagePlatform instance) {
    PlatformInterface.verifyToken(instance, _token);
    _instance = instance;
  }

  Future<String?> getPlatformVersion() {
    throw UnimplementedError('platformVersion() has not been implemented.');
  }
}

There is a getPlatformVersion which throws an UnimplementedError which we should not touch; it should be overriden by classes extending this one.

Platform interface and federated plugins

PlatformInterface is used to support federated plugins, which allow to split support of different packages in separate platforms. For example, if we look at the battery_plus package, which can be used to access battery information on any platform, has every implementation as a separate package dependency, as well as a dependency on battery_plus_platform_interface.

You can take a look at the documentation if you're interested in learning more about federated plugins, but the general idea is that separate platform implementations can be separate packages and can be maintained independently.

Method channels

The second class is MethodChannelAppUsage, which extends AppUsagePlatform. It's an implementation of our platform interface which uses a MethodChannel. It overrides getPlatformVersion and invokes a specific method in this method channel, which is implemented in both Android and iOS to return the current platform version.

// app_usage_method_channel.dart
/// An implementation of [AppUsagePlatform] that uses method channels.
class MethodChannelAppUsage extends AppUsagePlatform {
  /// The method channel used to interact with the native platform.
  @visibleForTesting
  final methodChannel = const MethodChannel('app_usage');

  @override
  Future<String?> getPlatformVersion() async {
    final String? version =
        await methodChannel.invokeMethod('getPlatformVersion');
    return version;
  }
}

The MethodChannel constructor accepts a channel name, which is the name of the channel on which communication between the Dart code and the host platform will happen. This name is typically the plugin name, and this is what the generated code uses, but some plugins usually use a combination of the application package/ID or domain. So for this example we could go for something like dev.dartling.app_usage or dartling.dev/app_usage.

Let's dig a little deeper into MethodChannel#invokeMethod:

 Future<T?> invokeMethod<T>(String method, [ dynamic arguments ])

We can specify which type we expect to be returned by the method channel, and the future's result can always be null. So in the platformVersion getter above, we could be more explicit and use _channel.invokeMethod<String>('getPlatformVersion') instead.

Last but not least, we have the AppUsage class. This is what we'll actually be using in our apps, and in the example app. By default, in the plugin template, there is already an example sub-folder with a Flutter app showing how AppUsage#getPlatformVersion can be used.

// app_usage.dart
class AppUsage {
  Future<String?> getPlatformVersion() {
    return AppUsagePlatform.instance.getPlatformVersion();
  }
}

All this does is call the current instance of AppUsagePlatform and call the appropriate, or in this case the only, method: getPlatformVersion(). In AppUsagePlatform, you can see that instance is actually the implementation using method channels: MethodChannelAppUsage.

To sum up, the 3 classes generated by the plugin template are:

  • AppUsagePlatform - A "platform" class which extends PlatformInterface, which defines the API of our plugin (i.e. methods are defined but throw UnimplementedErrors). The default instance (returned by the instance getter) of this class returns the default implementation.
  • MethodChannelAppUsage - A class which extends AppUsagePlatform and overrides every unimplemented method in that class. It uses method channels for the method implementations. This is what is returned with AppUsagePlatform.instance.
  • AppUsage - The actual class of our plugin to be used by anyone requiring the plugin's functionality. In the background, this invokes the methods of AppUsagePlatform.instance.

Kotlin code (Android)

// AppUsagePlugin.kt
class AppUsagePlugin : FlutterPlugin, MethodCallHandler {
    private lateinit var channel: MethodChannel

    override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
        channel = MethodChannel(flutterPluginBinding.binaryMessenger, "app_usage")
        channel.setMethodCallHandler(this)
    }

    override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
        if (call.method == "getPlatformVersion") {
            result.success("Android ${android.os.Build.VERSION.RELEASE}")
        } else {
            result.notImplemented()
        }
    }

    override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
        channel.setMethodCallHandler(null)
    }
}

The onAttachedToEngine and onDetachedFromEngine methods are pretty standard and we won't have to touch them at all. One note about onAttachedToEngine, is the MethodChannel constructor which also accepts a channel name. This name should match whatever we pass in the constructor on the Flutter side of things, so if you decide to go with a different name, make sure you change it in the constructors of all platforms.

The onMethodCall method is where the bulk of the logic happens, and will happen, when we add more functionality to our plugin. This method accepts two parameters. The first, the MethodCall, contains the data we pass from the invokeMethod invocation in Dart. So, MethodCall#method will return the method name (String), and MethodCall#arguments contains any arguments we pass along with the invocation. arguments is an Object, and can be used in different ways, but more on that later.

The Result can be used to return data or errors to Dart. This must always be used, otherwise the Future will never complete, and the invocation would hang indefinitely. With result, we can use the success(Object) method to return any object, error(String errorCode, String errorMessage, Object errorDetails) to return errors, and notImplemented() if the method we are invoking is not implemented (this is what happens in the else block above).

Swift code (iOS)

// SwiftAppUsagePlugin.swift
public class SwiftAppUsagePlugin: NSObject, FlutterPlugin {
  public static func register(with registrar: FlutterPluginRegistrar) {
    let channel = FlutterMethodChannel(name: "app_usage", binaryMessenger: registrar.messenger())
    let instance = SwiftAppUsagePlugin()
    registrar.addMethodCallDelegate(instance, channel: channel)
  }

  public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
    result("iOS " + UIDevice.current.systemVersion)
  }
}

Note that in the generated Swift code, there are no checks for the getPlatformVersion method name. Let's make some changes to the handle method, to keep things consistent across the two platforms.

public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
if (call.method == "getPlatformVersion") {
    result("iOS " + UIDevice.current.systemVersion)
} else {
    result(FlutterMethodNotImplemented)
}

Similarly to Android/Kotlin, FlutterMethodCall has a method string and dynamic arguments. But FlutterResult is a bit different. For a "successful" return, you can just pass any value in result(...). If the method is not implemented, just pass FlutterMethodNotImplemented, as shown above. And for errors, pass FlutterError.init(code: "ERROR_CODE", message: "error message", details: nil).

Returning complex objects

Now that we've seen how the code looks across Dart and our target platforms, let's implement some new functionality. Let's say that our App Usage plugin should return a list of the used apps, showing how much time we spend on each app.

We need to update all 3 classes to support a new method, but all we need to do is add a new method in each class.

// app_usage_platform_interface.dart
Future<List<UsedApp>> get apps async {
  throw UnimplementedError('apps has not been implemented.');
}
// app_usage_method_channel.dart
@override
Future<List<UsedApp>> get apps async {
  final List<dynamic>? usedApps =
      await methodChannel.invokeListMethod<dynamic>('getUsedApps');
  return usedApps?.map(UsedApp.fromJson).toList() ?? [];
}
// app_usage.dart
Future<List<UsedApp>> get apps {
  return AppUsagePlatform.instance.apps;
}
// models.dart
class UsedApp {
  final String id;
  final String name;
  final Duration timeUsed;

  UsedApp(this.id, this.name, this.timeUsed);

  static UsedApp fromJson(dynamic json) {
    return UsedApp(
      json['id'] as String,
      json['name'] as String,
      Duration(minutes: json['minutesUsed'] as int),
    );
  }
}

It seems a little tedious to change 3 classes just to support one new method, but at least the idea is simple. The main logic is in MethodChannelAppUsage; in AppUsagePlatform we simply define the method signature and just throw an error, and in AppUsage we simply call the method.

Notice that we actually expect a List<dynamic> rather than List<UsedApp> when we invoke a method from the channel, and map these to UsedApp using the fromJson method. We cannot just cast complex objects, though this will work fine for simple types such as int, double, bool and String. This is because the standard platform channels only support specific data types, which you can read more about here.

Calling _channel.invokeMethod<UsedApp>(...) will result to this error:

The following _CastError was thrown building MyApp(dirty, state: _MyAppState#8bcb2):
type '_InternalLinkedHashMap<Object?, Object?>' is not a subtype of type 'UsedApp' in type cast

Also notice that we used the convenience invokeListMethod<T>, since we are expecting a list of items to be returned. The above method is equivalent to _channel.invokeMethod<List<dynamic>>(...). There is also the invokeMapMethod<K, V> if we are expecting a map.

Now, let's implement getUsedApps on the Android and iOS platforms. If we don't, and try to invoke this method from the example app (or any app), we will see this error:

Unhandled Exception: MissingPluginException(No implementation found for method getUsedApps on channel app_usage)

For Android, we have to update our onMethodCall function in AppUsagePlugin. We replace the if statement with a when, to make things a bit simpler.

// AppUsagePlugin.kt
class AppUsagePlugin : FlutterPlugin, MethodCallHandler {
    private var appUsageApi = AppUsageApi()

    override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
        when (call.method) {
            "getPlatformVersion" -> result.success("Android ${android.os.Build.VERSION.RELEASE}")
            "getUsedApps" -> result.success(appUsageApi.usedApps.stream().map { it.toJson() }
                .toList())
            else -> result.notImplemented()
        }
    }
}

When invoking getUsedApps, we simply use the AppUsageApi to return the used apps, map them to a list of JSON objects (actually just a map of string to a value), and return them with result.

This is what AppUsageApi looks like, if you're curious:

// AppUsageApi.kt
data class UsedApp(val id: String, val name: String, val minutesUsed: Int) {
    fun toJson(): Map<String, Any> {
        return mapOf("id" to id, "name" to name, "minutesUsed" to minutesUsed)
    }
}

class AppUsageApi {
    val usedApps: List<UsedApp> = listOf(
        UsedApp("com.reddit.app", "Reddit", 75),
        UsedApp("dev.hashnode.app", "Hashnode", 37),
        UsedApp("link.timelog.app", "Timelog", 25),
    )
}

Just a data class and some hard-coded values. We could have made this simpler and just returned a Map<String, Any> straight from here, but realistically, an API would return its own data classes/models.

Similarly, for iOS, we need to update the handle function in SwiftAppUsagePlugin.

// AppUsageApi
struct UsedApp {
    var id: String
    var name: String
    var minutesUsed: Int

    func toJson() -> [String: Any] {
        return [
            "id": id,
            "name": name,
            "minutesUsed": minutesUsed
        ]
    }
}

class AppUsageApi {
    var usedApps = [
        UsedApp(id: "com.reddit.app", name: "Reddit", minutesUsed: 75),
        UsedApp(id: "dev.hashnode.app", name: "Hashnode", minutesUsed: 37),
        UsedApp(id: "link.timelog.app", name: "Timelog", minutesUsed: 25)
    ]
}
// SwiftAppUsagePlugin.swift
public class SwiftAppUsagePlugin: NSObject, FlutterPlugin {
  private var appUsageApi = AppUsageApi()

  public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
    switch (call.method) {
        case "getPlatformVersion":
            result("iOS " + UIDevice.current.systemVersion)
        case "getUsedApps":
            result(appUsageApi.usedApps.map { $0.toJson() })
        default:
            result(FlutterMethodNotImplemented)
    }
  }
}

I will not be sharing many snippets from the example app and usages of the AppUsage functions, just to keep the tutorial shorter, but you can take a look at the source code here. But the actual usage of the plugin is quite simple. We are simply calling the static methods of AppUsage to get data from the host platform, and display it. But in case you're curious to see the method in action, this is how the example app looks like:

android.png ios.png

Passing arguments

So far, we've shown how to receive data from the host platform. Now what if we want to pass data instead? Let's introduce a new method to our App Usage API. Once again, 3 times!

// app_usage_platform_interface.dart
Future<String> setAppTimeLimit(String appId, Duration duration) async {
  throw UnimplementedError('setAppTimeLimit() has not been implemented.');
}
// app_usage_method_channel.dart
@override
Future<String> setAppTimeLimit(String appId, Duration duration) async {
  try {
    final String? result =
        await methodChannel.invokeMethod('setAppTimeLimit', {
      'id': appId,
      'durationInMinutes': duration.inMinutes,
    });
    return result ?? 'Could not set timer.';
  } on PlatformException catch (ex) {
    return ex.message ?? 'Unexpected error';
  }
}
// app_usage.dart
Future<String> setAppTimeLimit(String appId, Duration duration) {
  return AppUsagePlatform.instance.setAppTimeLimit(appId, duration);
}

The difference with the previous method is that we're now also passing parameters to invokeMethod, which is an optional field. While the type of parameters is dynamic, and so could be anything, it's recommended to use a map.

Since our implementation won't actually set any app time limits, it would still be nice to confirm that the host platform has properly received the passed parameters, in our case id and minutes. So to keep things simple, we just want to return a string containing a confirmation that the time limit was set for the given app ID and duration.

Here's the Android/Kotlin implementation:

// AppUsageApi.kt
fun setTimeLimit(id: String, durationInMinutes: Int): String {
    return "Timer of $durationInMinutes minutes set for app ID $id";
}
// AppUsagePlugin.kt
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
    when (call.method) {
        ...
        "setAppTimeLimit" -> result.success(
            appUsageApi.setTimeLimit(
                call.argument<String>("id")!!,
                call.argument<Int>("durationInMinutes")!!
            )
        )
        else -> result.notImplemented()
    }
}

We get the arguments from the passed parameters using MethodCall#argument, and specify the type we expect the argument to have. This method only works if the parameters passed are either a map or a JSONObject. The method returns an optional result we could be null, hence the !! operator. If the argument for that key in the map is missing or has a different type, an exception is thrown.

Alternatively, we could return the whole map by using:

call.arguments()

We can also check if the argument exists by using:

call.hasArgument("id") // true
call.hasArgument("appId") // false

Next, the iOS/Swift code:

// AppUsageApi
func setTimeLimit(id: String, durationInMinutes: Int) -> String {
    return "Timer of \(durationInMinutes) minutes set for app ID \(id)"
}
// SwiftAppUsagePlugin.swift
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
  switch (call.method) {
      ...
      case "setAppTimeLimit":
          let arguments = call.arguments as! [String: Any]
          let id = arguments["id"] as! String
          let durationInMinutes = arguments["durationInMinutes"] as! Int
          result(appUsageApi.setTimeLimit(id: id, durationInMinutes: durationInMinutes))
      default:
          result(FlutterMethodNotImplemented)
  }
}

Very similar, but unlike Kotlin, there are no convenience methods to get an argument by its key. Instead, we need to cast call.arguments to a map of String to Any, and then cast each argument to the type we expect it in. Both the arguments and any values in the map can be null, which is why we need the ! operator when casting.

And that's it for the platform implementations! In the example app, I've added an icon button which calls this method and displays snackbar with the result string.

// EXAMPLE APP: main.dart
IconButton(
  icon: const Icon(Icons.timer_outlined),
  onPressed: () async {
    // TODO: Set duration manually.
    final scaffoldMessenger = ScaffoldMessenger.of(context);
    final String result = await _appUsagePlugin.setAppTimeLimit(
        app.id, const Duration(minutes: 30));
    scaffoldMessenger
        .showSnackBar(SnackBar(content: Text(result)));
  },
)

Note: we store ScaffoldMessengerState in a variable (scaffoldMessenger) before awaiting for the plugin's setAppTimeLimit because we should not use BuildContext across async gaps.

Error handling

We've now learned how to read data returned from the host platform, and pass data to the host platform. For the last part, we'll return an error from the platform side and catch it.

For this example, we'll just be doing this on the Android side. Let's improve the implementation for setAppTimeLimit.

override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
    when (call.method) {
        ...
        "setAppTimeLimit" -> {
            if (!call.hasArgument("id") || !call.hasArgument("durationInMinutes")) {
                result.error(
                    "BAD_REQUEST",
                    "Missing 'id' or 'durationInMinutes' argument",
                    Exception("Something went wrong")
                )
            }
            result.success(
                appUsageApi.setTimeLimit(
                    call.argument<String>("id")!!,
                    call.argument<Int>("durationInMinutes")!!
                )
            )
        }
        else -> result.notImplemented()
    }

If either the id or durationInMinutes arguments are missing from the method call, we'll throw a more helpful exception. Otherwise, we'd just get a null pointer exception when calling call.argument<T>("key")!!.

This results in a PlatformException being thrown from invokeMethod on the Flutter side. To handle it, we could do the following.

static Future<String> setAppTimeLimit(String appId, Duration duration) async {
  try {
    final String? result = await _channel.invokeMethod('setAppTimeLimit', {
      'appId': appId,
      'durationInMinutes': duration.inMinutes,
    });
    return result ?? 'Could not set timer.';
  } on PlatformException catch (ex) {
    return ex.message ?? 'Unexpected error';
  }
}

In the snippet above we replaced the id argument with appId, which will lead to a platform exception.

Testing

The plugin templates already comes with tests for AppUsage as well as MethodChannelAppUsage. No tests necessary for AppUsagePlatform as all it does is throw errors!

They are both nicely set up and easy to extend; the test for AppUsage already provides a mock implementation of AppUsagePlatform and sets it as the default instance, and the test for MethodChannelAppUsage mocks the method call handler's return values.

Take a look at the tests in the source code to see how we tested the new methods added for our App Usage plugin.

Alternatives

One not-so-nice aspect of host platform to Flutter communication with MethodChannel is the serialization/deserialization part. When passing many arguments, we need to pass them in a map, and accessing them, casting them, and checking if they exist is not very nice. Same for parsing data returned from the method calls; for this tutorial we needed to map the UsedApp list to a JSON-like map from the Kotlin/Swift code, and then implement a method to create a UsedApp from the returned list on the Flutter side. This can be time-consuming, but also error-prone (all our fields/keys are hard-coded strings and have to be kept in sync across 3 different languages!).

Enter Pigeon, an alternative to MethodChannel for Flutter to host platform communication. It is a code generator tool which aims to make this type-safe, easier and faster. With Pigeon, you just have to define the communication interface, and code generation takes care of everything else. In this post, we explore using Pigeon as an alternative to method channels and build the exact same functionality as with this tutorial. If you're curious, check it out and see how it compares!

Wrapping up

In this tutorial, we created a custom Flutter plugin with both Android and iOS implementations to call (fake) native functionality. We showed how to send and retrieve data from the host platforms, as well as throw errors and handle them.

You can find the full source code here.

If you found this helpful and would like to be notified of any future tutorials, please sign up with your email below.

Did you find this article valuable?

Support Christos by becoming a sponsor. Any amount is appreciated!