HomeDocumentationFlutter: The Enterprise Playbook
Flutter: The Enterprise Playbook
5 min read

Native MethodChannels for iOS and Android: When Flutter Needs to Talk to the Platform

Flutter MethodChannels: Calling Native iOS & Android Code

March 22, 2026

What MethodChannels Actually Are

When Flutter runs on a device, two worlds coexist. There's the Dart world — your app code, the Flutter framework, widgets, BLoCs, everything covered in the previous articles. And there's the platform world — iOS with its UIKit, Swift, and Apple frameworks; Android with its SDK, Kotlin, and Activity lifecycle.

By default these worlds don't communicate. Flutter renders to a GPU surface and manages its own event loop. The platform provides that surface and handles OS-level events. What they don't do is call each other's functions.

MethodChannels are the bridge. You give a channel a name, register a handler on the native side, and call it from Dart. Arguments travel across the bridge serialised as standard types. Results come back the same way. The communication is asynchronous — the Dart side awaits while the native side does its work and responds.

This is fundamentally different from Dart FFI. FFI calls C functions directly — compiled code, no bridge, synchronous. MethodChannels communicate with platform SDKs — message passing, async, with full access to everything the OS exposes to native apps. Different tools for different jobs.

Three Channel Types

The Flutter platform channel API has three variants. Most tutorials only mention the first, which leaves two important tools unexplained.

MethodChannel — request and response. Dart calls a named method, native executes it and returns a result. Works like a remote procedure call. The right choice for: one-time operations, SDK calls, reading device state, triggering native behaviour.

EventChannel — a stream from native to Dart. Native pushes data when it's available, Dart listens. The right choice for: sensor readings, location updates, Bluetooth scan results, any ongoing native data source.

BasicMessageChannel — lower-level, supports custom message codecs. Rarely needed. Most use cases are better served by the other two.

A Complete MethodChannel: Integrating a Native Payment SDK

The scenario: a payment terminal SDK exists for iOS and Android. It needs to be initialised with an API key, then called to start a payment flow. The result — success or failure with a reason — comes back to Flutter.

The Dart Side

dart
// lib/services/payment_channel.dart
import 'package:flutter/services.dart';

class PaymentChannel {
  static const _channel = MethodChannel('com.yourapp/payment');

  static Future<void> initialize(String apiKey) async {
    try {
      await _channel.invokeMethod('initialize', {'apiKey': apiKey});
    } on PlatformException catch (e) {
      throw PaymentInitializationException(e.message ?? 'Unknown error');
    }
  }

  static Future<PaymentResult> startPayment({
    required double amount,
    required String currency,
    required String reference,
  }) async {
    try {
      final result = await _channel.invokeMethod<Map>('startPayment', {
        'amount': amount,
        'currency': currency,
        'reference': reference,
      });

      return PaymentResult.fromMap(Map<String, dynamic>.from(result!));
    } on PlatformException catch (e) {
      // e.code — the error code from native
      // e.message — human-readable description
      // e.details — any additional data the native side sent
      throw PaymentException(code: e.code, message: e.message ?? 'Unknown');
    }
  }
}

class PaymentResult {
  final bool success;
  final String transactionId;
  final String? failureReason;

  const PaymentResult({
    required this.success,
    required this.transactionId,
    this.failureReason,
  });

  factory PaymentResult.fromMap(Map<String, dynamic> map) => PaymentResult(
        success: map['success'] as bool,
        transactionId: map['transactionId'] as String,
        failureReason: map['failureReason'] as String?,
      );
}

The channel name com.yourapp/payment is a convention — reverse domain name plus a feature identifier. It must match exactly between Dart and native. Using your app's bundle ID as the prefix avoids collisions with other plugins.

The iOS Side (Swift)

The channel is registered in AppDelegate.swift, where Flutter gives you access to the binary messenger:

