Native-Flutter communication
Method channels, Pigeon, and direct FFI - how to move data and events across the native-Flutter boundary, and which one to reach for.
Everything that crosses the native-Flutter boundary travels over one of a small set of mechanisms. They range from quick but untyped to fast but low-level, and picking the wrong one can cause a lot of churn: stringly-typed channels that silently drift apart, or an FFI binding where a plain method channel would have been enough. This page lays out the options and when each one fits.
If you have not yet read how platform channels and Dart isolates actually work, start there - the trade-offs below follow directly from those two concepts.
Overview: which mechanism, when
There are three families of mechanisms:
- Channels (
MethodChannel,EventChannel,BasicMessageChannel) - the built-in, asynchronous message-passing primitives. Untyped by default. - Pigeon - a code generator that puts a type-safe, multi-language contract on top of channels.
- Direct FFI (
ffigen,jnigen) - synchronous calls straight into native code, bypassing channels and serialization entirely.
| Mechanism | Type-safe | Sync / async | Codegen | Best for |
|---|---|---|---|---|
MethodChannel | No (manual) | Async | No | Throwaway prototypes only - prefer Pigeon |
EventChannel | No (manual) | Async (stream) | No | Raw native → Dart streams - prefer Pigeon's @EventChannelApi |
| Pigeon | Yes | Async | Yes | Structured APIs across Dart/Swift/Kotlin that evolve over time |
ffigen | Yes (from C headers) | Sync | Yes | Calling C/C++/Rust libraries; perf-critical, large buffers |
jnigen | Yes (from Java/Kotlin) | Sync | Yes | Calling existing Java/Kotlin/Android-SDK APIs directly |
The decision usually comes down to two questions, asked in order:
- Are you linking a self-contained native code library for computation - a C/C++/Rust library over a C ABI, or a JVM class you want to call directly - or chasing a measured hot path where serialization is the bottleneck? Then use direct FFI (
ffigen/jnigen). The test is compute that you link and call in-process, not "is there a native library involved" - most platform integrations (camera, location, sensors) wrap native libraries too, yet they belong on a channel because they are asynchronous, stateful, and main-thread-bound. See why FFI is not the default boundary. - Is it anything else that crosses the boundary - a method call or a native → Dart event stream? Then use Pigeon:
@HostApi/@FlutterApifor calls,@EventChannelApifor streams.
Notice that no branch lands on a raw MethodChannel or EventChannel. This is deliberate - we discourage using the raw channels. They are the untyped primitives that Pigeon generates its code on top of, and Pigeon covers both request/response calls (@HostApi/@FlutterApi) and native → Dart streaming (@EventChannelApi, which emits a typed Stream<T> backed by an EventChannel). Once anything crosses the boundary - a single method or a stream of events - Pigeon does the same job with a typed contract for roughly the same effort, and the string names and dynamic payloads the raw channels force on you are exactly the silent drift Pigeon exists to prevent. A bare channel can be fine for a quick spike you intend to delete; for anything you mean to keep, use Pigeon.
So, as a rule of thumb: reach for Pigeon by default for anything that crosses the boundary, calls and streams alike, and use FFI only when you are talking to a native library or chasing a measured hot path - not as a general way to call platform services.
MethodChannels
A MethodChannel is the most direct use of the platform-channel primitive: Dart invokes a named method with arguments and awaits a result; the native side registers a handler for that channel and replies. EventChannel is the streaming variant (native pushes a sequence of events to a Dart Stream), and BasicMessageChannel is the raw "send a message, get a message back" form.
// Dart side
const channel = MethodChannel('com.example.app/battery');
Future<int> batteryLevel() async {
// Untyped: the method name and the return type are a convention,
// not something the compiler checks against the native side.
return await channel.invokeMethod<int>('getBatteryLevel') ?? -1;
}let channel = FlutterMethodChannel(
name: "com.example.app/battery",
binaryMessenger: controller.binaryMessenger)
channel.setMethodCallHandler { call, result in
switch call.method {
case "getBatteryLevel":
result(self.currentBatteryLevel())
default:
result(FlutterMethodNotImplemented)
}
}What to keep in mind:
- It is untyped. The method name is a string and the arguments are a
dynamicmap serialized through theStandardMessageCodec, which only understands primitives, lists, and maps. Both sides must agree on the shape by hand, and nothing fails at compile time when they drift apart. - It is per-engine. A channel is bound to one engine's binary messenger, so in a multi-engine setup (see Core concepts) you register the handler on each engine that needs it.
- It is asynchronous and runs on the platform thread. Handlers execute on the host's main thread by default; long work there will jank the UI, so offload it.
- Errors cross the boundary as
PlatformException. The native side signals failure (result(FlutterError…)/result.), which surfaces in Dart as a catchable exception.error(…) - At high call volume, calls can vanish without a trace. One team we interviewed pushed tracing and analytics events over a channel and found only around 80% of them arrived, with no error on either side. Don't route high-volume telemetry through per-event channel calls - batch them, or keep telemetry on the side that produces it.
A raw MethodChannel or EventChannel can be convenient for a throwaway spike, but that is about the extent of it. The moment the code is meant to last - more than a method or two, or data shapes richer than a flat map - the manual, untyped bookkeeping becomes a liability, and Pigeon removes it for roughly the same effort (@HostApi/@FlutterApi for calls, @EventChannelApi for streams). In production code, a bare channel is usually a sign that Pigeon should have been used instead.
Pigeon (type-safe code generation)
Pigeon is a code generator maintained by the Flutter team. You describe the boundary once, as an annotated Dart file - data classes plus abstract API classes - and Pigeon emits the matching Dart, Kotlin/Java, and Swift/Objective-C glue. Under the hood it is still channels and the standard codec; what you gain is that every method signature and every field is checked by the compiler on all three sides.
// pigeons/example.dart - a spec file, not shipped code
import 'package:pigeon/pigeon.dart';
class BatteryStatus {
late int level;
late bool isCharging;
}
@HostApi() // methods Dart calls ON the host (native)
abstract class BatteryHostApi {
BatteryStatus getStatus();
}
@FlutterApi() // methods the host calls ON Flutter
abstract class BatteryFlutterApi {
void onStatusChanged(BatteryStatus status);
}Running Pigeon turns this into a BatteryHostApi that you implement in Swift/Kotlin and call from Dart, and a BatteryFlutterApi for the reverse direction - all with typed BatteryStatus objects instead of untyped maps.
The directions are worth naming explicitly:
@HostApi- Dart → native. Dart calls; the native side implements.@FlutterApi- native → Dart. The native side calls; Dart implements.@EventChannelApi- native → Dart streaming. Generates a typedStream<T>backed by anEventChannel, so a continuous stream of events (location updates, download progress, sensor data) is type-safe end to end as well - no hand-writtenEventChanneland noStandardMessageCodecparsing.
Pigeon is still asynchronous (calls return futures) and still serializes through the codec, so the cost model from Core concepts is unchanged. What changes is correctness and maintainability: rename a field or add a method, and every platform fails to compile until it is updated, instead of failing silently at runtime. For any boundary that resembles an evolving API, this is the default choice - and it is the one this framework is built on (see below).
Direct FFI: ffigen and jnigen
FFI is a different model entirely. Instead of messaging the platform, Dart calls native code directly and synchronously through dart:ffi, on the calling isolate's own thread - no method name, no codec, no hop to the platform thread. Two generators produce the bindings:
ffigenreads C/C++ headers and generates Dart bindings. It is the route for native libraries (crypto, image/audio processing, Rust via a C ABI) and for sharing large buffers by pointer instead of copying them. Calling Swift/Objective-C this way means exposing a C-compatible interface (or using theobjective_cinterop package).jnigengenerates Dart bindings to Java/Kotlin over JNI, letting Dart call Android-SDK and other JVM APIs directly, without writing a channel handler on the Kotlin side.
FFI shines when the boundary is compute rather than platform integration:
- No serialization overhead - you pass pointers and primitives, so it is dramatically faster for tight loops and large data than copying through a codec.
- Synchronous and direct - no async round trip, which is useful inside algorithms.
Why FFI is not the default add-to-app boundary
It is tempting to look at "call native code directly, no channels, no codec" and conclude that FFI should be the primary boundary - especially since jnigen can target your existing Kotlin and ffigen your existing Objective-C. In practice it is the wrong default, because what an add-to-app boundary actually carries is a poor match for what FFI is good at.
-
An add-to-app boundary is platform integration, not a library call. You are wiring Flutter into an existing app - navigating to its screens, reading its session, invoking services that touch UIKit, the Android UI toolkit, the activity/lifecycle, permissions, analytics. Almost all of that is main-thread-bound and stateful. FFI runs on the calling isolate's own thread, not the platform main thread, so for exactly the kind of work add-to-app is full of, you would have to hop back to the main thread anyway - which is what a channel already does for you. FFI is built for self-contained, thread-agnostic compute, and that is the rare case in this kind of project.
-
It is built for Dart → native calls. FFI's natural direction is Dart reaching into native and calling a function. The add-to-app boundary is bidirectional and event-driven: native pushes navigation events, lifecycle changes, and state updates back to Flutter. Native → Dart callbacks over FFI do exist (
NativeCallable., since Dart 3.1), but you wire them by hand, separately per platform, and they give you none of the typed contract thatlistener @FlutterApi(Pigeon) or@EventChannelApiprovide out of the box. -
There is no single cross-platform binding.
jnigenis JVM-only andffigenspeaks the C ABI; reaching Swift/Objective-C means exposing a C-compatible surface or leaning on theobjective_cpackage. So "use FFI" really means maintaining two unrelated, platform-specific binding layers, whereas one Pigeon definition produces Dart, Swift, and Kotlin from a single spec - which is exactly the everywhere-type-safe property an add-to-app boundary needs most. -
You take on memory and ABI ownership. Allocating, freeing, and finalizing native memory, and tracking the native ABI per platform, becomes your responsibility - real overhead and a new class of crashes, for a boundary that channels manage safely for free.
So in an add-to-app project, navigation, platform services, state sharing, and anything touching native UI go through channels or Pigeon. FFI is reserved for the narrow case where Flutter needs to call a native library (a C/C++/Rust codec, a crypto routine) or for a measured hot path where serialization is the bottleneck - and it sits alongside the channel boundary rather than replacing it.
Where Inlay uses Pigeon and where it doesn't
Inlay's boundary is built from two distinct code-generation layers, and only the lower one is actually Pigeon. The split matters because the two layers solve different problems.
The transport layer: Pigeon
The framework's fixed, generic channel contracts are generated with Pigeon. There are two of them, living in the inlay plugin:
inlay_navigator- the navigation transport: pushing a route, popping back, and coordinating the native and Flutter navigation stacks. Its payload is a generic envelope (aPageSettingswith arouteId, aparamsblob, apath, and aschemaFingerprintused to detect schema drift between host and module).key_value_storage- the cross-engine state transport: reads, writes, and change broadcasts for the sharedKeyValueStorage, carried as genericStorageEntry(key/value) records.
These are ordinary Pigeon definitions - @HostApi for Dart → native, @FlutterApi for native → Dart - regenerated through the framework's pigeon script, which runs Pigeon and then patches the Swift output (see the callout at the end of this page). They are intentionally untyped about your domain: Pigeon moves a route id and an opaque params blob, not your specific ProfileRoute with its fields.
The schema layer: a custom generator, not Pigeon
The part that gives you typed ProfileRoute/SettingsStore classes across Dart, Swift, and Kotlin is inlay_gen, and it does not use Pigeon under the hood. It is a standalone generator built directly on the Dart analyzer package: it parses your annotated schema classes and emits the typed route/store classes plus their per-language (de)serialization into and out of the generic transport envelope described above.
The model is inspired by Pigeon - schema files are specs only, never imported by app code, and one definition produces matching types for every platform - but the implementation is the framework's own. Pigeon is not invoked, imported, or present in inlay_gen's dependencies.
So the two layers stack like this:
inlay_gen(custom) generates your typed route and store classes and serializes their fields into the generic envelope.- Pigeon generates the transport that carries that envelope across the channel.
When you call a generated route or read a store field, you are calling inlay_gen output, which packs typed values into the Pigeon transport, which rides a platform channel - type-checked end to end in Dart, Swift, and Kotlin, with no hand-written channel names or dynamic maps in your code.
One sharp edge the framework smooths over on the Pigeon layer: Pigeon does not
emit public Swift types, but a plugin's module boundary requires them. Inlay's
Pigeon build step patches the generated Swift with the needed access modifiers -
an internal detail you only need to know if you regenerate the framework's own
transport channels. Large add-to-app teams that wrap Pigeon output in local
CocoaPods hit exactly this: every regeneration silently drops the public
modifiers, and someone re-adds them by hand until the patch is automated. One
of the largest add-to-app teams we interviewed called this chore out as a
frustrating, error-prone pain point.
Core concepts
Flutter engines, Dart isolates, and platform channels - the foundations of every add-to-app design decision.
Plugins in a multi-engine setup
How Flutter plugins register per engine, why iOS add-to-app never registers them for you, and which plugin categories misbehave when engines come and go.