How to create a custom plugin in Flutter with Pigeon

How to create a custom plugin in Flutter with Pigeon

·

13 min read

Introduction

In this tutorial, we will create a Flutter plugin which targets the Android and iOS platforms using the Pigeon package.

With Flutter being a UI framework, communicating with the native platform is not something we always need or use. And usually when we do need to do so, there are tons of packages out there that already do this for specific use cases.

But there are cases when the functionality the native platform has to offer is not yet available in a public package on pub.dev. In these cases, it's up to us! We need to write some Dart code on the Flutter side to call the platform, as well as the native platform code to call the functionality we need (and that's once per platform your app supports!).

Native platform communication

Platform channels

One way to communicate with the native platform in Flutter is by using a MethodChannel. We covered how to build a plugin or call native platform code in Flutter using method channels in a previous post.

While using MethodChannel is relatively straightforward, it can actually be quite time-consuming; when calling methods through the MethodChannel, you can only pass arguments of simple types such as int, double, bool or String, as well as maps of list with such types as values. You can see the full data types support here. If you need to pass a complex object as an argument, you would need to have logic to parse this to, let's say, a map of string keys to their values. In addition, you need to do the same for any data that is returned from the native platform.

We could work around having to introduce parsing logic by using a package such as json_serializable to parse data to and from JSON to save ourselves some time. However, you'd need to make sure the native platforms are returning the data in the exact format you are expecting, and vice versa. Otherwise the parsing will fail.

Pigeon

Pigeon is a code generator package which generates all the code necessary to communicate between Flutter and any host platform. All you have to do is define the API. This is convenient, because you don't have to worry about any parsing logic, and the communication is guaranteed to be type-safe.

As of July 10th 2022, Pigeon only supports Android and iOS, and generates Java and Objective-C code (Swift is experimental) respectively. The generated code is still accessible to Kotlin or Swift. There is also experimental Windows support with C++.

Creating a plugin

In this post, we will create a simple plugin using Pigeon. What we will build will be identical to the (fake) app usage plugin we built previously, so we can compare the results.

The plugin is simple; it should return a list of all apps and their usage, as well as support the ability to set time limits on specific apps. We won't actually implement this functionality on the native side as it's outside the scope of this tutorial, but rather return dummy data from the native side instead.

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_pigeon

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).

There is quite a bit of code included with the plugin template. We go into the Dart, Kotlin and Swift code into more detail in this post, if you're curious. For the context of this tutorial, it's enough to know the following:

On the Dart side, there are three classes:

  • AppUsagePlatform - the "interface"/API of the plugin, which implements PlatformInterface.
  • MethodChannelAppUsage - an implementation of AppUsagePlatform using method channels.
  • AppUsage - the class exposing the methods to be used by any apps which need to use our plugin.

The Kotlin code generated is AppUsagePlugin.kt, which uses method channels. It is defined in our pubspec.yaml as the plugin class for the Android platform, so we'll still be needing it, though we will make some changes to it later. The same applies to the Swift code, which includes SwiftAppUsagePlugin.swift as well as AppUsagePlugin.h and AppUsagePlugin.m.

In this tutorial, we will write an implementation of AppUsagePlatform which uses Pigeon rather than method channels. We can delete MethodChannelAppUsage as we won't be needing it.

Note: the new plugin template using PlatformInterface introduces quite a bit of code that you might not really need if you just want to call some native code in your app. If you wanted, you could still use Pigeon without creating a plugin or a separate package, but that's what we'll be doing in this tutorial.

Using Pigeon

Installing the pigeon package

Let's install the package:

flutter pub add --dev pigeon

Alternatively, add this to your pubspec.yaml:

dev_dependencies:
  pigeon: ^3.2.3

Defining the App Usage API

The way Pigeon works is pretty simple; we define our API in a Dart class outside the lib folder (as Pigeon is a dev dependency). The API class should be an abstract class with the @HostApi() decorator, and its methods should have the @async decorator.