swift
// ios/Runner/AppDelegate.swift
import UIKit
import Flutter
import PaymentSDK // your native SDK

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller = window?.rootViewController as! FlutterViewController
    let paymentChannel = FlutterMethodChannel(
      name: "com.yourapp/payment",
      binaryMessenger: controller.binaryMessenger
    )

    paymentChannel.setMethodCallHandler { [weak self] call, result in
      switch call.method {
      case "initialize":
        self?.handleInitialize(call: call, result: result)
      case "startPayment":
        self?.handleStartPayment(call: call, result: result)
      default:
        result(FlutterMethodNotImplemented)
      }
    }

    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  private func handleInitialize(call: FlutterMethodCall, result: FlutterResult) {
    guard let args = call.arguments as? [String: Any],
          let apiKey = args["apiKey"] as? String else {
      result(FlutterError(
        code: "INVALID_ARGS",
        message: "apiKey is required",
        details: nil
      ))
      return
    }

    PaymentSDK.initialize(apiKey: apiKey)
    result(nil) // nil means success with no return value
  }

  private func handleStartPayment(call: FlutterMethodCall, result: FlutterResult) {
    guard let args = call.arguments as? [String: Any],
          let amount = args["amount"] as? Double,
          let currency = args["currency"] as? String,
          let reference = args["reference"] as? String else {
      result(FlutterError(code: "INVALID_ARGS", message: "Missing required arguments", details: nil))
      return
    }

    // ⚠️ Heavy work — dispatch to background thread
    DispatchQueue.global(qos: .userInitiated).async {
      PaymentSDK.processPayment(
        amount: amount,
        currency: currency,
        reference: reference
      ) { paymentResult in
        // ✅ Result must be sent back on the main thread
        DispatchQueue.main.async {
          switch paymentResult {
          case .success(let transactionId):
            result([
              "success": true,
              "transactionId": transactionId,
            ])
          case .failure(let error):
            result([
              "success": false,
              "transactionId": "",
              "failureReason": error.localizedDescription,
            ])
          }
        }
      }
    }
  }
}

The Android Side (Kotlin)

kotlin
// android/app/src/main/kotlin/com/yourapp/MainActivity.kt
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.*
import com.paymentsdk.PaymentSDK // your native SDK

class MainActivity : FlutterActivity() {
  private val channelName = "com.yourapp/payment"
  private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())

  override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)

    MethodChannel(
      flutterEngine.dartExecutor.binaryMessenger,
      channelName
    ).setMethodCallHandler { call, result ->
      when (call.method) {
        "initialize" -> handleInitialize(call, result)
        "startPayment" -> handleStartPayment(call, result)
        else -> result.notImplemented()
      }
    }
  }

  private fun handleInitialize(call: MethodChannel.MethodCallHandler.Result, result: MethodChannel.Result) {
    val apiKey = call.argument<String>("apiKey")
    if (apiKey == null) {
      result.error("INVALID_ARGS", "apiKey is required", null)
      return
    }

    PaymentSDK.initialize(apiKey)
    result.success(null)
  }

  private fun handleStartPayment(call: MethodChannel.MethodCallHandler.Result, result: MethodChannel.Result) {
    val amount = call.argument<Double>("amount")
    val currency = call.argument<String>("currency")
    val reference = call.argument<String>("reference")

    if (amount == null || currency == null || reference == null) {
      result.error("INVALID_ARGS", "Missing required arguments", null)
      return
    }

    // ⚠️ Heavy work — launch coroutine on IO dispatcher
    scope.launch {
      val paymentResult = withContext(Dispatchers.IO) {
        PaymentSDK.processPayment(amount, currency, reference)
      }

      // ✅ Back on Main — safe to call result
      when {
        paymentResult.isSuccess -> result.success(mapOf(
          "success" to true,
          "transactionId" to paymentResult.transactionId,
        ))
        else -> result.success(mapOf(
          "success" to false,
          "transactionId" to "",
          "failureReason" to paymentResult.errorMessage,
        ))
      }
    }
  }

  override fun onDestroy() {
    scope.cancel()
    super.onDestroy()
  }
}

Thread Safety: The Part Everyone Skips

