Skip to main content
Technology & EngineeringFlutter335 lines

Platform Channels

Platform channels for bridging Flutter with native Android (Kotlin) and iOS (Swift) code

Quick Summary26 lines
You are an expert in Flutter platform channels for building cross-platform apps with Flutter.

## Key Points

- **Use Pigeon for any non-trivial API.** It catches type mismatches at compile time and generates clean, idiomatic native code.
- **Keep channel names namespaced** (e.g., `com.example.app/feature`) to avoid collisions with plugins.
- **Handle errors on both sides.** Always catch `PlatformException` in Dart and return `FlutterError` from native code for known failure modes.
- **Unregister native listeners in `onCancel`** (EventChannel) and when the Flutter engine detaches to prevent memory leaks and crashes.
- **Test platform channels** with `TestDefaultBinaryMessengerBinding` to mock native responses in widget tests.
- **Passing unsupported types**: Custom classes, enums, and `DateTime` are not supported by the standard codec. Serialize them to maps or strings, or use Pigeon.
- **Blocking the platform UI thread**: Long-running native work (image processing, file I/O) should run on a background thread (Kotlin coroutine, Swift async) and return the result via the channel.
- **Forgetting `result.notImplemented()`**: If the native handler does not recognize a method and does not call any result method, the Dart side hangs forever.
- **Using `invokeMethod` without `await`**: Fire-and-forget calls still need error handling. At minimum, add `.catchError()` to prevent unhandled exceptions.
- **Channel name mismatch**: The string must be identical on Dart and native sides. A single typo causes `MissingPluginException` at runtime with no compile-time warning.

## Quick Example

```dart
class BatteryService {
  final _api = BatteryHostApi();

  Future<BatteryInfo> getBatteryInfo() => _api.getBatteryInfo();
}
```
skilldb get flutter-skills/Platform ChannelsFull skill: 335 lines
Paste into your CLAUDE.md or agent config

Platform Channels — Flutter

You are an expert in Flutter platform channels for building cross-platform apps with Flutter.

Core Philosophy

Platform channels exist because Flutter cannot replace every native API. While Flutter's widget system handles rendering, platform channels provide the bridge to native capabilities -- Bluetooth, biometrics, platform-specific sensors, and system services that have no Flutter equivalent. The bridge should be as thin as possible: call the native API, return the result, and keep all business logic in Dart. The more native code you write, the more platform-specific maintenance you carry, and the less benefit you get from Flutter's cross-platform model.

Type safety across the bridge is the most important quality attribute. The default MethodChannel API uses string-based method names and dynamic arguments, which means typos, type mismatches, and missing parameters are only caught at runtime. Pigeon solves this by generating typed bindings from a shared Dart specification. When you define a BatteryHostApi in Pigeon, it generates a Dart interface, a Kotlin implementation skeleton, and a Swift implementation skeleton, all with matching types. A type mismatch is a compile error, not a runtime crash.

Error handling must be implemented on both sides of the bridge. On the native side, every method handler must call either result.success(), result.error(), or result.notImplemented(). If none of these is called, the Dart side hangs forever waiting for a response. On the Dart side, every invokeMethod call must handle PlatformException for known errors and MissingPluginException for unregistered methods. Silent failures on either side create bugs that are extremely difficult to diagnose.

Anti-Patterns

  • Using string-based MethodChannel for non-trivial APIs: When a platform channel has more than two or three methods, string-based method dispatch becomes error-prone. A typo in the method name string causes a silent MissingPluginException, and type mismatches crash at runtime. Use Pigeon for any channel with multiple methods or complex argument types.

  • Blocking the platform UI thread with heavy native work: Platform channel handlers run on the platform's main thread by default. Performing image processing, file I/O, or network calls synchronously in a method handler freezes the native UI (which also freezes Flutter's rendering). Dispatch heavy work to a background thread (Kotlin coroutine, Swift async) and return the result via the channel.

  • Forgetting to call result.notImplemented() for unknown methods: If the native handler receives a method name it does not recognize and does not call any result method, the Dart side waits for a response that never comes. The invokeMethod future never completes, and the app appears to hang. Always include a default case that calls result.notImplemented().

  • Passing unsupported types without serialization: Custom classes, enums, DateTime, and Duration are not part of the standard message codec. Passing them across the channel silently fails or crashes. Serialize them to supported primitives (String, int, Map, List) or use Pigeon, which handles serialization automatically.

  • Channel name mismatches between Dart and native code: The channel name string must be identical on both sides. A single character difference causes MissingPluginException at runtime with no compile-time warning. Use a shared constant or Pigeon's code generation to eliminate this class of bugs entirely.