Let's define our App Usage API in a new directory named pigeons:

// pigeons/app_usage_api.dart
import 'package:pigeon/pigeon.dart';

enum State { success, error }

class StateResult {
  final State state;
  final String message;

  StateResult(this.state, this.message);
}

class UsedApp {
  final String id;
  final String name;
  final int minutesUsed;

  UsedApp(this.id, this.name, this.minutesUsed);
}

@HostApi()
abstract class AppUsageApi {
  @async
  String? getPlatformVersion();

  @async
  List<UsedApp> getApps();

  @async
  StateResult setAppTimeLimit(String appId, int minutesUsed);
}

Caveats and limitations

Defining the API was relatively simple, but there are a few things to mention:

Futures

We do not need to specify the return values as Futures, but in the generated code they will be. So getPlatformVersion will actually return a Future<String?> in the generated Dart code.

No imports allowed

No imports other than package:pigeon/pigeon.dart are allowed. This means EVERY model class should be defined in this Pigeon API file.

Supported data types

As mentioned before, only simple JSON-like values are supported. This means we can't use useful Dart types such as DateTime or Duration. Which means we might still need additional mapping logic to convert the Pigeon model to the model we want to use within the app. For minutesUsed in UsedApp, we'll need to manually create a Duration out of the minutes, though it would be nice to have this as a Duration in the first place.

Enums aren't yet supported for primitive return types

We cannot return an enum from a method, but we can have an enum as a method parameter. We can still return enums, but only if we wrap them in a separate class, like below.

// Not valid, enums cannot be returned.
enum ResultState { success, error }

@HostApi()
abstract class AppUsageApi {
  @async
  ResultState getState();
}
// Valid, enums can be method parameters and fields of returned objects.
enum ResultState { success, error }

class ApiResult {
  final ResultState state;
  final String message;

  ApiResult(this.state, this.message);
}

@HostApi()
abstract class AppUsageApi {
  @async
  ApiResult getResult();

  @async
  void setState(ResultState state);
}

Generics are supported, but can only be used with nullable types

We can still define them as non-nullable in our HostApi definition, e.g. List<Something>, but the generated Dart class will have List<Something?> instead.

Generating the code

Running the generator

After defining the API, we can generate code using flutter pub run pigeon. This command requires quite a few arguments:

flutter pub run pigeon \
  --input pigeons/app_usage_api.dart \
  --dart_out lib/app_usage_api.dart \
  --java_package "dev.dartling.app_usage" \
  --java_out android/src/main/java/dev/dartling/app_usage/AppUsage.java \
  --experimental_swift_out ios/Classes/AppUsage.swift

We will store this in a pigeon.sh file, just so it's easy to find and run in the future.

We're going with Swift which has experimental support for now rather than Objective-C, but for Objective-C we can simply drop the experimental_swift_out argument in favor of these three:

  --objc_header_out ios/Classes/AppUsageApi.h \
  --objc_source_out ios/Classes/AppUsageApi.m \
  --objc_prefix FLT
Dart

The input argument should be the file we defined the API in, and dart_out should be in our lib folder, as it's the code we'll actually be using in our app.

Java

java_package is the full package name, in this case dev.dartling.app_usage and java_out is the path to the Java file that will be generated.

Note: Make sure the generated Java class name does NOT match the name of the Pigeon HostApi. In our case, the generated Java class will be AppUsage, and will include a nested public AppUsageApi interface, taken from the HostApi class name defined in Dart. If we used the same names (which is what I did initially!), compilation will fail due to duplicate names.

Note: if your plugin template uses Kotlin, like we did in this one, you will need to create the java/dev/dartling/app_usage directory manually under src/main, as only kotlin/dev/dartling/app_usage was generated as part of the plugin template.

Swift

experimental_swift_out is the path to the Swift file that will be generated.

Objective-C