Both examples above dispatch heavy work to background threads and send results back on the main thread. This isn't optional ceremony — it's the thing that separates a working MethodChannel integration from one that randomly freezes the UI or crashes with subtle threading errors.

The MethodChannel handler runs on the main thread on both platforms. If your native operation is fast (reading a device property, toggling a flag), doing it inline is fine. If it involves network calls, disk I/O, a third-party SDK that might block — dispatch it and come back.

On iOS, DispatchQueue.global(qos:).async for the work, DispatchQueue.main.async to call result. On Android, a coroutine with Dispatchers.IO for the work, the result call happens automatically back on main when you withContext.

Calling result from a background thread causes a platform-level exception on both iOS and Android. It's the kind of bug that manifests inconsistently and is genuinely confusing to debug.

EventChannel: When Native Pushes Data to Flutter

MethodChannel is request-response. EventChannel is a stream — native sends data whenever it's available, and Flutter listens continuously. The right tool for sensors, location, Bluetooth scanning, anything that produces ongoing data.

An accelerometer stream:

Dart:

dart
// lib/services/sensor_channel.dart
import 'package:flutter/services.dart';

class SensorChannel {
  static const _eventChannel = EventChannel('com.yourapp/accelerometer');

  static Stream<AccelerometerReading> get accelerometerStream {
    return _eventChannel
        .receiveBroadcastStream()
        .map((event) => AccelerometerReading.fromMap(
              Map<String, double>.from(event as Map),
            ));
  }
}

class AccelerometerReading {
  final double x;
  final double y;
  final double z;

  const AccelerometerReading({
    required this.x,
    required this.y,
    required this.z,
  });

  factory AccelerometerReading.fromMap(Map<String, double> map) =>
      AccelerometerReading(x: map['x']!, y: map['y']!, z: map['z']!);
}

iOS (Swift):

swift
// Register in AppDelegate alongside MethodChannels
let eventChannel = FlutterEventChannel(
  name: "com.yourapp/accelerometer",
  binaryMessenger: controller.binaryMessenger
)
eventChannel.setStreamHandler(AccelerometerHandler())
swift
// ios/Runner/AccelerometerHandler.swift
import CoreMotion

class AccelerometerHandler: NSObject, FlutterStreamHandler {
  private let motionManager = CMMotionManager()

  func onListen(
    withArguments arguments: Any?,
    eventSink events: @escaping FlutterEventSink
  ) -> FlutterError? {
    guard motionManager.isAccelerometerAvailable else {
      return FlutterError(
        code: "UNAVAILABLE",
        message: "Accelerometer not available",
        details: nil
      )
    }

    motionManager.accelerometerUpdateInterval = 0.1
    motionManager.startAccelerometerUpdates(to: .main) { data, error in
      if let error = error {
        events(FlutterError(code: "SENSOR_ERROR", message: error.localizedDescription, details: nil))
        return
      }
      guard let data = data else { return }
      events(["x": data.acceleration.x, "y": data.acceleration.y, "z": data.acceleration.z])
    }
    return nil
  }

  func onCancel(withArguments arguments: Any?) -> FlutterError? {
    motionManager.stopAccelerometerUpdates()
    return nil
  }
}

Android (Kotlin):

kotlin
// In configureFlutterEngine
EventChannel(
  flutterEngine.dartExecutor.binaryMessenger,
  "com.yourapp/accelerometer"
).setStreamHandler(AccelerometerStreamHandler(this))
kotlin
// AccelerometerStreamHandler.kt
import android.content.Context
import android.hardware.*
import io.flutter.plugin.common.EventChannel

class AccelerometerStreamHandler(
  private val context: Context
) : EventChannel.StreamHandler, SensorEventListener {

  private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
  private var eventSink: EventChannel.EventSink? = null

  override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
    eventSink = events
    val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
    if (accelerometer == null) {
      events.error("UNAVAILABLE", "Accelerometer not available", null)
      return
    }
    sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_NORMAL)
  }

  override fun onCancel(arguments: Any?) {
    sensorManager.unregisterListener(this)
    eventSink = null
  }

  override fun onSensorChanged(event: SensorEvent) {
    eventSink?.success(mapOf(
      "x" to event.values[0].toDouble(),
      "y" to event.values[1].toDouble(),
      "z" to event.values[2].toDouble(),
    ))
  }

  override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
}