Overview

Platform channels allow Flutter code to call native Android (Kotlin/Java) and iOS (Swift/Objective-C) APIs that have no Flutter plugin. This skill covers MethodChannel for RPC-style calls, EventChannel for streaming native data to Dart, and the Pigeon code generator for type-safe bindings.

Core Concepts

MethodChannel — Request/Response Communication

MethodChannel sends a message from Dart to native (or vice versa) and awaits a response.

Dart side:

class BatteryService {
  static const _channel = MethodChannel('com.example.app/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 level: ${e.message}');
    }
  }

  Future<bool> isCharging() async {
    final result = await _channel.invokeMethod<bool>('isCharging');
    return result ?? false;
  }
}

Android (Kotlin) side:

// MainActivity.kt
class MainActivity : FlutterActivity() {
    private val CHANNEL = "com.example.app/battery"

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
            .setMethodCallHandler { call, result ->
                when (call.method) {
                    "getBatteryLevel" -> {
                        val level = getBatteryLevel()
                        if (level != -1) result.success(level)
                        else result.error("UNAVAILABLE", "Battery level not available", null)
                    }
                    "isCharging" -> result.success(isCharging())
                    else -> result.notImplemented()
                }
            }
    }

    private fun getBatteryLevel(): Int {
        val manager = getSystemService(BATTERY_SERVICE) as BatteryManager
        return manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
    }

    private fun isCharging(): Boolean {
        val manager = getSystemService(BATTERY_SERVICE) as BatteryManager
        return manager.isCharging
    }
}

iOS (Swift) side:

// AppDelegate.swift
@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.app/battery",
            binaryMessenger: controller.binaryMessenger
        )

        channel.setMethodCallHandler { [weak self] (call, result) in
            switch call.method {
            case "getBatteryLevel":
                UIDevice.current.isBatteryMonitoringEnabled = true
                let level = Int(UIDevice.current.batteryLevel * 100)
                if level >= 0 {
                    result(level)
                } else {
                    result(FlutterError(code: "UNAVAILABLE",
                                       message: "Battery level not available",
                                       details: nil))
                }
            case "isCharging":
                UIDevice.current.isBatteryMonitoringEnabled = true
                let state = UIDevice.current.batteryState
                result(state == .charging || state == .full)
            default:
                result(FlutterMethodNotImplemented)
            }
        }

        GeneratedPluginRegistrant.register(with: self)
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
}

EventChannel — Streaming Data from Native

EventChannel pushes a continuous stream of events from native code to Dart.

Dart side:

class SensorService {
  static const _channel = EventChannel('com.example.app/accelerometer');

  Stream<AccelerometerEvent> get accelerometerEvents {
    return _channel.receiveBroadcastStream().map((event) {
      final data = event as Map;
      return AccelerometerEvent(
        x: (data['x'] as num).toDouble(),
        y: (data['y'] as num).toDouble(),
        z: (data['z'] as num).toDouble(),
      );
    });
  }
}

class AccelerometerEvent {
  const AccelerometerEvent({required this.x, required this.y, required this.z});
  final double x, y, z;
}

Android (Kotlin) side:

class AccelerometerPlugin(private val context: Context) : EventChannel.StreamHandler {
    private var sensorManager: SensorManager? = null
    private var listener: SensorEventListener? = null

    override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
        sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
        val sensor = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)

        listener = object : SensorEventListener {
            override fun onSensorChanged(event: SensorEvent) {
                events?.success(mapOf(
                    "x" to event.values[0],
                    "y" to event.values[1],
                    "z" to event.values[2]
                ))
            }
            override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
        }

        sensorManager?.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_UI)
    }

    override fun onCancel(arguments: Any?) {
        sensorManager?.unregisterListener(listener)
        sensorManager = null
        listener = null
    }
}

Implementation Patterns

Pigeon — Type-Safe Code Generation

