How Flutter Talks to iOS & Android:

How Flutter Talks to iOS & Android:

A Deep Dive into Method Channels & Native Integration


Introduction

Flutter makes UI development fast and easy. But as soon as you need platform features — like Bluetooth, background tasks, secure storage, sensors, notifications, or camera APIs — one question becomes important:

"How does Flutter communicate with the native side?"

This is where Flutter's engine, platform channels, MethodChannels, EventChannels, Pigeon, FFI, and Platform Views come in. Understanding these is essential not just for plugin authors but for any serious Flutter product, where you continuously integrate native SDKs, hardware devices, analytics frameworks, and OS-level capabilities.

In this blog, we break everything down clearly:

  • How Flutter's engine works internally
  • How the Dart runtime communicates with Swift/Kotlin
  • When to use MethodChannel, EventChannel, or FFI

How Platform Channels Work Internally

Flutter’s architecture:

  • Flutter Framework (Dart): Widgets, state, UI code
  • Flutter Engine (C++): Skia graphics, Dart VM, message channels
  • Embedding Layer (Swift/ObjC/iOS, Kotlin/Java/Android): Connects engine to OS APIs

Flutter encodes each Dart-to-native call as a binary message, delivers it to the corresponding OS thread, and decodes the result back. For best results, offload intensive native work to background threads—use DispatchQueue.global() (Swift) and Dispatchers.IO (Kotlin). Never block main/UI threads in either layer.​


Three Core Communication Models

Channel TypeWhen to UseExample Feature
MethodChannelCall one function, await resultBattery, file picker, SDK call
EventChannelNative source emits stream of eventsBLE sensor, location
BasicMessageChannelCustom, bidirectional raw messagingChat, JSON, custom protocol

MethodChannel: Robust Request & Response

It is the most commonly used platform channel in Flutter — and for good reason. It provides a simple, reliable request–response model between Dart and native code. Think of it as Flutter’s equivalent of making a function call on the other side of the bridge.

  • Example for one-off Dart-to-native (and native-to-Dart) method calls.
  • Triggering a native SDK method
  • Synchronous or async tasks

How does it work?

  • Dart sends a message with:
    • channel name
    • method name
    • optional arguments
  • Native code receives the message through the handler
  • Native runs the requested functionality
  • Native returns a value (or error) to Dart

Dart Implementation

import 'package:flutter/services.dart';

class BatteryService {
  static const MethodChannel _channel =
      MethodChannel('com.mycompany.native/battery');

  Future<int?> getBatteryLevel() async {
    try {
      final int? level = await _channel.invokeMethod<int>('getBatteryLevel');
      return level;
    } on PlatformException catch (e) {
      debugPrint('Native error: ${e.message}');
      return null;
    }
  }
}

Kotlin Implementation

// MainActivity.kt
class MainActivity : FlutterActivity() {
    private val CHANNEL = "com.mycompany.native/battery"

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
            .setMethodCallHandler { call, result ->
                when (call.method) {
                    "getBatteryLevel" -> {
                        CoroutineScope(Dispatchers.IO).launch {
                            val batteryLevel = getBatteryLevel()
                            withContext(Dispatchers.Main) {
                                result.success(batteryLevel)
                            }
                        }
                    }
                    else -> result.notImplemented()
                }
            }
    }

    private fun getBatteryLevel(): Int {
        val manager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
        return manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
    }
}

Swift Implementation

// AppDelegate.swift
import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    override func application(_ application: UIApplication,
                             didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        let controller = window?.rootViewController as! FlutterViewController
        let channel = FlutterMethodChannel(name: "com.mycompany.native/battery",
                                           binaryMessenger: controller.binaryMessenger)
        channel.setMethodCallHandler { call, result in
            switch call.method {
            case "getBatteryLevel":
                DispatchQueue.global().async {
                    let level = self.fetchBatteryLevel()
                    DispatchQueue.main.async {
                        result(level)
                    }
                }
            default:
                result(FlutterMethodNotImplemented)
            }
        }
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }

    private func fetchBatteryLevel() -> Int {
        let device = UIDevice.current
        device.isBatteryMonitoringEnabled = true
        guard device.batteryState != .unknown else { return -1 }
        return Int(device.batteryLevel * 100)
    }
}

EventChannel: Efficient Streaming

It is designed specifically for continuous streams of data coming from the native side into Flutter.
It shines when the native platform needs to push updates repeatedly — without Dart asking every time.

It follows a publisher–subscriber pattern:

  • Native side = publisher of events
  • Dart side = listener/subscriber

Once the stream starts, Flutter receives updates as they happen, with minimal overhead.

  • Recommended for continuous sensor data, notifications, and any scenario where the native layer pushes updates.

Dart Implementation

class LocationService {
  static const EventChannel _eventChannel =
      EventChannel('com.mycompany.native/location');

  Stream<Map<String, double>> get locationStream =>
      _eventChannel.receiveBroadcastStream().map(
          (event) => Map<String, double>.from(event as Map));
}

Kotlin Implementation

// Location streaming with coroutines
EventChannel(flutterEngine.dartExecutor.binaryMessenger, "com.mycompany.native/location")
  .setStreamHandler(object : EventChannel.StreamHandler {
      private var locationManager: LocationManager? = null
      private var locationListener: LocationListener? = null

      override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
          locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
          locationListener = LocationListener { location ->
              CoroutineScope(Dispatchers.IO).launch {
                  val data = mapOf("latitude" to location.latitude, "longitude" to location.longitude)
                  withContext(Dispatchers.Main) { events.success(data) }
              }
          }
          locationManager?.requestLocationUpdates(
              LocationManager.GPS_PROVIDER, 1000L, 10f, locationListener!!
          )
      }
      override fun onCancel(arguments: Any?) {
          locationManager?.removeUpdates(locationListener!!)
          locationManager = null
          locationListener = null
      }
  })

Swift Implementation

// LocationStreamHandler.swift
import CoreLocation

class LocationStreamHandler: NSObject, FlutterStreamHandler, CLLocationManagerDelegate {
    private var locationManager: CLLocationManager?
    private var eventSink: FlutterEventSink?

    func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
        self.eventSink = events
        locationManager = CLLocationManager()
        locationManager?.delegate = self
        locationManager?.requestWhenInUseAuthorization()
        locationManager?.startUpdatingLocation()
        return nil
    }

    func onCancel(withArguments arguments: Any?) -> FlutterError? {
        locationManager?.stopUpdatingLocation()
        locationManager = nil
        eventSink = nil
        return nil
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        if let location = locations.last {
            let data: [String: Double] = [
                "latitude": location.coordinate.latitude,
                "longitude": location.coordinate.longitude
            ]
            eventSink?(data)
        }
    }
}

Register your stream handler in AppDelegate using:

let locationChannel = FlutterEventChannel(name: "com.mycompany.native/location", binaryMessenger: controller.binaryMessenger)
locationChannel.setStreamHandler(LocationStreamHandler())

BasicMessageChannel: Custom Protocols

Use when you need a raw pipe for JSON, custom protocols, large payloads, or specialized bidirectional messaging.

static const messageChannel = BasicMessageChannel<String>(
  'com.mycompany.native/messages', StringCodec());

await messageChannel.send('Hello from Dart');
messageChannel.setMessageHandler((msg) async => 'Hi from Dart');

Advanced Alternatives FFI (Foreign Function Interface)

Perfect for calling C/C++/Rust libraries directly for ML, graphics, encryption, or very large data processing (10-25× faster than channels).

import 'dart:ffi';
import 'dart:io';

final DynamicLibrary lib = Platform.isAndroid
    ? DynamicLibrary.open('libnative.so')
    : DynamicLibrary.process();

typedef NativeAdd = Int32 Function(Int32 a, Int32 b);
typedef DartAdd = int Function(int a, int b);
final add = lib.lookupFunction<NativeAdd, DartAdd>('native_add');
final sum = add(5, 3);

Platform Views

For true native UI embedding (maps, camera, webviews).

AndroidView(
  viewType: 'com.mycompany/webview',
  creationParams: {'url': 'https://example.com'},
  creationParamsCodec: StandardMessageCodec(),
)
UiKitView(
  viewType: 'com.mycompany/webview',
  creationParams: {'url': 'https://example.com'},
  creationParamsCodec: StandardMessageCodec(),
)

Pigeon

Type-safe auto-generation of platform channel code, reducing manual serialization mistakes—highly recommended for large-scale apps.

// pigeon/messages.dart
import 'package:pigeon/pigeon.dart';

class BatteryState {
  int? level;
  bool? isCharging;
}

@HostApi()
abstract class BatteryApi {
  BatteryState getBatteryState();
  void startCharging();
  void stopCharging();
}

After running Pigeon, you implement platform-specific stubs in Kotlin and Swift.

Structuring Large Apps

Adopt a modular codebase structure:

  platform/
    battery/
      battery_platform.dart          # Abstract interface
      battery_method_channel.dart    # MethodChannel implementation
      battery_mock.dart              # Unit test mock implementation
    notifications/
      notification_platform.dart
      notification_method_channel.dart
    bluetooth/
      bluetooth_platform.dart
      bluetooth_event_channel.dart

ios/
  Runner/
    Platform/
      Battery/
        BatteryManager.swift
      Notifications/
        NotificationManager.swift

android/
  app/src/main/kotlin/
    platform/
      battery/
        BatteryManager.kt
      notifications/
        NotificationManager.kt

Every feature has an abstract interface, a channel-based implementation, and mocks for testing. Native folders mirror the Dart structure for clarity and ease of onboarding.​

Best Practices for Production

  • Use clear, namespaced channel names: com.mycompany.native/battery
  • Keep one channel per domain/module—not one global channel
  • Never block the Dart UI or OS main thread
  • Validate all method names and return types on the native side—handle “not implemented” explicitly
  • Batch messages for performance, but keep each payload small; use FFI if streaming or processing huge data
  • Request and manage all device permissions natively
  • Always log channel traffic for debugging
  • Clean up EventChannel subscriptions (avoid leaks)

Common Pitfalls

  • Calling channels inside the widget build() methods (unintended repeat calls)
  • Not handling nulls or exceptions on both sides
  • Failing to move long-running native tasks to a background thread
  • Forgetting to clean up EventChannel listeners in the state lifecycle
  • Neglecting necessary OS permissions before calling native services
  • Creating new channel instances unnecessarily—reuse for efficiency

Debugging

  • Instrument both Dart and native handlers with debug logs for method calls, arguments, and results
  • Use Xcode console and Logcat on devices—add meaningful log tags
  • Use Flutter DevTools to inspect channel communication and isolate message flows for troubleshooting

Hoomanely Context: Why This Matters to Us

For Hoomanely, native integration is not optional — it’s what powers some of our most meaningful product experiences. Our Bluetooth channel for device connection, our analytics stack leverages native SDKs to capture precise behavioral data. Platform channels enable Flutter to orchestrate all of this without compromising performance or accuracy.

Key Takeaways

  • Flutter needs native integration for Bluetooth, notifications, analytics, sensors, and platform APIs.
  • Platform Channels are the bridge between Dart and native code.
  • MethodChannel = request/response; EventChannel = streams; BasicMessageChannel = raw messaging..
  • On the native side, you only register the MethodChannel and handle incoming calls.
  • Use FFI for heavy, performance-critical operations instead of MethodChannels.
  • Pigeon provides type-safe, auto-generated native interfaces.
  • Use Platform Views when embedding native UI widgets.

Read more