Flutter Lesson 65 of 83 5 min read
Platform Channels in Flutter: Calling Native Code
Call Kotlin and Swift code from Flutter with MethodChannel: send arguments, return results, handle errors and receive events.
On this page
Plugins cover nearly everything. Now and then you need a native feature that no plugin provides, or you must use a company’s native SDK. A platform channel lets your Dart code send a message to Kotlin on Android or Swift on iOS, and get an answer back.
How it works #
Dart Native (Kotlin / Swift)
│ invokeMethod('getBatteryLevel') → │
│ │ reads the battery from the OS
│ ← result: 82 │
Both sides agree on a channel name and on method names. Calls are asynchronous: Dart gets a Future.
The Dart side #
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class BatteryService {
static const _channel = MethodChannel('com.example.myapp/battery');
Future<int?> batteryLevel() async {
try {
return await _channel.invokeMethod<int>('getBatteryLevel');
} on PlatformException catch (e) {
debugPrint('Native error: ${e.code} ${e.message}');
return null;
} on MissingPluginException {
return null; // not implemented on this platform
}
}
}
class BatteryPage extends StatefulWidget {
const BatteryPage({super.key});
@override
State<BatteryPage> createState() => _BatteryPageState();
}
class _BatteryPageState extends State<BatteryPage> {
final _service = BatteryService();
String _text = 'Tap to check';
Future<void> _check() async {
final level = await _service.batteryLevel();
if (!mounted) return;
setState(() => _text = level == null ? 'Not available' : 'Battery: $level%');
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(child: FilledButton(onPressed: _check, child: Text(_text))),
);
}
}
Use a unique channel name, by convention your app id followed by a topic.
The Android side (Kotlin) #
In android/app/src/main/kotlin/.../MainActivity.kt:
package com.example.myapp
import android.content.Context
import android.os.BatteryManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
private val channelName = "com.example.myapp/battery"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
.setMethodCallHandler { call, result ->
when (call.method) {
"getBatteryLevel" -> {
val manager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
val level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
if (level >= 0) result.success(level)
else result.error("UNAVAILABLE", "Battery level not available", null)
}
else -> result.notImplemented()
}
}
}
}
The iOS side (Swift) #
In ios/Runner/AppDelegate.swift:
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let controller = window?.rootViewController as! FlutterViewController
let channel = FlutterMethodChannel(
name: "com.example.myapp/battery",
binaryMessenger: controller.binaryMessenger
)
channel.setMethodCallHandler { call, result in
guard call.method == "getBatteryLevel" else {
result(FlutterMethodNotImplemented)
return
}
UIDevice.current.isBatteryMonitoringEnabled = true
let level = UIDevice.current.batteryLevel
if level < 0 {
result(FlutterError(code: "UNAVAILABLE", message: "Battery level not available", details: nil))
} else {
result(Int(level * 100))
}
}
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
The generated native files differ a little between Flutter versions. Keep whatever is already in your file and add the channel code to it. After editing native code, stop the app and run it again.
Sending arguments #
final sum = await _channel.invokeMethod<int>('add', {'a': 3, 'b': 4});
"add" -> {
val a = call.argument<Int>("a") ?: 0
val b = call.argument<Int>("b") ?: 0
result.success(a + b)
}
Types that can cross #
| Dart | Kotlin | Swift |
|---|---|---|
null | null | nil |
bool | Boolean | Bool |
int | Int or Long | Int |
double | Double | Double |
String | String | String |
Uint8List | ByteArray | FlutterStandardTypedData |
List | List | Array |
Map | HashMap | Dictionary |
Your own classes cannot cross directly. Convert them to a map.
The three results #
Every native handler must answer exactly once:
| Native call | Dart sees |
|---|---|
result.success(value) | The future completes with the value |
result.error(code, message, details) | A PlatformException |
result.notImplemented() | A MissingPluginException |
Never leave a call unanswered. The Dart future would wait for ever.
EventChannel: a stream from native #
MethodChannel is request and response. For continuous data, such as sensor readings or Bluetooth events, use an EventChannel, which appears in Dart as a stream.
const _events = EventChannel('com.example.myapp/charging');
Stream<bool> get chargingChanges =>
_events.receiveBroadcastStream().map((event) => event as bool);
The native side implements a stream handler that sends events while someone is listening.
Type-safe channels with Pigeon #
String method names and untyped maps are easy to get wrong, and mistakes appear only at run time. The pigeon package, from the Flutter team, generates matching, type-checked Dart, Kotlin and Swift code from one Dart file that describes your API. Use it for anything beyond one or two methods.
Other ways to reach native code #
| Tool | For |
|---|---|
| Platform channels | Calling platform APIs written in Kotlin, Swift and so on |
dart:ffi | Calling C libraries directly, with no message passing |
jnigen, ffigen | Generating bindings to Java and Kotlin, or C and Objective-C libraries |
| Platform views | Embedding a native view, such as a map or web view, inside the Flutter UI |
dart:js_interop | Calling JavaScript on the web |
Keep it behind an interface #
Put channel code in one class, as BatteryService does. The rest of the app depends on that class, not on the channel, so tests can substitute a fake and platforms without an implementation can return a sensible default.
Before you write a channel #
Search pub.dev first. A maintained plugin already handles permissions, lifecycle and the edge cases of each OS version. Write your own channel when no plugin exists or when you must integrate a specific native SDK. If the result is general, consider publishing it as a plugin with flutter create --template=plugin.
Try it yourself #
Add a method channel with a getDeviceName method. Implement it on Android (using android.os.Build.MODEL) and on iOS (using UIDevice.current.name), and show the result in your app. Make the Dart side return “Unknown device” on platforms where it is not implemented.