A platform channel is a named, asynchronous message pipe between Dart and the host platform: Dart encodes a message with a codec, the engine hands it to the native handler registered under the same name, and the reply comes back as a Future.
**MethodChannel: request and response: call a named method with arguments and await one result; the everyday choice (battery level, open a native screen)
EventChannel: a stream pushed from native to Dart: sensors, location, connectivity
BasicMessageChannel**: raw messages with a codec you choose (StringCodec, JSONMessageCodec, BinaryCodec), and either side can send; it is the low-level sibling of MethodChannel, both run on the same BinaryMessenger
Never synchronous: even a trivial getter returns a Future, so you cannot call the platform from inside build
Threads: Dart calls from the root isolate (or a registered background isolate); native handlers run on the platform main thread unless you give the channel a background task queue
Both sides must agree on the name and the codec: a typo surfaces at runtime as MissingPluginException, never as a compile error, which is why Pigeon generates this code for anything bigger than a spike
FlutterSenior
Platform Channels
MethodChannel, EventChannel, plugin development, FFI
Questions closed
0 / 24
- 0Review
- 4Asked often
Questions
EventChannel carries a continuous stream of events from native to Dart. Use it when the platform pushes values over time, and MethodChannel when Dart asks once and gets one answer.
Dart calls receiveBroadcastStream(). When the first listener subscribes, native gets onListen with an event sink and pushes success, error or endOfStream into it; when the last listener cancels, native gets onCancel.
Use it for: sensors, location updates, connectivity changes, download or playback progress
**Release the source in onCancel: otherwise the sensor or receiver keeps running after the screen is gone
Create the stream once and cache it**: every receiveBroadcastStream() call is a new native subscription, and the channel has a single native handler, so a second one restarts it
Emit from the main thread: the sink sends a message to Dart, and on Android that must happen on the main thread
The native side answers with an error instead of a value (result.error(code, message, details)), and Dart receives a PlatformException whose code it can branch on.
What arrives in Dart: result.error becomes PlatformException; result.notImplemented() or no handler at all becomes MissingPluginException
Catch everything natively: a synchronous exception on Android arrives as a generic PlatformException with code error; one on another thread or in a callback crashes or leaves the call unanswered, so convert them to result.error with your own code
Use stable codes: PERMISSION_DENIED, NO_CAMERA. Write the user-facing wording in Dart, where localisation lives
There is no built-in timeout: wrap the call in .timeout(...) if a hanging call is unacceptable
**Keep secrets out of message and details**: they end up in logs and crash reports
Expected failures (no permission, no network) are errors with a known code; anything else is a bug and should be reported as one.
Both crashes assert a type the standard codec never promised: Dart receives Map<Object?, Object?>, and Kotlin receives Integer or Long depending on the value, so read values as Object? and Number and convert them yourself.
The codec carries values, not your types. Back in Dart a map is a Map<Object?, Object?>: it does not know every key happens to be a String, so the cast fails. Going the other way, a Dart int arrives in Kotlin as an Integer only while it fits in 32 bits, and as a Long after that.
In Dart: use invokeMapMethod<String, Object?> and invokeListMethod, which call .cast() for you, and cast nested maps one level at a time
In Kotlin: read numbers as Number and narrow them yourself; call.argument<Int>(...) is an unchecked cast that works for 42 in development and throws on a real timestamp
**There is no DateTime and no enum: send epoch milliseconds and the enum name, and write the unit into the method contract
The real fix**: decode once at the boundary into real classes, which is exactly the code Pigeon writes for you
The standard codec carries null, bool, int, double, String, typed data such as Uint8List and Float64List, and List and Map of those. Your own class must become one of them: a map, bytes, or a type you teach the codec.
What they become natively: int is Integer or Long on Android and NSNumber on iOS; Map is a HashMap or NSDictionary; Uint8List is a ByteArray or FlutterStandardTypedData
**toMap() and fromMap(): the quick way: fine for a couple of types, but untyped and written three times, once per language
Bytes you already have**: JSON or Protobuf go across as a String or Uint8List unchanged
A custom codec: extend StandardMessageCodec, write a tag byte of 128 or above plus the fields in writeValue, and read them back in readValueOfType; every side must use the same tag, or the reader misparses the rest of the message
**Delegate each field to super.writeValue: that keeps typed data aligned and nested values encoded the standard way
Usually: Pigeon**: it generates exactly this codec on all sides; write one by hand only on a hot path
Pigeon is a code generator: you describe the interface once in Dart, and it writes matching, typed Dart, Kotlin and Swift (or C++, Objective-C) that talk over an ordinary channel.
Type safety on both sides: no untyped maps and no method-name strings scattered around the code
Renames break the build: in Dart, Kotlin and Swift at once, instead of failing at runtime on a user's phone
Both directions: @HostApi is Dart calling native, @FlutterApi is native calling Dart; recent versions also generate typed event streams
Data classes and the codec come free: your model is generated on every side with a codec that matches, including nullability
**@async: the native method gets a callback, so it can reply later from a background thread
When raw channels are still fine**: a one-method spike or a plugin you do not own; past a couple of methods Pigeon is the better default
Declare the permission in each platform's config, then request it at runtime at the moment the feature needs it (usually with permission_handler), and handle granted, denied and permanently denied as different cases.
Two layers: <uses-permission> in AndroidManifest.xml, and a usage string such as NSCameraUsageDescription in Info.plist; without that string iOS terminates the app when it asks
**permission_handler on iOS**: enable each permission you use with a PERMISSION_...=1 macro in the Podfile; anything not enabled is compiled out and always reports denied
Ask in context: right before the feature, after a line of explanation; a wall of prompts at launch gets denied
Permanently denied: iOS shows the system dialog only once, Android stops after the second refusal; from then on only openAppSettings() helps
Re-check on resume: the user can revoke a permission in Settings, and Android kills the app process when they do
Many plugins ask for you: camera, image_picker and geolocator request their own permission; permission_handler is for checking status, explaining and routing to Settings
It means the message reached the platform and nobody there had registered a handler for that channel name, so the platform answered "not implemented".
Check this first: you hot-restarted after adding the dependency; plugins register while the engine starts, so a new plugin needs a full stop and rebuild
Then the native build: pod install did not run, the Gradle sync failed, or the device still has the old install; flutter clean and build again
The platform is not supported: on web, desktop or inside flutter test there may be no native side at all, so mock the channel or degrade gracefully
A background isolate: a push handler or WorkManager callback has no plugins until it calls DartPluginRegistrant.ensureInitialized()
Add-to-app with your own engine: an engine created with plugin registration turned off, or channels on a different engine's messenger, gives the same error
Still stuck? Log the channel name from the exception and compare that exact string with the native registration: a rename, a stale generated file or two plugin versions claiming one name all look the same.
Every call must get exactly one reply on every path, and this handler breaks that rule four ways: a failure path with no reply, a double reply, a conditional reply and no fallback branch.
**readFile**: a missing file or argument throws inside launch: no reply is sent, and the uncaught exception in the coroutine crashes the process; wrap it and answer with result.error
**clearCache**: the second success throws IllegalStateException: Reply already submitted on the main thread, which is a crash; it also deletes files on the main thread
**ping**: when connected is false nothing replies, and the Dart Future waits forever, because channels have no timeout
**No else branch**: an unknown method name, for example from a newer Dart side, hangs instead of producing MissingPluginException; always end with result.notImplemented()
A channel name has exactly one Dart handler: each setMethodCallHandler replaces the previous one, so the chat's listener silently evicts the badge's, and the chat's dispose() then sets it to null, leaving nobody to receive pushes.
Why it happens: the handler lives in the BinaryMessenger keyed by channel name, not in the MethodChannel object, so any MethodChannel with that name (even a fresh instance) overwrites the same slot
What native sees: while the handler is null, its invokeMethod gets notImplemented and the push is lost; Dart logs nothing
The fix: register the handler once, in one owner that is touched at startup, and fan messages out through a broadcast Stream; screens subscribe in initState and cancel in dispose, so they come and go without touching the channel
Catch it next time: keep setMethodCallHandler for each channel in exactly one place; a unit test that creates two listeners and delivers a message with TestDefaultBinaryMessengerBinding shows the first one going silent
build creates a new stream on every rebuild. StreamBuilder sees a different stream object, cancels the old subscription and subscribes to the new one, so native gets onCancel followed by onListen and restarts the sensor each time.
Any parent rebuild triggers it: a setState above, a Provider or a MediaQuery the parent reads (keyboard, theme); unit does not even have to change
**.cast<int>() is no help: it also returns a new stream object on each call
The fix**: create the stream once: a static final field, a field set in initState, or a repository that owns it; then StreamBuilder receives the same object every build and keeps its subscription
The general rule: never create the Future or Stream for a FutureBuilder or StreamBuilder inside build
The subscription is never cancelled: there is no dispose, so after the screen closes the stream still has a listener, native never receives onCancel, and GPS keeps running; and every new fix calls setState on a disposed State.
Why native keeps going: receiveBroadcastStream sends listen to native on the first subscription and cancel only when the last one is cancelled; until then the native StreamHandler has no reason to stop the location updates
The second symptom: each event calls setState on a defunct State, which in debug throws setState() called after dispose(); the listener closure also keeps the State reachable, a leak
The fix: cancel the stored StreamSubscription in dispose; the cancel reaches native as onCancel, where the handler unregisters its location listener
Catch it next time: the analyzer already warns unused_field on _sub, and the cancel_subscriptions lint flags a subscription field that is never cancelled; a widget test with setMockStreamHandler can assert that onCancel fires when the screen is removed
Lag that keeps growing means something accumulates: events queue faster than the UI isolate drains them, or your code does work per event that grows with time. Every event already pays for encoding, a thread hop and a turn of the Dart event loop.
Nothing applies back-pressure: pausing the subscription only buffers; it never asks the sensor to slow down
Work that grows with time: appending every sample to a list and redrawing a chart gets slower each second; keep a fixed-size ring buffer. setState calls within a frame coalesce into one build, so rebuilds cap at about 60 a second, but each build still pays
A native flood: a sensor listener at SENSOR_DELAY_FASTEST that posts every sample to the main looper floods the same thread that runs Dart UI (Flutter 3.29+); sample slower or batch before posting
Fix it at the source: set a sampling period on SensorManager or CMMotionManager; 200 Hz is rarely really needed
Or batch natively: send one Float64List per frame instead of many maps, or send derived state (a step count, a shake) instead of raw samples
Update a leaf, not the screen: push the latest value into a ValueNotifier read by one small ValueListenableBuilder
For genuinely kilohertz data a channel is the wrong pipe: use FFI with a shared buffer.
Run flutter create --template=plugin --platforms=android,ios for a Dart API, native code and an example/ app; split into a federated plugin only when platforms are owned or released separately.
Registration is generated: the flutter: plugin: platforms: section names a pluginClass; the tool writes GeneratedPluginRegistrant, which creates it when the engine starts
The native class: on Android it implements FlutterPlugin (onAttachedToEngine, onDetachedFromEngine) plus ActivityAware if it needs an Activity; on iOS it adopts FlutterPlugin and sets up channels in register(with:)
Define the contract with Pigeon: not with method-name strings and maps
iOS packaging: Flutter is moving from CocoaPods to Swift Package Manager, so a new plugin should ship a Package.swift next to its podspec
Federate when platforms are owned or released separately: my_plugin, a my_plugin_platform_interface package extending PlatformInterface, and one package per platform
Do not federate an in-house plugin with two platforms: one package is simpler, and the interface can be extracted later
Test in layers: Dart tests with a mocked channel, native unit tests, and integration_test in example/ on a real device
For Dart a channel call is always asynchronous, but the native handler runs on the platform main thread by default. Since Flutter 3.29 that is also the thread running your Dart UI code on Android and iOS, so a slow handler stalls frames directly.
Dart side: invokeMethod sends and returns a Future; it must be called from the root isolate or from a background isolate set up with BackgroundIsolateBinaryMessenger
Native default: handlers run on the Android main looper or the iOS main queue; fine for quick calls and anything that touches native UI
Background task queue: create the channel with a queue from makeBackgroundTaskQueue(), and the engine delivers its calls on a background thread, still in order
Or hop yourself: launch a coroutine or dispatch to a background DispatchQueue, then reply when the work is done
Replies versus new messages: a result reply may come from any thread, but a new message to Dart (invokeMethod from native, an EventSink event) must be sent from the main thread
Reply exactly once: a second reply throws, and no reply leaves the Dart Future waiting forever
The filter goes over FFI and the permission over a channel. FFI is a direct synchronous call into C with no serialisation, which suits hot pure computation; a channel reaches Kotlin and Swift, which you need for system UI and Activity-bound APIs.
Channel cost: encode, copy, thread hop, decode: fine once per user action, wrong for 30 frames a second of megabyte buffers
FFI cost: a plain native call on the calling thread, measured in nanoseconds; you pass a pointer to the frame instead of copying it
FFI is synchronous: it blocks the calling isolate, so long C work belongs in a worker isolate, or on the library's own thread reporting back through NativeCallable.listener
You manage the memory: calloc and free, an Arena, or a NativeFinalizer; a mistake is a crash, not an exception
Packaging: ffigen generates bindings from the C headers, and build hooks (native assets, stable since Dart 3.10) compile and bundle the library
The line is moving: jnigen and the Objective-C/Swift support in ffigen generate direct bindings to platform APIs; they are still young, so channels with Pigeon remain the default for platform APIs
Stop moving pixels across the boundary. Every channel message is copied on both sides, and the native handler decodes on the platform main thread by default.
A 12-megapixel RGBA buffer is about 48 MB, and during the hop it exists two or three times at once, on two heaps. Meanwhile the decode blocks the main thread, which on current Flutter also runs your Dart UI.
Send a path or a JPEG, not raw pixels: then decode with ui.ImmutableBuffer and ui.ImageDescriptor, which run on the engine's worker threads and can resize while decoding
Move the handler off the main thread: give the channel a background task queue from makeBackgroundTaskQueue()
If bytes really must cross: BasicMessageChannel with BinaryCodec drops the method envelope and one copy
Rule of thumb: channels carry control messages; megabytes per second belong in a Texture, a file, or FFI memory
A channel needs a BinaryMessenger, and only the root isolate has one. A channel used in a spawned isolate fails until you lend it the root's messenger with BackgroundIsolateBinaryMessenger.ensureInitialized(token).
Pass the token: on the root isolate read RootIsolateToken.instance! and send it into the isolate
Initialise before any channel: call BackgroundIsolateBinaryMessenger.ensureInitialized(token) first; after that invokeMethod and most plugins work as usual
Only Dart to native: native-initiated invokeMethod and EventChannel events go to the root isolate, and setMethodCallHandler throws in a background isolate
The native handler still runs on the platform thread unless the channel has a background task queue; the isolate saves Dart-side work only
The cleaner split: often the root isolate makes the platform call and hands the background isolate raw data
Isolates started by native code: a background push handler or a WorkManager entry point is a different case: it has its own engine and must call DartPluginRegistrant.ensureInitialized() before its first plugin call
A platform view embeds a real native view (a map, a WebView, an ad) in the widget tree through AndroidView or UiKitView. The price is that the engine must combine two rendering systems in every frame.
Native code registers a view factory under a view-type string, Dart creates the view with that string, and each instance gets an id so a per-instance channel can reach it. In practice a package such as google_maps_flutter or webview_flutter has done this for you.
Android modes: AndroidView uses texture layer hybrid composition by default: the native view draws into a texture, cheap for Flutter, with a fallback when the view cannot be drawn that way (a SurfaceView). initExpensiveAndroidView gives full hybrid composition: the real view in the Android hierarchy, best input and accessibility fidelity, slower frames
iOS: the native view sits in the UIKit hierarchy; every Flutter widget drawn on top of it needs an extra overlay layer
Where it hurts: many platform views in a scrolling list, widgets stacked over them, transforms and clips; profile on a low-end Android device
Tests: golden and widget tests cannot render it; you see an empty box
**If you only need pixels, use Texture**: native writes frames into a surface the engine owns; camera preview and video players work this way, much more cheaply
The Activity arrives through the ActivityAware callbacks, and the rule that stops the leak is simple: hold it only between attach and detach.
Two bindings, two lifetimes: the FlutterPlugin attach gives the application context and the messenger, which anything outliving a screen should use; ActivityAware gives the Activity, which you store on attach and clear in both detach callbacks
Why a leak is expensive: an Activity kept in a field survives a rotation, so the dead Activity and its whole view tree stay in memory; a static field is the same bug with a longer life
Unregister what you register: remove the activity-result and permission-result listeners on detach, or the next attach adds a second one and your handler runs twice
Reply once: even a permission request the user dismissed must produce a reply, or the Dart Future never completes
Do not count on the pending result surviving: the Activity can be recreated, or the process killed, while the system dialog is up
On iOS there is no Activity: addApplicationDelegate gives you the app delegate callbacks, with the same unregister rule.
Flutter has no background scheduler of its own: the OS wakes the app through its own API (WorkManager on Android, BGTaskScheduler on iOS), and a plugin starts a headless Flutter engine that runs a top-level Dart entry point.
How the Dart side runs
The entry point: a top-level or static function marked @pragma('vm:entry-point'), or release tree shaking removes it
A fresh isolate: none of the UI's providers, blocs or caches exist; call DartPluginRegistrant.ensureInitialized() before plugins, then reopen the database and read tokens from storage
Talk to the UI through storage: the database or a port registered in IsolateNameServer, never shared globals
What each OS allows
Android: WorkManager (the workmanager plugin) runs work with constraints such as network or charging; periodic work is at least 15 minutes apart and Doze defers it. Long user-visible work needs a foreground service with a notification and a declared foregroundServiceType
iOS: no guaranteed schedule: BGAppRefreshTask gets about 30 seconds when the system decides, BGProcessingTask runs longer, usually while charging; task ids must be listed in Info.plist, and a user force-quit stops them. iOS 26 adds BGContinuedProcessingTask for long work the user started
Location: continuous tracking is a separate permission (ACCESS_BACKGROUND_LOCATION, iOS "Always" plus the location background mode), a foreground service on Android, and a store review question
Design rules
Keep jobs short, idempotent and resumable, and sync again on the next launch, because a background run may never happen
When the server knows something changed, a push message is a better trigger than polling
You build the Flutter part as a module that the native app consumes as a library, and show it through FlutterActivity, FlutterFragment or FlutterViewController. Each is backed by a FlutterEngine, and starting that engine is the expensive part.
Module, not app: flutter create -t module; the host includes it as a Gradle source dependency or a prebuilt AAR, and on iOS through CocoaPods or flutter build ios-framework, so native-only developers do not need the Flutter SDK
Why pre-warm: a cold engine loads the Dart VM and your code and runs main, which shows as a blank delay before the first frame; start it early (Application.onCreate, app launch), store it in FlutterEngineCache, and open screens with withCachedEngine
The trade-off: a warm engine holds memory while no Flutter screen is visible; pre-warm only when the screen is likely to open
Several Flutter screens: FlutterEngineGroup spawns engines that share the VM and compiled code, so each extra one is cheap
The host owns navigation: pass the starting route before running Dart, and exchange data over channels or Pigeon on that engine's messenger
Plugins: the engine registers them automatically; a channel created on a different engine's messenger is a classic MissingPluginException
At that moment no Dart handler exists on the channel yet, so the message goes into the engine's channel buffer, which holds one message per channel by default and drops the rest with a warning.
The race is structural: your handler is registered in main or an initState, and on a cold start that runs after the Activity or AppDelegate has already handled the launch intent
A burst keeps only the newest: a notification tap plus a deep link, and one of them is gone
Ship the pull pattern instead: native keeps the payload, Dart asks for it once at startup and then subscribes for later events; that is what getInitialMessage and getInitialLink are
Clear it only after handover: so a Dart restart does not lose it
If it truly must be pushed: call channelBuffers.resize(name, 8) from dart:ui in main before runApp, but messages that arrived before main are already trimmed, so the pull pattern is the real fix
A native-to-Dart push is best effort until Dart is listening; anything that must survive a cold start has to be readable.
Use NativeCallable.listener. It is the only callback form that may be invoked from a thread the Dart isolate does not own.
**NativeCallable.listener(fn)**: posts the arguments to the isolate's event loop and runs your Dart function on a later turn. That is what makes it thread-safe, and also why it cannot return a value: the native signature must return Void
**NativeCallable.isolateLocal(fn, ...): runs synchronously and can return a value, but only on the isolate's own thread, in practice during an FFI call you are making. From any other thread it is undefined behaviour, usually a crash
Pointer.fromFunction is the older form: top-level functions only, no closures, the same one-thread rule
Always close() it: a live listener keeps the isolate alive, the usual reason a spawned isolate refuses to exit
Only argument values are copied**: a char* that C frees when the callback returns is already dangling when your Dart code runs, so copy it natively first
Order is not guaranteed: a progress callback can be processed after the FFI function has already returned