Platform Channels
When Dart is not enough
Section titled “When Dart is not enough”Flutter draws its own pixels, but some things live only in the native OS — the battery level, a specific sensor, a platform SDK, the secure enclave. To reach them, Flutter uses platform channels: a typed, asynchronous message pipe between your Dart code and native (Kotlin/Java on Android, Swift/Objective-C on iOS).
The model is the same async message passing you already know from Dart: you send a message, the native side handles it, and a Future completes with the reply. Nothing blocks the UI thread.
flowchart LR
dart["Dart
invokeMethod('getBattery')"] -->|message| platform["platform channel"]
platform --> native["native code
reads battery level"]
native -->|reply| platform
platform -->|Future completes| dart MethodChannel — call a native method, proven
Section titled “MethodChannel — call a native method, proven”A MethodChannel names a pipe and lets you invokeMethod on it. The call is async and returns a Future with the native result. Arguments and results are standard types (numbers, strings, lists, maps) serialized across the boundary by a MethodCodec — the same codec the mock handler below round-trips through, so the test proves real (de)serialization, not just a stub.
import 'package:flutter/services.dart';
const batteryChannel = MethodChannel('samples.app/battery');
Future<int> getBatteryLevel() async { final level = await batteryChannel.invokeMethod<int>('getBatteryLevel'); return level ?? -1;}flutter_test ships a real mock messenger for exactly this: TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, handler) intercepts the channel and lets your test play the native side.
import 'package:flutter/services.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/battery_channel.dart';
void main() { TestWidgetsFlutterBinding.ensureInitialized(); final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
tearDown(() { // Always unregister — a handler left behind leaks into the next test. messenger.setMockMethodCallHandler(batteryChannel, null); });
test('a mocked native handler answers invokeMethod through the real codec round trip', () async { messenger.setMockMethodCallHandler(batteryChannel, (call) async { expect(call.method, 'getBatteryLevel'); return 76; }); expect(await getBatteryLevel(), 76); });
test('a native-side error surfaces as a real PlatformException', () async { messenger.setMockMethodCallHandler(batteryChannel, (call) async { throw PlatformException(code: 'UNAVAILABLE', message: 'Battery info unavailable'); }); expect( () => batteryChannel.invokeMethod<int>('getBatteryLevel'), throwsA(isA<PlatformException>().having((e) => e.code, 'code', 'UNAVAILABLE')), ); });
test('with NO handler registered at all, invokeMethod throws the real MissingPluginException text', () async { // No setMockMethodCallHandler call in this test — nothing is listening. expect( () => batteryChannel.invokeMethod<int>('getBatteryLevel'), throwsA(isA<MissingPluginException>().having( (e) => e.toString(), 'message', 'MissingPluginException(No implementation found for method getBatteryLevel on channel samples.app/battery)', )), ); });}✓ a mocked native handler answers invokeMethod through the real codec round trip✓ a native-side error surfaces as a real PlatformException✓ with NO handler registered at all, invokeMethod throws the real MissingPluginException text
00:00 +3: All tests passed!That third test’s exact text — MissingPluginException(No implementation found for method getBatteryLevel on channel samples.app/battery) — is MissingPluginException’s real toString() (packages/flutter/lib/src/services/message_codec.dart: 'MissingPluginException($message)'), and it is exactly what you get in a real app that calls a channel no platform implementation (no plugin, no native code) ever registered — not a hypothetical, the literal message every “plugin not found” bug report contains.
EventChannel — a stream of native events, proven
Section titled “EventChannel — a stream of native events, proven”Where MethodChannel is one call and one reply, an EventChannel delivers a stream of events from native — sensor updates, connectivity changes, location. It maps to a Dart Stream, so you consume it exactly like any other stream.
import 'package:flutter/services.dart';
const accelerometerChannel = EventChannel('samples.app/accelerometer');
Stream<double> accelerometerReadings() { return accelerometerChannel.receiveBroadcastStream().cast<double>();}flutter_test has the equivalent mock for streams: setMockStreamHandler(channel, MockStreamHandler.inline(onListen: ...)) — onListen receives a MockStreamHandlerEventSink with success/error/endOfStream, mimicking the real native EventSink API.
import 'package:flutter_test/flutter_test.dart';import 'package:my_app/accelerometer_channel.dart';
void main() { TestWidgetsFlutterBinding.ensureInitialized(); final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
test('EventChannel stream delivers mocked native events, in order, then closes', () async { messenger.setMockStreamHandler( accelerometerChannel, MockStreamHandler.inline( onListen: (Object? arguments, MockStreamHandlerEventSink sink) { sink.success(1.0); sink.success(2.0); sink.endOfStream(); }, ), );
final received = await accelerometerReadings().toList(); expect(received, [1.0, 2.0]); });}✓ EventChannel stream delivers mocked native events, in order, then closes
00:00 +1: All tests passed!Pigeon: type-safe channels, generated — run for real
Section titled “Pigeon: type-safe channels, generated — run for real”Hand-written invokeMethod('getBatteryLevel') is stringly-typed on both sides — a method-name typo or a mismatched argument shape is a runtime failure, not a compile error, on both the Dart and the native side. Pigeon (current: 29.0.4 on pub.dev) generates a typed Dart API and the matching native interface (Kotlin, Swift, and others) from one Dart file, so a rename or type change fails to compile everywhere it’s used, instead of failing silently at the channel boundary.
// pigeons/battery.dart — the Pigeon *definition* file (not part of the shipped app)import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(PigeonOptions( dartOut: 'lib/battery.g.dart', kotlinOut: 'android/app/src/main/kotlin/com/example/app/Battery.g.kt', kotlinOptions: KotlinOptions(package: 'com.example.app'), swiftOut: 'ios/Runner/Battery.g.swift',))@HostApi()abstract class BatteryHostApi { int getBatteryLevel();}Running the generator for real (dart run pigeon --input pigeons/battery.dart, pigeon 29.0.4, no console output on success — the three files below are the actual, unedited result):
// generated by Pigeon — lib/battery.g.dart (real output, trimmed to the parts you call)// Autogenerated from Pigeon (v29.0.4), do not edit directly.
class BatteryHostApi { BatteryHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger; final String pigeonVar_messageChannelSuffix;
Future<int> getBatteryLevel() async { final pigeonVar_channelName = 'dev.flutter.pigeon.pigeon_demo.BatteryHostApi.getBatteryLevel$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel<Object?>( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final pigeonVar_replyList = await pigeonVar_channel.send(null) as List<Object?>?; final pigeonVar_replyValue = _extractReplyValueOrThrow( pigeonVar_replyList, pigeonVar_channelName, isNullValid: false, ); return pigeonVar_replyValue! as int; }}// generated by Pigeon — android/.../Battery.g.kt (real output, trimmed)/** Generated interface from Pigeon that represents a handler of messages from Flutter. */interface BatteryHostApi { fun getBatteryLevel(): Long
companion object { @JvmOverloads fun setUp(binaryMessenger: BinaryMessenger, api: BatteryHostApi?, messageChannelSuffix: String = "") { val channel = BasicMessageChannel<Any?>( binaryMessenger, "dev.flutter.pigeon.pigeon_demo.BatteryHostApi.getBatteryLevel", codec, ) if (api != null) { channel.setMessageHandler { _, reply -> val wrapped: List<Any?> = try { listOf(api.getBatteryLevel()) } catch (exception: Throwable) { BatteryPigeonUtils.wrapError(exception) } reply.reply(wrapped) } } else { channel.setMessageHandler(null) } } }}You implement BatteryHostApi as a plain Kotlin class MainBatteryApi : BatteryHostApi { override fun getBatteryLevel(): Long = ... } and call BatteryHostApi.setUp(binaryMessenger, MainBatteryApi()) once — the channel name string, the codec, and the message envelope are never written or read by hand on either side. A Swift stub (ios/Runner/Battery.g.swift) is generated the same way from the same pigeons/battery.dart definition.
dev_dependencies: pigeon: ^29.0.4Kotlin, built in since Flutter 3.44
Section titled “Kotlin, built in since Flutter 3.44”Android plugin/host code used to require the separate Kotlin Gradle plugin to be configured by hand. Flutter’s own 3.44 release notes list this directly — “Add Support For Built-in Kotlin” (landed, briefly reverted, then re-landed in the same 3.44 cycle per the release notes’ own changelog entries) — meaning a fresh Flutter project’s Android host code can use Kotlin (as the generated Battery.g.kt above does) without you adding and version-pinning a Kotlin Gradle plugin dependency yourself first.
Plugins vs. raw channels, and federated plugins
Section titled “Plugins vs. raw channels, and federated plugins”You rarely write channel or Pigeon code by hand for a common need. A plugin is a package that wraps platform channels (increasingly Pigeon-generated) plus the native implementation behind a clean Dart API — battery_plus, geolocator, camera. Reach for a well-maintained plugin first; write a raw channel (or a Pigeon definition) only for something bespoke to your app.
Most first-party Flutter plugins today are federated: split into an app-facing package, a platform-interface package, and one implementation package per platform — quoting Flutter’s own package-development docs directly: “Federated plugins are a way of splitting the API of a plugin into a platform interface, independent platform implementations of that interface, and an app-facing interface that uses the registered implementation of the running platform.” You depend on the app-facing package; the right platform implementation is resolved for you. A platform implementation package’s pubspec.yaml declares which platform(s) it covers under flutter: plugin: platforms: — the same real shape the docs show:
# A federated platform-implementation package's pubspec.yamlflutter: plugin: implements: hello platforms: windows: pluginClass: HelloPlugin# The app-facing package's pubspec.yaml — endorses a default implementation per platformflutter: plugin: platforms: android: package: com.example.hello pluginClass: HelloPlugin ios: pluginClass: HelloPlugin windows: default_package: hello_windowsdart:ffi — the other way to reach native code
Section titled “dart:ffi — the other way to reach native code”Platform channels are for calling into Android/iOS-specific APIs through Kotlin/Swift glue code. For calling a native C library directly (no Kotlin/Swift layer at all — image codecs, a crypto library, an existing C/C++ engine), Dart has a separate, lower-level mechanism: dart:ffi. Quoting the official Dart FFI/C-interop docs directly: “Dart mobile, command-line, and server apps running on the Dart Native platform can use the dart:ffi library to call native C APIs, and to read, write, allocate, and deallocate native memory.” It’s a different tool for a different job — platform channels talk to platform-specific SDKs through native glue code you write; dart:ffi talks directly to a C ABI, no channel or native glue required (though it still needs the C library itself bundled for the target platform).
BackgroundIsolateBinaryMessenger — platform channels off the main isolate
Section titled “BackgroundIsolateBinaryMessenger — platform channels off the main isolate”A platform channel call normally runs on the root isolate. Calling one from a background isolate (inside compute() or Isolate.run, covered in the async-and-performance module) needs one extra step: BackgroundIsolateBinaryMessenger.ensureInitialized(token), where token is a RootIsolateToken obtained on the root isolate before spawning the background isolate (ServicesBinding.rootIsolateToken, quoting the class’s own doc comment in the installed 3.47.5 SDK, packages/flutter/lib/src/services/_background_isolate_binary_messenger_io.dart) and passed across. Without calling ensureInitialized first, BackgroundIsolateBinaryMessenger.instance throws — quoting the same source directly: “The BackgroundIsolateBinaryMessenger.instance value is invalid until BackgroundIsolateBinaryMessenger.ensureInitialized is executed.”
// On the root isolate, before spawning:final rootToken = ServicesBinding.rootIsolateToken!;
// Inside the background isolate's entry point:BackgroundIsolateBinaryMessenger.ensureInitialized(rootToken);// Platform channel calls now work on THIS isolate too.Declaring assets
Section titled “Declaring assets”Not native, but part of app structure: images, fonts, and JSON you bundle must be declared in pubspec.yaml so they ship with the app.
flutter: assets: - assets/images/logo.png - assets/data/ fonts: - family: Inter fonts: - asset: assets/fonts/Inter-Regular.ttf// Then load them by their declared path:Image.asset('assets/images/logo.png');Mental Model
Section titled “Mental Model”A platform channel is invokeMethod/EventChannel on the Dart side and a registered handler keyed by the exact same channel-name string on the native side — nothing connects them at compile time, which is exactly why MissingPluginException, proven above, is the single most common platform-channel bug: the channel name (or platform) simply has no listener. Pigeon doesn’t change that wire format at all — it’s the same named-channel, same-codec message pipe underneath — it just generates both sides’ string-matching, type-casting glue code from one shared definition, so a mismatch becomes a compile error in generated code instead of a runtime string mismatch. dart:ffi is a different tree entirely: no channel, no native glue, a direct call into a C ABI.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Hand-written MethodChannel/EventChannel | No extra dependency, full control | Stringly-typed on both sides; a mismatch is a runtime MissingPluginException/crash, not a compile error |
| Pigeon-generated channels | Compile-time-safe on both Dart and native sides, one source of truth | Codegen step (dart run pigeon) in the loop; still the same channel underneath |
A maintained plugin (battery_plus, geolocator, …) | Zero channel code to write or maintain yourself | Depends on someone else’s maintenance; less control over exact native behavior |
dart:ffi | Direct C ABI access, no platform glue code at all | Lower-level (manual memory management); only for actual C libraries, not platform SDKs |
BackgroundIsolateBinaryMessenger | Platform channel calls work off the main isolate | One extra ensureInitialized step with a token passed from the root isolate first |
Common Misconceptions
Section titled “Common Misconceptions”- “A
MissingPluginExceptionmeans the plugin package is missing frompubspec.yaml.” It means nothing answered that exact channel name — proven above, the same exception fires from a hand-rolled channel with zero plugin involved, whenever there’s simply no handler registered (native-side code not yet written, wrong platform, or a typo in the channel name). - “Pigeon replaces platform channels with something else.” It generates the same
MethodChannel/BasicMessageChannelcode you’d otherwise write by hand — proven above, the generated Dart still constructs aBasicMessageChannelwith a channel-name string and a codec underneath. - “You can call a platform channel from any isolate without special setup.” Only the root isolate gets one for free — a background isolate needs
BackgroundIsolateBinaryMessenger.ensureInitialized(token)first, or.instancethrows aStateError, per the source quoted above. - “
dart:ffiand platform channels solve the same problem, just at different speeds.” They solve different problems: platform channels reach platform-specific SDKs (Android/iOS APIs) via native glue code;dart:ffireaches a C ABI directly, with no platform glue and no channel involved at all. - “Federated plugins are Flutter reinventing plugin architecture without reason.” They solve a real problem: one app-facing API with cleanly swappable platform implementations (including community-contributed platforms) — proven by the real two-file split (
implements: helloon the platform side,default_package:on the app-facing side) quoted from the docs above.
Reading AI-generated code
Section titled “Reading AI-generated code”A very common AI-generated pattern calls a platform channel without ever wrapping it in a try/catch — it works in the demo (a device with the real plugin installed) and throws an unhandled exception the first time it runs somewhere the native side isn’t wired up (a different platform, a fresh CI simulator, a plugin that silently failed to register).
// AI-generated: "get the battery level and show it"Future<void> showBatteryLevel() async { final level = await batteryChannel.invokeMethod<int>('getBatteryLevel'); print('Battery: $level%');}This compiles clean and works fine in the common case. Proven above, the real failure mode when nothing answers the channel is MissingPluginException, not a null result — print('Battery: $level%') never even runs; the await rethrows before reaching that line. The fix wraps both realistic failure shapes:
Future<void> showBatteryLevel() async { try { final level = await batteryChannel.invokeMethod<int>('getBatteryLevel'); print('Battery: $level%'); } on MissingPluginException { print('No native battery implementation registered on this platform.'); } on PlatformException catch (e) { print('Native side reported an error: ${e.message}'); }}🐛 Spot the bug
// AI-generated: "read accelerometer data off the main thread to avoid jank"Future<void> startAccelerometerInBackground() async {await Isolate.run(() { final events = EventChannel('samples.app/accelerometer'); events.receiveBroadcastStream().listen((e) => print(e));});}Show the answer
Platform channel calls only work on an isolate that has a registered BinaryMessenger — the root isolate gets one automatically, but Isolate.run’s background isolate does NOT, unless BackgroundIsolateBinaryMessenger.ensureInitialized(token) is called first with a RootIsolateToken obtained on the root isolate BEFORE spawning. This snippet never calls it, and also never captures the token (which can only be read on the root isolate, not from inside the spawned closure) — so the EventChannel call inside Isolate.run throws BackgroundIsolateBinaryMessenger.instance is invalid until ensureInitialized is executed. The fix: capture final token = ServicesBinding.rootIsolateToken!; on the root isolate, pass token into the isolate closure, and call BackgroundIsolateBinaryMessenger.ensureInitialized(token) as the very first line inside it, exactly as shown in this lesson.