Platform Channels: Method Channels, Event Channels & Pigeon
Bridging Flutter and native Android/iOS — calling platform APIs and streaming native data
Open interactive version (quiz + challenge)Real-world analogy
Platform channels are a bilingual interpreter between Flutter and native code. Flutter speaks Dart, Android speaks Kotlin/Java, iOS speaks Swift/ObjC. The interpreter (channel) stands in the middle, translates the message, waits for the response, and translates it back. Without this interpreter, they cannot communicate at all.
What is it?
Platform channels are a typed message-passing mechanism that allows Flutter (Dart) code to call native Android (Kotlin/Java) and iOS (Swift/ObjC) code — and receive responses or continuous streams back. They are the foundation of all Flutter plugins.
Real-world relevance
On an NFC asset recovery app, an EventChannel streamed NFC tag scan events from the Android NFC hardware to Flutter in real time. A MethodChannel handled the one-shot 'write data to tag' operation. Pigeon was used to generate type-safe bindings so the Dart team and Android team could work without runtime codec errors.
Key points
- Why platform channels exist — Flutter cannot access all device APIs directly — Bluetooth details, NFC, custom sensors, native audio engines, platform-specific security APIs. Channels bridge Dart and native code when the Dart/plugin ecosystem has no solution.
- MethodChannel — one-shot calls — MethodChannel is for request-response interactions: call a native method, get a result back. Think: get battery level, check biometric availability, trigger a native share sheet. The Dart side awaits the result.
- EventChannel — native streams — EventChannel is for continuous native-to-Dart data streams: accelerometer updates, NFC tag scans, Bluetooth device discoveries, network reachability changes. The Dart side receives a Stream.
- BasicMessageChannel — For raw message passing without the method call structure. Used for custom binary protocols or when you control both sides and want a simple ping-pong mechanism. Less common in production apps.
- Channel naming conventions — Use reverse-DNS format: 'com.example.myapp/battery'. The same string must match exactly on both Dart and native sides. A mismatch results in MissingPluginException — a common gotcha in interviews.
- Data type serialisation — Channels use StandardMessageCodec by default, which handles: null, bool, int, double, String, Uint8List, List, Map. Custom objects must be serialised to Map before sending. Sending an unsupported type causes a codec exception.
- Pigeon — type-safe code generation — Pigeon generates type-safe Dart, Kotlin, and Swift code from a Dart interface definition. Eliminates manual serialisation, string-based method names, and runtime type errors. The production standard for new platform channel code.
- Error handling in channels — Wrap native calls in try/catch PlatformException on the Dart side. On the native side, call result.error() with a code, message, and details. Always handle MissingPluginException for features not available on all platforms.
- Asynchronous execution on native — Never block the platform channel thread on Android (main thread). Dispatch heavy work to a background thread and call result.success() from the correct thread. Blocking the main thread causes ANR (Application Not Responding).
- When to use a plugin vs writing channels yourself — Always check pub.dev first — over 90% of common platform integrations have a maintained plugin. Write your own channel only when: no plugin exists, the existing plugin does not support your specific use case, or you need tight control for performance-critical native code.
Code example
// === Dart side: MethodChannel (one-shot) ===
class BatteryService {
static const _channel = MethodChannel('com.example.myapp/battery');
Future<int> getBatteryLevel() async {
try {
final level = await _channel.invokeMethod<int>('getBatteryLevel');
return level ?? -1;
} on PlatformException catch (e) {
throw BatteryException('Failed to get battery: ${e.message}');
} on MissingPluginException {
throw BatteryException('Battery API not available on this platform');
}
}
}
// === Android side (Kotlin): MethodChannel ===
// MainActivity.kt
class MainActivity : FlutterActivity() {
private val CHANNEL = "com.example.myapp/battery"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result ->
if (call.method == "getBatteryLevel") {
val batteryLevel = getBatteryLevel()
if (batteryLevel != -1) {
result.success(batteryLevel)
} else {
result.error("UNAVAILABLE", "Battery level not available", null)
}
} else {
result.notImplemented()
}
}
}
private fun getBatteryLevel(): Int {
val batteryManager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
return batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
}
}
// === Dart side: EventChannel (stream) ===
class NfcScanService {
static const _channel = EventChannel('com.example.myapp/nfc_scan');
Stream<NfcTag> get tagScans {
return _channel.receiveBroadcastStream().map((dynamic event) {
final data = Map<String, dynamic>.from(event as Map);
return NfcTag.fromMap(data);
});
}
}
// Usage in a BLoC or widget:
// nfcScanService.tagScans.listen((tag) {
// bloc.add(NfcTagScannedEvent(tag));
// });
// === Pigeon definition (pigeons/messages.dart) ===
// Run: dart run pigeon --input pigeons/messages.dart
import 'package:pigeon/pigeon.dart';
@HostApi()
abstract class DeviceInfoApi {
DeviceInfoResult getDeviceInfo();
}
class DeviceInfoResult {
final String model;
final int sdkVersion;
const DeviceInfoResult({required this.model, required this.sdkVersion});
}Line-by-line walkthrough
- 1. static const _channel = MethodChannel('com.example.myapp/battery') — the channel name is the address; must match exactly in native code
- 2. _channel.invokeMethod('getBatteryLevel') — typed generic ensures the return value is cast to int; crashes at runtime if native sends wrong type
- 3. on PlatformException catch (e) — catches errors sent by result.error() on the native side
- 4. on MissingPluginException — catches the case where native never registered this channel (important for platform-conditional features)
- 5. MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler — registers the native handler; must run inside configureFlutterEngine
- 6. result.notImplemented() — sent back to Dart when the method name is not recognised; triggers MissingPluginException on the Dart side
- 7. EventChannel('com.example.myapp/nfc_scan').receiveBroadcastStream() — returns a Dart Stream backed by the native EventChannel
- 8. Map.from(event as Map) — deserialises the Map sent from native into a typed Dart object
Spot the bug
// Dart side
const _channel = MethodChannel('com.example.app/sensors');
Future<double> getTemperature() async {
final temp = await _channel.invokeMethod('getTemperature');
return temp;
}
// Kotlin side
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.example.app/sensor")
.setMethodCallHandler { call, result ->
if (call.method == "getTemperature") {
result.success(23.5)
}
}Need a hint?
The method will always throw MissingPluginException even though both sides look correct. Compare the channel names character by character.
Show answer
Bug: The Dart side uses channel name 'com.example.app/sensors' (plural) but the Kotlin side registers 'com.example.app/sensor' (singular). This one character difference means the Dart call never reaches the Kotlin handler. The fix is to make both strings identical. This is one of the most common platform channel bugs and is why defining channel names as shared constants (or using Pigeon) is best practice.
Explain like I'm 5
Imagine Flutter is an English speaker and Android is a Korean speaker. They cannot talk directly. Platform channels are a translator who stands between them. For a one-time question ('what time is it?') you use a MethodChannel. For a live radio broadcast ('tell me every time a car passes') you use an EventChannel. Pigeon writes the translator's phrasebook automatically so nobody makes mistakes.
Fun fact
The Flutter plugin ecosystem on pub.dev has over 4,000 plugins that use platform channels under the hood. When you add camera, bluetooth, or local_auth to pubspec.yaml, you are using platform channels that other developers wrote — each one is a Dart-to-native bridge.
Hands-on challenge
Design a platform channel integration for reading a device's ambient light sensor. Specify: (1) channel type and name, (2) data format sent from native to Dart, (3) error handling strategy, (4) whether you would use Pigeon and why.
More resources
- Platform Channels — Flutter Docs (Flutter Docs)
- Pigeon package (pub.dev)
- Writing custom platform-specific code (Flutter Docs)
- EventChannel API (Flutter API)