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.
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 is an AppUsage
Dart class with a MethodChannel
, and a method which invokes a specific method name in this channel. This getPlatformVersion
method is implemented in both Android and iOS to return the current platform version.
class AppUsage {
static const MethodChannel _channel = MethodChannel('app_usage');
static Future<String?> get platformVersion async {
final String? version = await _channel.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.
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.
// app_usage.dart
static Future<List<UsedApp>> get usedApps async {
final List<dynamic>? usedApps =
await _channel.invokeListMethod<dynamic>('getUsedApps');
return usedApps?.map(UsedApp.fromJson).toList() ?? [];
}
// 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),
);
}
}
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. This is because we cannot just cast complex objects, though this will work fine for simple types such as int
, double
, bool
and String
. 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.
// app_usage.dart
static Future<String> setAppTimeLimit(String appId, Duration duration) async {
final String? result = await _channel.invokeMethod('setAppTimeLimit', {
'id': appId,
'durationInMinutes': duration.inMinutes,
});
return result ?? 'Could not set timer.';
}
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 String result = await AppUsage.setAppTimeLimit(
app.id, const Duration(minutes: 30));
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(result)));
},
)
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.
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 here.
Top comments (0)