Platform Channels
Platform channels for bridging Flutter with native Android (Kotlin) and iOS (Swift) code
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 linesPlatform 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
invokeMethodfuture never completes, and the app appears to hang. Always include a default case that callsresult.notImplemented(). -
Passing unsupported types without serialization: Custom classes, enums,
DateTime, andDurationare 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
MissingPluginExceptionat 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
PlatformExceptionin Dart and returnFlutterErrorfrom 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
TestDefaultBinaryMessengerBindingto mock native responses in widget tests.
Common Pitfalls
- Passing unsupported types: Custom classes, enums, and
DateTimeare 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
invokeMethodwithoutawait: 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
MissingPluginExceptionat runtime with no compile-time warning.
Install this skill directly: skilldb add flutter-skills
Related Skills
Animations
Implicit, explicit, and hero animation patterns for polished Flutter UIs
Local Storage
Hive, SharedPreferences, and Drift patterns for local data persistence in Flutter
Navigation
GoRouter navigation patterns for declarative, deep-linkable Flutter routing
Networking
Dio HTTP client and API integration patterns for Flutter applications
State Management
Riverpod and Bloc state management patterns for scalable Flutter applications
Testing
Widget testing, unit testing, and integration testing patterns for Flutter apps