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 implementsPlatformInterface
. -
MethodChannelAppUsage
- an implementation ofAppUsagePlatform
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 Future
s, 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.
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 here.
Top comments (0)