Skip to content
FlutterLearn

Advanced · Lesson 4 · 13 min read

Platform Channels and Native Integration

Call Android and iOS APIs from Dart with method channels, stream native events back, and use FFI for direct C interop.

Updated July 31, 2026

What you will learn

  • Call native platform code with MethodChannel
  • Receive continuous native events with EventChannel
  • Choose between channels, Pigeon and FFI
  • Package native functionality as a reusable plugin

Flutter covers most of what apps need through packages, but eventually you hit something with no plugin: a vendor SDK, a device-specific API, a C library. Platform channels are the bridge — asynchronous, message-based, and available on every platform.

MethodChannel: request and response

lib/platform/battery_service.dart
import 'package:flutter/services.dart';

class BatteryService {
  // Namespace the channel name to avoid collisions with plugins
  static const _channel = MethodChannel('dev.flutterlearn/battery');

  Future<int> getBatteryLevel() async {
    try {
      final level = await _channel.invokeMethod<int>('getBatteryLevel');
      if (level == null) throw const BatteryException('No level returned');
      return level;
    } on PlatformException catch (e) {
      throw BatteryException(e.message ?? 'Platform error: ${e.code}');
    } on MissingPluginException {
      // The platform has no implementation — e.g. running on web
      throw const BatteryException('Not supported on this platform');
    }
  }

  Future<void> setLowPowerWarning({required int threshold}) {
    return _channel.invokeMethod<void>('setThreshold', {
      'threshold': threshold,
      'enabled': true,
    });
  }
}
android/app/src/main/kotlin/.../MainActivity.kt
class MainActivity : FlutterActivity() {
    private val channelName = "dev.flutterlearn/battery"

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

        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
            .setMethodCallHandler { call, result ->
                when (call.method) {
                    "getBatteryLevel" -> {
                        val level = getBatteryLevel()
                        if (level != -1) {
                            result.success(level)
                        } else {
                            result.error("UNAVAILABLE", "Battery level unavailable", null)
                        }
                    }
                    "setThreshold" -> {
                        val threshold = call.argument<Int>("threshold") ?: 20
                        saveThreshold(threshold)
                        result.success(null)
                    }
                    else -> result.notImplemented()
                }
            }
    }

    private fun getBatteryLevel(): Int {
        val bm = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
        return bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
    }
}
ios/Runner/AppDelegate.swift
@main
@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: "dev.flutterlearn/battery",
      binaryMessenger: controller.binaryMessenger
    )

    channel.setMethodCallHandler { call, result in
      switch call.method {
      case "getBatteryLevel":
        UIDevice.current.isBatteryMonitoringEnabled = true
        let level = UIDevice.current.batteryLevel
        if level < 0 {
          result(FlutterError(code: "UNAVAILABLE",
                              message: "Battery level unavailable",
                              details: nil))
        } else {
          result(Int(level * 100))
        }
      default:
        result(FlutterMethodNotImplemented)
      }
    }

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

Supported types

The standard codec handles primitives, strings, byte arrays, lists and maps. Anything else must be serialised — usually to JSON or a typed byte buffer.

DartKotlinSwift
nullnullnil
boolBooleanNSNumber(value:)
intInt/LongNSNumber(value:)
doubleDoubleNSNumber(value:)
StringStringString
Uint8ListByteArrayFlutterStandardTypedData
ListListArray
MapHashMapDictionary

EventChannel: a stream of native events

Method channels are request/response. For continuous data — sensor readings, connectivity changes, download progress — use an EventChannel.

Dart
class ConnectivityService {
  static const _events = EventChannel('dev.flutterlearn/connectivity');

  Stream<bool> get onConnectivityChanged => _events
      .receiveBroadcastStream()
      .map((event) => event as bool)
      .handleError((Object error) {
        debugPrint('Connectivity stream error: $error');
      });
}

