Flutter Platform Channel Development for iOS
Flutter covers 80–90% of tasks through Dart packages. But when you need direct access to native iOS APIs — CoreNFC, NetworkExtension for VPN, AVFoundation with custom session settings, PassKit for Wallet passes — Dart wrappers either don't exist or are outdated by 2 SDK versions. That's when you write a Platform Channel by hand.
Three channel types and when to use each
Flutter provides three mechanisms for communicating with native code.
MethodChannel — request/response. Dart calls a method, Swift responds once. Suitable for most tasks: biometrics, Keychain operations, one-time system API calls. The most common type.
EventChannel — data stream from native code to Dart. Used for subscriptions: sensor data via CoreMotion, Bluetooth connection status via CoreBluetooth, network interface changes via NWPathMonitor. Data flows continuously while Dart listens.
BasicMessageChannel — arbitrary two-way messaging with custom codec. Rare case, needed when the standard StandardMessageCodec (supports String, int, double, List, Map, Uint8List) isn't enough.
Mixing types without reason is unwise: I've seen projects using EventChannel where a single MethodChannel call would suffice — this creates unnecessary subscriptions and memory leaks if StreamController isn't cleaned up when the channel is destroyed.
Most common failure points
Threads and FlutterResult
FlutterResult — an Objective-C callback that Flutter passes to Swift for responding. Main rule: call it exactly once. Call it twice — runtime crash with Call to FlutterResult callback after it has been released.
Typical trap with AVCaptureSession: method launches capture asynchronously, result arrives through completion on background queue. If you don't dispatch result via DispatchQueue.main.async, Flutter sometimes receives the response on an unexpected thread — behavior is unpredictable, bug doesn't always reproduce.
channel.setMethodCallHandler { [weak self] call, result in
guard call.method == "startCapture" else {
result(FlutterMethodNotImplemented)
return
}
self?.session.startRunning(completion: { success, error in
DispatchQueue.main.async {
if let error = error {
result(FlutterError(code: "CAPTURE_ERROR",
message: error.localizedDescription,
details: nil))
} else {
result(success)
}
}
})
}
Serialization via StandardMessageCodec
StandardMessageCodec can handle Uint8List, which saves you when transferring small binary data (image preview, encrypted payload). But for objects more complex than a dictionary — manual serialization is still needed. Attempting to pass Data directly without converting to FlutterStandardTypedData causes silent failure: Dart receives null instead of data.
EventChannel and memory leaks
FlutterEventSink must be nulled in onCancel:
final class SensorStreamHandler: NSObject, FlutterStreamHandler {
private var motionManager = CMMotionManager()
private var eventSink: FlutterEventSink?
func onListen(withArguments arguments: Any?,
eventSink events: @escaping FlutterEventSink) -> FlutterError? {
eventSink = events
motionManager.startAccelerometerUpdates(to: .main) { [weak self] data, _ in
guard let data = data else { return }
self?.eventSink?(["x": data.acceleration.x, "y": data.acceleration.y])
}
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
motionManager.stopAccelerometerUpdates()
eventSink = nil // critical: without this, there will be accesses to freed object
return nil
}
}
Skip eventSink = nil — you'll get EXC_BAD_ACCESS in a few minutes when motionManager tries to send data to an already-destroyed channel.
How we build the channel
Start with defining the contract: method, arguments, return type, error codes. Document in comments on both sides synchronously. This is critical for teams where iOS and Flutter developers are different people.
On the Dart side, wrap MethodChannel in a separate service class with typed API:
class BiometricService {
static const _channel = MethodChannel('com.app/biometric');
Future<bool> authenticate(String reason) async {
try {
return await _channel.invokeMethod<bool>('authenticate', {'reason': reason}) ?? false;
} on PlatformException catch (e) {
if (e.code == 'BIOMETRIC_UNAVAILABLE') return false;
rethrow;
}
}
}
Direct calls to MethodChannel from widgets is an antipattern: lose type safety, error handling spreads throughout the tree.
Test with MockMethodCallHandler in unit tests on Dart side and via XCTest on Swift side. Isolated testing of each part accelerates debugging many times over.
What's included in the work
- Designing channel contract (method names, argument types, error codes)
- Implementing Swift handler with proper thread safety
- Dart service with typed public API
- Handling edge cases: device doesn't support feature, user denied permission
- Unit tests for both sides
- Real device verification (not just simulator — many iOS APIs aren't available in simulator)
Timeline
3–5 days. Simple MethodChannel for one system call — 2–3 days with tests. EventChannel with continuous data stream and lifecycle management — 4–5 days. Cost calculated individually after requirements analysis.