The objc_header_out and objc_source_out arguments determine the generated files on the Objective-C side, and the objc_prefix is optional and determines the prefix of the generated class names.

Understanding the generated code

The code generated by Pigeon after running flutter pub run pigeon is not something we should really have to look at often. All we need to know is that the Java class will have an AppUsageApi interface which our implementation class should implement; this can be both in either Java or Kotlin. In Objective-C, there will be a FLTAppUsageApi protocol equivalent (notice the FLT prefix which is an argument when running the generator), and in Swift an AppUsageApi protocol.

Native platform implementation

Android

We have our interface, now all we need to do is have an implementation for it. To keep things simple, we will simply the existing AppUsagePlugin Kotlin class to implement AppUsageApi, in addition to the existing FlutterPlugin interface.

Here is the full class:

// AppUsagePlugin.kt
class AppUsagePlugin : FlutterPlugin, AppUsageApi {
    val usedApps: MutableList<UsedApp> = mutableListOf(
        usedApp("com.reddit.app", "Reddit", 75),
        usedApp("dev.hashnode.app", "Hashnode", 37),
        usedApp("link.timelog.app", "Timelog", 25),
    )

    override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
        AppUsageApi.setup(flutterPluginBinding.binaryMessenger, this)
    }

    override fun getPlatformVersion(result: Result<String>?) {
        result?.success("Android ${android.os.Build.VERSION.RELEASE}")
    }

    override fun getApps(result: Result<MutableList<UsedApp>>?) {
        result?.success(usedApps);
    }

    override fun setAppTimeLimit(
        appId: String,
        durationInMinutes: Long,
        result: Result<TimeLimitResult>?
    ) {
        val stateResult = TimeLimitResult.Builder()
            .setState(ResultState.success)
            .setMessage("Timer of $durationInMinutes minutes set for app ID $appId")
            .build()
        result?.success(stateResult)
    }

    private fun usedApp(id: String, name: String, minutesUsed: Long): UsedApp {
        return UsedApp.Builder()
            .setId(id)
            .setName(name)
            .setMinutesUsed(minutesUsed)
            .build();
    }

    override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
        AppUsageApi.setup(binding.binaryMessenger, null)
    }
}

The onAttachedToEngine and onDetachedFromEngine are from FlutterPlugin. Previously they set things up to work for the method channel implementation. Now, we are calling the AppUsageApi#setup method to get it to work with Pigeon's generated code.

The other three functions we override are from the AppUsageApi interface. These are actually void functions, and we "return" the results by making use of result, which was actually generated as nullable. To return, we simply use result?.success(...), and in case we want to throw an error, we can use result?.error(...) and pass a Throwable; this will be wrapped into a PlatformException on the Dart side.

iOS

Very similarly to Android, we will make the existing SwiftAppUsagePlugin implement the AppUsageApi protocol in addition to being a FlutterPlugin. Rather than result, we have completion which we call by passing the result as the argument. We also make some changes to register to use the static AppUsageApiSetup#setUp function to set things up with the generated file.

public class SwiftAppUsagePlugin: NSObject, FlutterPlugin, 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)
    ]

    public static func register(with registrar: FlutterPluginRegistrar) {
        let messenger : FlutterBinaryMessenger = registrar.messenger()
        let api : AppUsageApi & NSObjectProtocol = SwiftAppUsagePlugin.init()
        AppUsageApiSetup.setUp(binaryMessenger: messenger, api: api)
    }

    func getPlatformVersion(completion: @escaping (String?) -> Void) {
        completion("iOS " + UIDevice.current.systemVersion)
    }

    func getApps(completion: @escaping ([UsedApp]) -> Void) {
        completion(usedApps)
    }

    func setAppTimeLimit(appId: String, durationInMinutes: Int32, completion: @escaping (TimeLimitResult) -> Void) {
        completion(TimeLimitResult(state: ResultState.success, message: "Timer of \(durationInMinutes) minutes set for app ID \(appId)"))
    }
}

