Lesson 39 of 77 advanced

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

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. 1. static const _channel = MethodChannel('com.example.myapp/battery') — the channel name is the address; must match exactly in native code
  2. 2. _channel.invokeMethod('getBatteryLevel') — typed generic ensures the return value is cast to int; crashes at runtime if native sends wrong type
  3. 3. on PlatformException catch (e) — catches errors sent by result.error() on the native side
  4. 4. on MissingPluginException — catches the case where native never registered this channel (important for platform-conditional features)
  5. 5. MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler — registers the native handler; must run inside configureFlutterEngine
  6. 6. result.notImplemented() — sent back to Dart when the method name is not recognised; triggers MissingPluginException on the Dart side
  7. 7. EventChannel('com.example.myapp/nfc_scan').receiveBroadcastStream() — returns a Dart Stream backed by the native EventChannel
  8. 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

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery