Flutter Platform Channels: Native Code Interop

13 min readMarch 9, 2026
Flutter platform channelsFlutter MethodChannelFlutter native integrationFlutter PigeonFlutter FFIFlutter iOS integrationFlutter Android integrationFlutter EventChannel

# Flutter Platform Channels and Native Interop

Flutter gives you a single codebase for iOS, Android, and beyond. But sooner or later, you need something that only the native platform can provide -- a proprietary SDK, a hardware sensor with no existing plugin, or a platform API that Flutter simply does not wrap. That is where platform channels come in.

Platform channels are the communication bridge between Dart and native code. They let you call Swift/Objective-C on iOS and Kotlin/Java on Android, receive results back, and even stream continuous data. When I needed to integrate a client's proprietary biometric SDK that had no Flutter plugin, platform channels saved the project. There was no alternative. Understanding this mechanism turned what could have been a rewrite into a straightforward integration.

The Three Channel Types

Flutter provides three distinct channel types, each designed for a different communication pattern. Choosing the right one matters.

MethodChannel

The most common type. It works like a remote procedure call: Dart sends a method name and arguments, the native side executes the logic and returns a result. Communication is asynchronous and bidirectional -- native code can also invoke methods on the Dart side.

EventChannel

Built for streaming data from native to Dart. Think sensor readings, location updates, or Bluetooth scan results. The native side sets up a stream, and Dart listens to it with a standard `Stream` object.

BasicMessageChannel

The simplest channel. It sends and receives unstructured messages using a codec you choose (binary, string, JSON). Useful when you need a lightweight communication pipe without the method-call semantics.

MethodChannel: The Workhorse

Dart Side

dart
import class="code-string">'package:flutter/services.dart';

class BatteryService {
  static const _channel = MethodChannel(class="code-string">'com.example.app/battery');

  Future<int> getBatteryLevel() async {
    try {
      final int level = await _channel.invokeMethod(class="code-string">'getBatteryLevel');
      return level;
    } on PlatformException catch (e) {
      throw Exception(class="code-string">'Failed to get battery level: ${e.message}');
    }
  }

  Future<bool> isCharging() async {
    final bool charging = await _channel.invokeMethod(class="code-string">'isCharging');
    return charging;
  }
}

iOS Side (Swift)

swift
import Flutter
import UIKit

@main
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller = window?.rootViewController as! FlutterViewController
    let batteryChannel = FlutterMethodChannel(
      name: "com.example.app/battery",
      binaryMessenger: controller.binaryMessenger
    )

    batteryChannel.setMethodCallHandler { (call, result) in
      switch call.method {
      case "getBatteryLevel":
        let device = UIDevice.current
        device.isBatteryMonitoringEnabled = true
        let level = Int(device.batteryLevel * 100)
        if level >= 0 {
          result(level)
        } else {
          result(FlutterError(
            code: "UNAVAILABLE",
            message: "Battery level not available on simulator",
            details: nil
          ))
        }
      case "isCharging":
        let device = UIDevice.current
        device.isBatteryMonitoringEnabled = true
        let state = device.batteryState
        result(state == .charging || state == .full)
      default:
        result(FlutterMethodNotImplemented)
      }
    }

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

Android Side (Kotlin)

kotlin
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import android.os.BatteryManager
import android.content.Context

class MainActivity : FlutterActivity() {
    private val CHANNEL = "com.example.app/battery"

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

        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
            .setMethodCallHandler { call, result ->
                when (call.method) {
                    "getBatteryLevel" -> {
                        val batteryManager =
                            getSystemService(Context.BATTERY_SERVICE) as BatteryManager
                        val level = batteryManager.getIntProperty(
                            BatteryManager.BATTERY_PROPERTY_CAPACITY
                        )
                        if (level >= 0) {
                            result.success(level)
                        } else {
                            result.error("UNAVAILABLE", "Battery level not available", null)
                        }
                    }
                    "isCharging" -> {
                        val batteryManager =
                            getSystemService(Context.BATTERY_SERVICE) as BatteryManager
                        val charging = batteryManager.isCharging
                        result.success(charging)
                    }
                    else -> result.notImplemented()
                }
            }
    }
}

The channel name `com.example.app/battery` must be identical on both sides. I use reverse-domain notation to avoid collisions with plugins.

EventChannel: Streaming Native Data

EventChannel is the right tool when native code produces a continuous flow of data.

Dart Side

dart
class AccelerometerService {
  static const _eventChannel = EventChannel(class="code-string">'com.example.app/accelerometer');

  Stream<AccelerometerReading> get readings {
    return _eventChannel.receiveBroadcastStream().map((event) {
      final data = Map<String, double>.from(event as Map);
      return AccelerometerReading(
        x: data[class="code-string">'x']!,
        y: data[class="code-string">'y']!,
        z: data[class="code-string">'z']!,
      );
    });
  }
}

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

iOS Side (Swift)

swift
import CoreMotion

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

    func onListen(withArguments arguments: Any?,
                  eventSink events: @escaping FlutterEventSink) -> FlutterError? {
        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
            }
            if let data = data {
                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
    }
}

Register the handler in your `AppDelegate`:

swift
let accelChannel = FlutterEventChannel(
    name: "com.example.app/accelerometer",
    binaryMessenger: controller.binaryMessenger
)
accelChannel.setStreamHandler(AccelerometerStreamHandler())

Always implement `onCancel` properly. Forgetting to stop the sensor when the Dart stream is cancelled is one of the most common resource leak bugs in platform channel code.

BasicMessageChannel

When you need to send simple messages without method-call structure, `BasicMessageChannel` is the lightest option.

dart
const messageChannel = BasicMessageChannel<String>(
  class="code-string">'com.example.app/status',
  StringCodec(),
);

class=class="code-string">"code-comment">// Send a message
await messageChannel.send(class="code-string">'ping');

class=class="code-string">"code-comment">// Receive messages from native
messageChannel.setMessageHandler((String? message) async {
  print(class="code-string">'Received from native: $message');
  return class="code-string">'acknowledged';
});

In practice, I rarely use `BasicMessageChannel` directly. `MethodChannel` covers most use cases better. But it is worth knowing about for scenarios like sending raw config strings or simple status flags.

Pigeon: Type-Safe Channels Without Boilerplate

Writing platform channel code by hand is tedious and error-prone. Channel names can have typos, argument maps can miss keys, and return types are untyped `dynamic` values. Pigeon solves all of this.

Pigeon is a code-generation tool from the Flutter team. You define your API in a Dart file, and it generates the Dart, Swift/Objective-C, Kotlin/Java, and C++ boilerplate for you. Everything is type-safe.

Define the API

dart
import class="code-string">'package:pigeon/pigeon.dart';

class DeviceInfo {
  String? model;
  String? osVersion;
  int? batteryLevel;
}

@HostApi()
abstract class DeviceApi {
  DeviceInfo getDeviceInfo();
  bool isFeatureSupported(String featureName);
}

@FlutterApi()
abstract class DeviceEventApi {
  void onBatteryLevelChanged(int level);
}

Run the generator:

bash
dart run pigeon --input pigeons/device_api.dart

Pigeon generates all the channel setup, serialization, and deserialization. On the Dart side, you get a clean class to call. On the native side, you implement a protocol (Swift) or interface (Kotlin). No more string-based method names or untyped maps.

When I switched a project from hand-written channels to Pigeon, the number of serialization bugs dropped to zero. The initial setup takes twenty minutes, but it pays for itself on the first refactor.

FFI: Direct Native Calls with dart:ffi

Platform channels communicate asynchronously through message passing. For performance-critical code, you can bypass this entirely using `dart:ffi`, which calls C/C++ functions directly from Dart with near-zero overhead.

dart
import class="code-string">'dart:ffi';
import class="code-string">'package:ffi/ffi.dart';

typedef NativeAddFunc = Int32 Function(Int32 a, Int32 b);
typedef DartAddFunc = int Function(int a, int b);

class NativeMath {
  late final DartAddFunc _add;

  NativeMath() {
    final dylib = DynamicLibrary.open(class="code-string">'libnative_math.so');
    _add = dylib.lookupFunction<NativeAddFunc, DartAddFunc>(class="code-string">'add_numbers');
  }

  int add(int a, int b) => _add(a, b);
}

The corresponding C code:

c
#include <stdint.h>

int32_t add_numbers(int32_t a, int32_t b) {
    return a + b;
}

FFI is ideal for image processing, cryptography, or any computation where the overhead of message serialization is unacceptable. But it only works with C-compatible libraries. For Swift or Kotlin APIs, you still need platform channels.

Use the `ffigen` package to auto-generate Dart bindings from C headers. Writing FFI bindings by hand for a large API surface is a recipe for memory bugs.

Platform-Specific Code Organization

As your native interop grows, organization becomes critical. Here is the structure I use:

lib/
  platform/
    battery_service.dart          # Public Dart API
    battery_service_method_channel.dart  # MethodChannel impl
    battery_service_platform_interface.dart  # Abstract interface
ios/
  Runner/
    Channels/
      BatteryChannelHandler.swift
android/
  app/src/main/kotlin/com/example/
    channels/
      BatteryChannelHandler.kt

This mirrors how official Flutter plugins are structured. The abstract interface allows you to swap implementations for testing or for different platforms (web, desktop).

dart
abstract class BatteryServicePlatform {
  Future<int> getBatteryLevel();
  Future<bool> isCharging();
}

class BatteryServiceMethodChannel implements BatteryServicePlatform {
  final _channel = const MethodChannel(class="code-string">'com.example.app/battery');

  @override
  Future<int> getBatteryLevel() async {
    return await _channel.invokeMethod<int>(class="code-string">'getBatteryLevel') ?? -class="code-number">1;
  }

  @override
  Future<bool> isCharging() async {
    return await _channel.invokeMethod<bool>(class="code-string">'isCharging') ?? false;
  }
}

Error Handling Across the Platform Boundary

Error handling is where platform channel code gets tricky. Exceptions on the native side do not automatically propagate to Dart. You must explicitly use the error-passing mechanisms.

Native to Dart Errors

On iOS, return a `FlutterError`:

swift
result(FlutterError(code: "PERMISSION_DENIED",
                    message: "Camera permission not granted",
                    details: ["requiredPermission": "camera"]))

On Android, use `result.error()`:

kotlin
result.error("PERMISSION_DENIED",
             "Camera permission not granted",
             mapOf("requiredPermission" to "camera"))

On the Dart side, catch `PlatformException`:

dart
try {
  await _channel.invokeMethod(class="code-string">'startCamera');
} on PlatformException catch (e) {
  if (e.code == class="code-string">'PERMISSION_DENIED') {
    class=class="code-string">"code-comment">// Show permission request dialog
  } else {
    class=class="code-string">"code-comment">// Log unexpected error
    debugPrint(class="code-string">'Platform error: ${e.code} - ${e.message}');
  }
}

Define your error codes as constants shared across platforms. I keep a `ChannelErrorCodes` class in Dart and mirror the values in Swift and Kotlin. This prevents typos in error code strings from causing silent failures.

Platform Channels vs Existing Plugins

Before writing any platform channel code, check pub.dev thoroughly. The Flutter ecosystem has mature plugins for most common needs.

Use an existing plugin when:

  • A well-maintained plugin exists (check the pub.dev score, last update, and issue tracker).
  • The plugin covers your requirements fully or mostly.
  • The plugin is from the Flutter team or a reputable publisher.
  • Write your own platform channels when:

  • You need to integrate a proprietary native SDK.
  • No plugin exists for the platform API you need.
  • Existing plugins are unmaintained or have critical bugs you cannot work around.
  • You need fine-grained control over the native implementation.
  • Performance requirements demand FFI instead of message passing.
  • When I needed to integrate native SDKs for a fintech project, no plugin existed. Writing the platform channel layer took about two days, but the result was a clean, testable abstraction that outlived three major SDK version updates without breaking.

    Common Mistakes

    Threading Issues

    Platform channel calls arrive on the platform's UI thread. On Android, this means the main thread. If your native code does heavy work (network calls, disk I/O, large computations), you must dispatch to a background thread. Otherwise, you will freeze the UI.

    kotlin
    "code-comment">// Wrong: Blocking the main thread
    call.method == "processImage" -> {
        val result = heavyImageProcessing() "code-comment">// Blocks UI
        result.success(result)
    }
    
    "code-comment">// Right: Dispatch to a background thread
    call.method == "processImage" -> {
        thread {
            val processed = heavyImageProcessing()
            Handler(Looper.getMainLooper()).post {
                result.success(processed)
            }
        }
    }

    On iOS, remember that the result callback must be called on the main thread:

    swift
    case "processImage":
        DispatchQueue.global(qos: .userInitiated).async {
            let processed = self.heavyImageProcessing()
            DispatchQueue.main.async {
                result(processed)
            }
        }

    Serialization Pitfalls

    Platform channels use the Standard Method Codec, which supports a limited set of types: `null`, `bool`, `int`, `double`, `String`, `Uint8List`, `Int32List`, `Float64List`, `List`, and `Map`. If you try to pass a custom Dart object or a native object that is not one of these types, you get a codec error at runtime.

    Always serialize complex objects to `Map` before sending them across the channel. Better yet, use Pigeon to avoid this entire class of bugs.

    Calling result() Multiple Times

    On the native side, calling `result()` more than once for the same method call crashes the app. This is especially easy to do accidentally in callback-based APIs where both a success callback and an error callback might fire.

    Forgetting to Handle Missing Methods

    Always include a default case that returns `FlutterMethodNotImplemented`. Without it, calling an unimplemented method from Dart will hang forever, waiting for a response that never comes.

    Personal Experience

    When I needed to integrate a native payment SDK that had no Flutter plugin, platform channels were the only option. The SDK provided Swift and Kotlin APIs with complex callback chains, custom error types, and UI components that needed to be presented natively.

    I started with hand-written MethodChannels. It worked, but maintaining the serialization code across three languages was painful. Every time the SDK updated, I had to update the Dart model, the Swift serialization, and the Kotlin serialization. After the third SDK update, I migrated to Pigeon. The migration took a single afternoon, and subsequent SDK updates became a matter of updating the Pigeon definition file and re-running the generator.

    The key lesson: start with platform channels to understand the communication model, then graduate to Pigeon as soon as you have more than two or three methods. For anything performance-critical that can be expressed as a C API, use FFI.

    Conclusion

    Platform channels are the escape hatch that makes Flutter truly flexible. You are not locked into what the framework provides. Any native API, any proprietary SDK, any platform capability is accessible. The three channel types cover different communication patterns, Pigeon eliminates the boilerplate, and FFI handles the performance-critical edge cases.

    Master these tools, and there is no integration challenge that Flutter cannot handle.

    If you are facing a native integration challenge or need help designing your platform channel architecture, let's talk.

    Related Articles

    Have a Flutter Project?

    I build high-performance Flutter applications for iOS, Android, and web.

    Get in Touch