Note: I've faced some weird issues with the iOS build sometimes not succeeding due to AppUsageApi not being found in the scope. If you run into the same issue, the quick hacky way is to copy everything in the generated AppUsage.swift into the existing SwiftAppUsagePlugin.swift file. Then it should work! If you figure out how/why this happens and how to fix it, please let me know in the comments!

Using the plugin

We now have everything in place. The native platform implementations are done, and the AppUsageApi Dart class can be used to communicate with the native platforms.

All we have to do is create an instance of AppUsageApi and invoke its method... but wait, we're building a plugin! We should not use AppUsageApi directly (though we could!). Remember the MethodChannelAppUsage Dart class we deleted a while ago? We need to introduce an alternative that will use Pigeon instead of method channels.

Firstly, let's add make sure all methods that are part of our Pigeon HostApi are also defined in our AppUsagePlatform.

abstract class AppUsagePlatform extends PlatformInterface {
  ...

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

  Future<List<UsedApp>> get apps async {
    throw UnimplementedError('apps has not been implemented.');
  }

  Future<TimeLimitResult> setAppTimeLimit(String appId, Duration duration) async {
    throw UnimplementedError('setAppTimeLimit() has not been implemented.');
  }
}

Now, our AppUsagePlatform implementation with Pigeon is very simple. We're simply going to invoke the methods of AppUsageApi, which was generated by Pigeon.

// app_usage_pigeon.dart
/// An implementation of [AppUsagePlatform] that uses Pigeon.
class PigeonAppUsage extends AppUsagePlatform {
  final AppUsageApi _api = AppUsageApi();

  @override
  Future<String?> getPlatformVersion() {
    return _api.getPlatformVersion();
  }

  @override
  Future<List<UsedApp>> get apps {
    return _api
        .getApps()
        .then((apps) => apps.where((e) => e != null).map((e) => e!).toList());
  }

  @override
  Future<TimeLimitResult> setAppTimeLimit(String appId, Duration duration) async {
    return _api.setAppTimeLimit(appId, duration.inMinutes);
  }
}

Note that in apps we filter null values and use the ! operator, as AppUsageApi#getApps() returns List<UsedApp?>, due to Pigeon's current limitations.

Lastly, AppUsage, our main plugin class, should also be updated. All it does is delegate method calls to AppUsagePlatform.instance.

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

  Future<List<UsedApp>> get apps {
    return AppUsagePlatform.instance.apps;
  }

  Future<TimeLimitResult> setAppTimeLimit(String appId, Duration duration) {
    return AppUsagePlatform.instance.setAppTimeLimit(appId, duration);
  }
}

And let's not forget, that AppUsagePlatform.instance should now return an instance of PigeonAppUsage rather than MethodChannelAppUsage:

abstract class AppUsagePlatform extends PlatformInterface {
  ...

  static AppUsagePlatform _instance = PigeonAppUsage();

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

  ...
}

I won't share snippets of the UI code and widgets, but you can take look at these here. Using the plugin in any app is simple; we initialize an instance of AppUsage and call its methods we need them.

Here is the final result:

android.png ios.png

Comparing Pigeon and method channels

We built an almost identical plugin and example app in a previous article, so we can compare Pigeon with using method channels.

Overall, Pigeon is definitely an improvement. We only have to define our API and models once; the generated Android/iOS will include these models for us.

We also don't have to worry about serializing data we want to pass to the platform side or deserialize data coming from the platform side, and the opposite for the platform side; we won't have to worry about deserializing data coming from the Dart side and serializing data we return to the Dart side.

Thanks to the two points above, we needed significantly less lines of code to write a plugin using Pigeon rather than method channels.

Wrapping up

In this tutorial, we introduced Pigeon as a way to simplify native platform communication, and created a custom Flutter plugin with Android and iOS implementations to call (fake) native functionality, using Pigeon rather than method channels.

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!