The onListen / onCancel pair on both platforms handles the stream lifecycle automatically. When Flutter subscribes to the stream, onListen fires. When all subscribers cancel, onCancel fires and you clean up the native sensor registration.

In Dart, this integrates cleanly with BLoC via a StreamSubscription:

dart
class SensorBloc extends Bloc<SensorEvent, SensorState> {
  StreamSubscription? _sensorSubscription;

  SensorBloc() : super(SensorInitial()) {
    on<SensorStartRequested>(_onStart);
    on<SensorReadingReceived>(_onReading);
    on<SensorStopRequested>(_onStop);
  }

  void _onStart(SensorStartRequested event, Emitter<SensorState> emit) {
    _sensorSubscription = SensorChannel.accelerometerStream.listen(
      (reading) => add(SensorReadingReceived(reading)),
      onError: (error) => add(SensorErrorReceived(error.toString())),
    );
  }

  void _onStop(SensorStopRequested event, Emitter<SensorState> emit) {
    _sensorSubscription?.cancel();
    emit(SensorInitial());
  }

  @override
  Future<void> close() {
    _sensorSubscription?.cancel();
    return super.close();
  }
}

Structuring Channels for a Real Project

In a Clean Architecture project — as covered here — MethodChannel wrappers belong in the data layer. The domain layer defines what it needs (a sensor data source, a payment service interface). The data layer implements it, and one of those implementations talks to a MethodChannel.

javascript
features/
└── payment/
    ├── domain/
    │   ├── repositories/
    │   │   └── payment_repository.dart     ← interface
    │   └── use_cases/
    │       └── process_payment_use_case.dart
    └── data/
        ├── channels/
        │   └── payment_channel.dart        ← MethodChannel wrapper
        └── repositories/
            └── payment_repository_impl.dart ← uses the channel

The use case doesn't know MethodChannels exist. The BLoC doesn't know MethodChannels exist. If the vendor ever releases a proper Flutter plugin, you swap the channel wrapper for the plugin in one file, and nothing else changes.

Testing MethodChannels

MethodChannels can be mocked in Flutter tests using TestDefaultBinaryMessengerBinding:

dart
// test/payment_channel_test.dart
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  TestWidgetsFlutterBinding.ensureInitialized();

  setUp(() {
    TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
        .setMockMethodCallHandler(
      const MethodChannel('com.yourapp/payment'),
      (MethodCall call) async {
        switch (call.method) {
          case 'startPayment':
            return {
              'success': true,
              'transactionId': 'test-txn-123',
            };
          default:
            return null;
        }
      },
    );
  });

  tearDown(() {
    TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
        .setMockMethodCallHandler(
      const MethodChannel('com.yourapp/payment'),
      null,
    );
  });

  test('startPayment returns a successful result', () async {
    final result = await PaymentChannel.startPayment(
      amount: 99.99,
      currency: 'USD',
      reference: 'order-001',
    );
    expect(result.success, isTrue);
    expect(result.transactionId, 'test-txn-123');
  });
}

The mock intercepts channel calls before they reach native code, so the test runs without a device or simulator.

MethodChannels vs FFI: The Clear Line

After covering both in this series, the distinction is straightforward:

Use MethodChannels when you need to call platform APIs or integrate a native SDK. The iOS Camera API, an Android NFC reader, a proprietary payment terminal SDK. These are platform capabilities accessed through their native interfaces. Message passing overhead is acceptable because these are inherently async operations.

Use FFI when you have a C or C++ library you need to call directly. Cryptographic algorithms, image processing, signal processing, computational code. No platform API involved — just native computation that Dart needs to reach.

The practical rule: if the vendor gave you a .framework or an .aar, use MethodChannels to wrap it. If the vendor gave you a .h header and a .so library, use FFI.

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.