// In the UI
StreamBuilder<bool>(
  stream: connectivity.onConnectivityChanged,
  builder: (context, snapshot) {
    final online = snapshot.data ?? true;
    return online ? const SizedBox.shrink() : const OfflineBanner();
  },
)
Kotlin
EventChannel(messenger, "dev.flutterlearn/connectivity").setStreamHandler(
    object : EventChannel.StreamHandler {
        private var callback: ConnectivityManager.NetworkCallback? = null

        override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
            callback = object : ConnectivityManager.NetworkCallback() {
                override fun onAvailable(network: Network) = events.success(true)
                override fun onLost(network: Network) = events.success(false)
            }
            connectivityManager.registerDefaultNetworkCallback(callback!!)
        }

        // Called when Dart cancels the subscription — clean up or leak
        override fun onCancel(arguments: Any?) {
            callback?.let { connectivityManager.unregisterNetworkCallback(it) }
            callback = null
        }
    }
)

Pigeon: type-safe channels

Hand-written channels are stringly-typed on both sides — a typo in a method name or argument key becomes a runtime failure. Pigeon generates the Dart, Kotlin and Swift glue from a single Dart definition.

pigeons/battery_api.dart
import 'package:pigeon/pigeon.dart';

class BatteryInfo {
  BatteryInfo({required this.level, required this.isCharging});
  final int level;
  final bool isCharging;
}

@HostApi()   // Dart calls native
abstract class BatteryApi {
  BatteryInfo getBatteryInfo();
  void setThreshold(int threshold);
}

@FlutterApi()  // native calls Dart
abstract class BatteryEvents {
  void onLowBattery(int level);
}
Terminal
dart run pigeon --input pigeons/battery_api.dart

FFI for C and C++ libraries

dart:ffi calls native functions directly with no channel, no serialisation and no asynchronous hop. It is the right tool for compute-heavy C/C++ libraries — image codecs, cryptography, ML kernels.

Dart
import 'dart:ffi';
import 'dart:io';
import 'package:ffi/ffi.dart';

// C: int32_t sum_array(const int32_t* values, int32_t length);
typedef _SumArrayC = Int32 Function(Pointer<Int32>, Int32);
typedef _SumArrayDart = int Function(Pointer<Int32>, int);

final DynamicLibrary _lib = Platform.isAndroid
    ? DynamicLibrary.open('libnative_math.so')
    : DynamicLibrary.process();

final _sumArray = _lib.lookupFunction<_SumArrayC, _SumArrayDart>('sum_array');

int sumArray(List<int> values) {
  final pointer = calloc<Int32>(values.length);
  try {
    for (var i = 0; i < values.length; i++) {
      pointer[i] = values[i];
    }
    return _sumArray(pointer, values.length);
  } finally {
    calloc.free(pointer);   // manual memory management — no GC here
  }
}

Packaging as a plugin

Terminal
flutter create --template=plugin \
  --platforms=android,ios \
  --org dev.flutterlearn \
  flutter_battery_plus

A plugin uses the federated structure: a platform-interface package defining the contract, one package per platform implementation, and an app-facing package. That is what lets a third party add Windows support to your plugin without you shipping a release.

  • Always provide a graceful fallback for unsupported platforms rather than letting MissingPluginException reach users.
  • Native work must not block the platform's main thread — dispatch to a background thread and post results back.
  • Test the native side with the platform's own tooling (JUnit, XCTest); Dart tests cannot cover it.

Key takeaways

  • MethodChannel for request/response, EventChannel for continuous native events.
  • Every native handler branch must call result exactly once.
  • Use Pigeon for type-safe generated channels once you have more than a couple of methods.
  • FFI is fastest and least safe — wrap it carefully and always free what you allocate.

Practice

Native device info plugin

Build a plugin exposing device model, OS version and available storage on Android and iOS. Implement it with Pigeon, add an EventChannel for battery level changes, provide a sensible fallback on web, and write native unit tests for both platforms.

Show hints
  • Start with the Pigeon definition — it forces you to design the API before writing native code.
  • Handle the permission-denied path explicitly; storage APIs differ significantly between platforms.