All posts
Platform Deep Dives7 min read

Writing a Flutter platform channel in Swift. What the official docs skip.

A complete walk through a production platform channel in Swift, including threading, error propagation, and passing complex objects. The parts the docs gloss over.

The first platform channel I ever wrote crashed in production within an hour. Not because the Swift was wrong. Because I returned a result on a background queue, and the Flutter engine politely required the main thread. The crash log made no sense until I understood that Flutter's FlutterMethodCallHandler runs on the platform thread but FlutterResult wants to be invoked on the same thread it was given to you on.

That detail is in the docs if you read carefully. Most people do not. This post is the version I wish I had read first: the entire Swift side and Dart side of a real channel, with threading, error handling, and complex object passing. Where the docs say "see the API reference," I will show you the line.

Context: what a native developer needs to know first

A platform channel is asynchronous, ordered, and one-way per call. The Dart side calls invokeMethod, the engine routes the message to the registered handler on the platform thread, your handler does work, you call result(...) exactly once, and the engine sends the value back. There is no reentrancy guarantee and no synchronous variant.

The platform thread on iOS is the main thread. The platform thread on Android is the main thread of the activity. If you do CPU work on either, you stall the UI. So your handler typically dispatches to a background queue, does work, then dispatches the result back.

The Dart side, exact

Three details the docs skip:

  1. invokeMapMethod returns Map<String, Object?>?, not Map<String, dynamic>. The cast is on you.
  2. MissingPluginException is thrown when the channel name is wrong on either side, not just when no handler is registered. It is the most common bug after a rename.
  3. PlatformException.details is dynamically typed. If you want structured errors, encode them yourself — see below.

The Swift side, complete

Things to notice:

  • The handler dispatches to a serial queue rather than DispatchQueue.global(). If two Dart calls come in close together, they execute in order on the Swift side. Otherwise you can have a later call complete first and confuse callers that were sequencing.
  • result is invoked on the main thread. On iOS, calling result off the main thread does not always crash, but FlutterError interactions with the engine eventually will when the engine is torn down or in pushViewController flows.
  • [weak self] matters. The plugin can be deallocated when the engine restarts (hot restart in debug, controller swap in release add-to-app scenarios).

Comparison: how a native iOS developer solves the same problem without Flutter

A native developer reads ProcessInfo.processInfo.physicalMemory directly. There is no channel, no result, no serialization. They write a struct in Swift and use it.

The Flutter version is twenty-times the code for the same value, plus an asynchronous boundary, plus serialization risk. That is the cost of a platform channel. It is worth it when the value is actually platform-specific. It is not worth it when you reach for a channel out of habit.

Threading and ordering, in a diagram

Caption: the actual thread hops for one round trip. Skipping the main-thread bounce on either side eventually causes a crash or a deadlock during engine teardown.

Passing complex objects

The standard codec supports null, bool, int, double, String, Uint8List, Int32List, Int64List, Float64List, List, and Map<String, Object?> (with string keys). It does not support custom Swift structs directly. You have two options:

  1. Encode to a Map on the Swift side and decode on the Dart side. Simple, brittle for evolving types.
  2. Use Pigeon. Pigeon generates type-safe channel bindings on both sides from a Dart definition.

Pigeon writes the Swift protocol and the Dart wrapper. You implement the Swift protocol. Renames become compile errors instead of runtime MissingPluginExceptions. For any platform channel you expect to live longer than three months, use Pigeon. I lost two days to a renamed key once. That is enough to never write a hand-rolled codec again.

What I would do differently

  • I would have used Pigeon from the first call. The hand-rolled version saved fifteen minutes and cost two days.
  • I would have set up logging on both sides on the first day so I could see method names and elapsed time. Channels are a black box otherwise.
  • I would not have shared one channel for unrelated features. Splitting by domain made debugging much easier.
  • I would have written a MockMethodChannel from day one and tested the Dart layer in isolation. I tested only end-to-end at first, which masked a serialization bug for weeks.
  • I would have read the Flutter engine source for MethodChannel once. It is short, it is C++, and ten minutes there saves a day later.

Closing opinion

Use Pigeon. Hand-roll only for prototypes. Always dispatch off the platform thread for real work, always invoke the result back on the main thread. If you only remember one rule, remember that. For where this fits into deeper platform work, see Background processing in Flutter. What a native developer needs to know. For more context on when Flutter is the right tool to begin with, see I have shipped apps in Swift, Kotlin, and Flutter.

Written by the author of Flutterstacks

A developer who shipped production apps in Swift, Kotlin, and Dart — with a genuine native reference point that most Flutter writers simply don't have.

More articles →

Continue reading

You may also enjoy

Platform Deep Dives

Android back gesture handling in Flutter vs native. The edge cases nobody talks about.

Predictive back, PopScope, GoRouter, and the bottom sheet that swallows your gesture. The Android back gesture story for Flutter, with Kotlin equivalents.

Read article
Platform Deep Dives

Background processing in Flutter. What a native developer needs to know.

Isolates, WorkManager on Android, BGTaskScheduler on iOS, and the places where Flutter's abstraction breaks down. Native Kotlin and Swift equivalents included.

Read article