Pigeon generates platform channel boilerplate from a shared Dart interface definition, eliminating stringly-typed method calls.

Define the API (pigeons/battery_api.dart):

import 'package:pigeon/pigeon.dart';

@ConfigurePigeon(PigeonOptions(
  dartOut: 'lib/src/platform/battery_api.g.dart',
  kotlinOut: 'android/app/src/main/kotlin/com/example/app/BatteryApi.g.kt',
  swiftOut: 'ios/Runner/BatteryApi.g.swift',
))
class BatteryInfo {
  late int level;
  late bool isCharging;
}

@HostApi()
abstract class BatteryHostApi {
  BatteryInfo getBatteryInfo();
}

@FlutterApi()
abstract class BatteryFlutterApi {
  void onBatteryLow(int level);
}

Generated Dart usage:

class BatteryService {
  final _api = BatteryHostApi();

  Future<BatteryInfo> getBatteryInfo() => _api.getBatteryInfo();
}

Implement on Android (Kotlin):

class BatteryHostApiImpl(private val context: Context) : BatteryHostApi {
    override fun getBatteryInfo(): BatteryInfo {
        val manager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
        return BatteryInfo(
            level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY).toLong(),
            isCharging = manager.isCharging
        )
    }
}

Passing Complex Data

// Dart side - sending structured data
Future<void> saveUserNatively(User user) async {
  await _channel.invokeMethod('saveUser', {
    'id': user.id,
    'name': user.name,
    'email': user.email,
    'tags': user.tags, // List<String> is supported
  });
}

Supported types across the standard message codec: null, bool, int, double, String, Uint8List, Int32List, Int64List, Float64List, List, Map.

Calling Dart from Native

// Dart side - receiving calls from native
class NativeCallbackHandler {
  NativeCallbackHandler() {
    const MethodChannel('com.example.app/callbacks')
        .setMethodCallHandler(_handleNativeCall);
  }

  Future<dynamic> _handleNativeCall(MethodCall call) async {
    switch (call.method) {
      case 'onDeepLink':
        final url = call.arguments as String;
        _handleDeepLink(url);
        return null;
      case 'onPushNotification':
        final payload = Map<String, dynamic>.from(call.arguments as Map);
        _handleNotification(payload);
        return null;
      default:
        throw MissingPluginException('Unknown method: ${call.method}');
    }
  }
}

Background Isolate Channels

For heavy native work that should not block the platform thread:

Future<Uint8List> processImageNatively(Uint8List imageData) async {
  // Run the channel call on a background isolate
  return Isolate.run(() async {
    // Note: BackgroundIsolateBinaryMessenger must be initialized
    final rootIsolateToken = RootIsolateToken.instance!;
    BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken);

    const channel = MethodChannel('com.example.app/image_processor');
    final result = await channel.invokeMethod<Uint8List>('processImage', imageData);
    return result!;
  });
}

Best Practices

  • Use Pigeon for any non-trivial API. It catches type mismatches at compile time and generates clean, idiomatic native code.
  • Keep channel names namespaced (e.g., com.example.app/feature) to avoid collisions with plugins.
  • Handle errors on both sides. Always catch PlatformException in Dart and return FlutterError from native code for known failure modes.
  • Unregister native listeners in onCancel (EventChannel) and when the Flutter engine detaches to prevent memory leaks and crashes.
  • Test platform channels with TestDefaultBinaryMessengerBinding to mock native responses in widget tests.

Common Pitfalls

  • Passing unsupported types: Custom classes, enums, and DateTime are not supported by the standard codec. Serialize them to maps or strings, or use Pigeon.
  • Blocking the platform UI thread: Long-running native work (image processing, file I/O) should run on a background thread (Kotlin coroutine, Swift async) and return the result via the channel.
  • Forgetting result.notImplemented(): If the native handler does not recognize a method and does not call any result method, the Dart side hangs forever.
  • Using invokeMethod without await: Fire-and-forget calls still need error handling. At minimum, add .catchError() to prevent unhandled exceptions.
  • Channel name mismatch: The string must be identical on Dart and native sides. A single typo causes MissingPluginException at runtime with no compile-time warning.

Install this skill directly: skilldb add flutter-skills

Get CLI access →