Flutter Platform Channels: Bridging to Native Android SDKs in Kotlin
Most Flutter developers go years without writing a platform channel, and that is a good thing — it usually means pub.dev had what they needed. But eventually a vendor hands you an Android SDK with no Flutter bindings and a contract that says you will integrate it, and then you are on the boundary.
At iBind Systems I built 3+ native Android modules in Kotlin — MVVM, Coroutines, Jetpack — wired to Flutter over MethodChannels and EventChannels, to integrate multiple third-party KYC SDKs for secure document capture and biometric verification. That platform ran identity verification across 6+ countries at 97% first-attempt verification, which meant the boundary between Dart and Kotlin had to be boring. This post is what I learned making it boring.
When a platform channel is the honest answer
First, the part that saves you weeks: most of the time it is not.
Reach for a channel only when all of these are true:
- The capability genuinely lives in native code. A vendor SDK distributed as an AAR, a Jetpack API with no Dart equivalent, hardware access Flutter does not expose.
- No maintained plugin wraps it. Check pub.dev properly — including the unpopular packages, since a 40-like package that works beats a channel you maintain forever.
- You can accept owning it. A channel is code you will update when the vendor ships a breaking change, when Android changes its permission model, and when Flutter changes its embedding.
The honest counter-cases: if you only need a value the OS already exposes, there is probably a package. If you need an SDK that renders its own full-screen UI, consider launching a native Activity and returning a result instead of trying to embed it — PlatformView exists but it is the most expensive tool in the box, and the composition cost on Android is real. Every KYC SDK we integrated ran its capture flow in its own Activity, and we treated the channel purely as a request/result pipe. That decision alone removed an entire category of problems.
And if you find yourself writing a channel that other apps would want, write a federated plugin instead. Same code, better structure, and you get a clean place to put the iOS half later.
The mental model that prevents most bugs
The single most useful reframe: a platform channel is not a function call. It is asynchronous message passing between two runtimes that happen to live in the same process. Everything crossing it is serialized. Nothing crossing it is type-checked. It can fail. It can arrive after the widget that asked for it is gone.
If you treat it like a network call — assume latency, assume failure, assume the caller may have disappeared — you will write correct channel code. If you treat it like a method invocation because the API is named invokeMethod, you will ship the four bugs in the pitfalls section below.
MethodChannel vs EventChannel: one-shot vs stream
Picking the wrong one is the most common structural mistake, and it is easy to avoid because the rule is genuinely simple.
| Dimension | MethodChannel | EventChannel |
|---|---|---|
| Shape | Request → single response | Subscribe → 0..n events → done/error |
| Direction | Dart calls native (or native calls Dart, one-shot) | Native pushes to Dart continuously |
| Dart surface | Future<T> | Stream<T> |
| Kotlin surface | MethodCallHandler with a Result | StreamHandler with onListen / onCancel |
| Lifecycle burden | Low — reply once, done | High — you own registration and teardown |
| Use it for | Start a KYC capture, read a device attribute, submit a document | Liveness detection progress, sensor feeds, download progress, SDK state changes |
| Failure mode if misused | Polling loop that hammers the boundary | Leaked subscription firing into a dead isolate |
The rule: if the answer arrives once, use a MethodChannel. If it arrives repeatedly and native decides when, use an EventChannel. The tell that you chose wrong is a Timer.periodic in Dart calling invokeMethod to ask "are we there yet." That is an EventChannel you have not written yet.
In the KYC work, the split fell out naturally: startDocumentCapture was a MethodChannel call that returned a result object when the vendor Activity finished, while biometric liveness — which emits progress as the user turns their head — was an EventChannel. Trying to model liveness as repeated method calls would have been the single worst decision available.
The basic shape, in both languages
// Dart
static const _channel = MethodChannel('app/kyc');
Future<CaptureResult> startCapture(String docType) async {
try {
final map = await _channel.invokeMapMethod<String, dynamic>(
'startCapture', {'docType': docType},
);
return CaptureResult.fromMap(map!);
} on PlatformException catch (e) {
throw KycFailure(code: e.code, message: e.message);
} on MissingPluginException {
throw KycFailure(code: 'UNSUPPORTED', message: 'Not available here');
}
}
Two things in that snippet matter more than they look. invokeMapMethod instead of invokeMethod gives you a typed cast at the boundary rather than a _CastError three frames later. And catching MissingPluginException separately is not paranoia — it is what you get on iOS, on Flutter web, and in unit tests where nothing registered the handler.
Threading: the pitfall that produces the weirdest crashes
This is the one that costs people days, so it gets its own section.
Flutter's platform channel handlers run on the Android main thread, and result.success() must be called on the main thread. That gives you two symmetric ways to break your app.
Break it the first way by doing real work inside onMethodCall. Image encoding, an SDK's synchronous init, a disk read — all of it runs on the main thread and janks the Flutter UI, because Flutter's own rendering is driven from that same thread. Your Dart await looks innocent; the frame budget disagrees.
Break it the second way by fixing the first problem carelessly: you move the work to a coroutine on Dispatchers.IO, and then call result.success() from that background thread. Sometimes it works. Sometimes it corrupts the engine's message state and you get a crash with a stack trace that points nowhere near your code. This is the single most common cause of "impossible" platform channel crashes I have debugged.
The pattern that is correct in both directions:
// Kotlin
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"startCapture" -> {
val docType = call.argument<String>("docType")
if (docType == null) {
result.error("BAD_ARGS", "docType is required", null); return
}
scope.launch {
try {
// heavy work off the main thread...
val outcome = withContext(Dispatchers.IO) { sdk.capture(docType) }
// ...but we resume on Main, so this reply is safe
result.success(outcome.toMap())
} catch (e: SdkException) {
result.error("CAPTURE_FAILED", e.message, e.diagnostics)
}
}
}
else -> result.notImplemented()
}
}
Scope on Dispatchers.Main, hop to IO for the work, come back automatically for the reply. And note result.notImplemented() in the else branch — that is what turns into a MissingPluginException in Dart rather than a silently hanging Future. A Dart Future that never completes is the worst outcome on this boundary, because there is nothing to see: no crash, no log, just a spinner forever.
One more rule with no exceptions: call the result exactly once. Not zero times (hung Future), not twice (a runtime error on the engine side). If your handler has multiple return paths, audit every one of them.
Lifecycle: the leak nobody notices until Crashlytics does
Register your channels in configureFlutterEngine and tear them down in cleanUpFlutterEngine (or, in a plugin, in onAttachedToEngine / onDetachedFromEngine). Skipping the teardown half is close to universal in tutorial code and it is a real bug.
The failure mode: an EventChannel StreamHandler holds a reference to the vendor SDK's listener, the Activity is destroyed on a config change, and the SDK keeps emitting into an EventSink whose engine is gone. On a good day it leaks the Activity. On a bad day it crashes on a rotation.
// Kotlin — EventChannel done properly
class LivenessStreamHandler(private val sdk: KycSdk) : EventChannel.StreamHandler {
private var listener: LivenessListener? = null
override fun onListen(args: Any?, sink: EventChannel.EventSink) {
listener = object : LivenessListener {
override fun onProgress(p: Float) = sink.success(mapOf("progress" to p))
override fun onFailure(c: String, m: String) = sink.error(c, m, null)
override fun onComplete() = sink.endOfStream()
}.also { sdk.addLivenessListener(it) }
}
override fun onCancel(args: Any?) {
listener?.let { sdk.removeLivenessListener(it) } // the half everyone forgets
listener = null
}
}
On the Dart side, the mirror discipline: cancel the StreamSubscription in dispose(). onCancel only fires because Dart cancelled. If your widget forgets, the native listener lives forever. This is a two-sided contract and both sides break silently.
Also worth knowing: with the v2 embedding your plugin can be attached to an engine but not to an Activity — during a background service, for instance. If your code assumes an Activity is present, guard it via ActivityAware instead of reaching for a null reference. That crash reads as a null pointer in the platform layer and takes forever to attribute.
Passing structured data across the boundary
The default StandardMessageCodec handles primitives, strings, byte arrays, lists, and maps. It does not handle your classes. So the boundary is a serialization boundary whether you designed it that way or not, and the practical advice is:
- Define the contract as maps at the edge and convert immediately. Native emits a
Map, Dart converts to a typed model in one factory constructor, and noMap<String, dynamic>escapes into your app code. Same in reverse. This keeps the untyped blast radius to one function per direction. - Use the typed invoke helpers.
invokeListMethod,invokeMapMethod,invokeMethod<T>— they surface cast failures at the boundary where the error message still means something. - Send binary as
Uint8List, never base64 strings. Byte arrays cross the codec directly with no copy through UTF-16. For document capture, where a single frame can be several megabytes, this is the difference between a smooth flow and a visible stall. - Consider Pigeon if the surface is large. Pigeon generates typed Dart and Kotlin from one schema, which eliminates the entire class of "someone renamed a map key on one side." For a two-method channel it is overkill; for the KYC-scale surface we had, hand-written maps needed real review discipline to stay in sync — a code-generated contract is a legitimately better answer there.
Error propagation that survives the trip
Errors are where sloppy channels leak their sloppiness into your crash reports. Three rules.
Use result.error(code, message, details), not exceptions. An uncaught Kotlin exception inside a handler does not become a Dart error — it becomes a native crash, or worse, a hung Future. Catch, map to a code, reply.
Make the code a stable enum, not a message. "DOCUMENT_BLURRY" is something Dart can branch on and something you can group in Crashlytics. A raw vendor message is neither — it is unstable across SDK versions and it usually is not localizable.
Put diagnostics in details, and log them natively too. Because Dart's stack trace stops at the channel, a PlatformException in Crashlytics tells you a call failed but not why. We logged the vendor's error payload natively with Crashlytics custom keys, so a crash-free-session dip could be traced to a specific SDK failure mode rather than an anonymous PlatformException. That instrumentation habit is the same one that drove the 67% crash reduction I wrote about separately — and platform channel errors are one of the four crash classes covered there for exactly this reason.
Testing the native side
The layer people skip, and the layer that actually catches regressions.
Dart side: use TestDefaultBinaryMessengerBinding to install a mock handler for the channel. You are testing your Dart wrapper — that it parses the map, throws your domain error on PlatformException, and handles MissingPluginException. Fast, and it runs on any machine.
TestWidgetsFlutterBinding.ensureInitialized();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(const MethodChannel('app/kyc'), (call) async {
if (call.method == 'startCapture') return {'status': 'ok', 'score': 0.98};
return null;
});
Kotlin side: this is the part worth insisting on. Your MethodCallHandler is an ordinary class — construct it in a JVM unit test, hand it a MethodCall and a mocked Result, and verify which reply it made. That test catches the mistakes that matter: wrong error code, missing notImplemented(), replying twice, forgetting to validate a null argument. Keep the SDK behind an interface so the handler is testable without the vendor's AAR — the same MVVM discipline you would apply in a pure Android app applies here, and it is why the modules I built were structured that way rather than as one fat handler.
Integration: one integration_test that exercises the real round trip on a device, because mocked-both-sides tests pass happily while a codec mismatch ships. You do not need many. You need one that would have caught the renamed key.
The review checklist I use
- Every branch of
onMethodCallcalls the result exactly once, includingelse -> result.notImplemented(). - No blocking work on the main thread; no
resultcalls off it. - Every
onListenhas a symmetriconCancel; every Dart subscription is cancelled indispose(). - Error codes are a stable enum, and diagnostics are logged natively as well as returned.
- Arguments are null-checked before use — Dart can send anything.
- Bytes cross as
Uint8List. - The handler has a JVM unit test that does not need the vendor SDK.
None of that is clever. That is the point. The boundary between Dart and Kotlin should be the least interesting code in your app, because when it is interesting it is usually on fire. For the wider trade-off of when to write native Android at all versus staying in Flutter, my Flutter vs Kotlin comparison covers that decision, and the Flutter vs React Native piece covers the equivalent bridge story on the other framework.
The Takeaway
Write a platform channel only when the capability truly lives in native code and no maintained plugin wraps it — then treat the channel as a network boundary, not a function call. MethodChannel for one-shot request/response, EventChannel for anything native pushes over time. Do the work off the main thread but reply on it, reply exactly once, pair every onListen with an onCancel, convert maps to typed models at the edge, and unit-test the Kotlin handler with the vendor SDK behind an interface. Get those right and the boundary becomes boring, which is the only state you want it in.
I build this kind of thing for a living — mobile engineer, Flutter and native Android in Kotlin, open to full-time roles. Get in touch.
Frequently asked questions
When should I write a Flutter platform channel instead of using a plugin?
Only when the capability genuinely lives in native code, no maintained plugin wraps it, and you can accept owning it through vendor and Android platform changes. If other apps would want the same thing, write a federated plugin rather than a bare channel.
MethodChannel or EventChannel — how do I choose?
If the answer arrives once, use a MethodChannel. If it arrives repeatedly and native decides when, use an EventChannel — a Timer.periodic in Dart calling invokeMethod is an EventChannel you have not written yet.
Why does my platform channel crash randomly?
Usually because result.success() was called from a background thread. Do the heavy work on Dispatchers.IO but scope the coroutine to Dispatchers.Main so the reply happens on the main thread, and call the result exactly once per branch.
How do I propagate errors from Kotlin to Dart?
Use result.error(code, message, details) with a stable error code enum, never an uncaught exception — an uncaught Kotlin exception becomes a native crash or a hung Future. Log the native diagnostics natively too, since the Dart stack trace stops at the boundary.
How do you test the native side of a platform channel?
Keep the vendor SDK behind an interface so your MethodCallHandler is an ordinary class, then unit-test it on the JVM with a mocked Result. Add one integration_test for the real round trip, because mocked-both-sides tests pass happily while a codec mismatch ships.
Bottom line
A platform channel is not a function call — it is asynchronous message passing between two runtimes. Treat it like a network boundary and it becomes boring, which is the only state you want it in.
More from the field.
Like the way I think? Let's talk.
I'm a mobile engineer — Flutter and React Native for cross-platform, native Android in Kotlin where the platform demands it, Next.js on the web — open to full-time roles. If your team is hiring, the résumé is the fastest way in.