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 extendsPlatformInterface
, which defines the API of our plugin (i.e. methods are defined but throwUnimplementedError
s). The default instance (returned by theinstance
getter) of this class returns the default implementation.MethodChannelAppUsage
- A class which extendsAppUsagePlatform
and overrides every unimplemented method in that class. It uses method channels for the method implementations. This is what is returned withAppUsagePlatform.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 ofAppUsagePlatform.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:
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 await
ing 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.