Skip to content
Blog

MethodChannel for Native Communication in Flutter

Build reliable Flutter method channels: naming and codecs, Kotlin and Swift handlers, error envelopes, testing, and when to switch to Pigeon.

Published on • October 16, 2026

AI Assistant

Every Flutter app eventually needs something Dart cannot do: read a battery level, talk to a HealthKit record, register a share target. MethodChannel is the sanctioned bridge, and it is small enough to learn in an afternoon - but the failure modes (a typo’d channel name, an unhandled error envelope, a MissingPluginException in tests) are what actually cost you days. Here is the whole loop, from channel anatomy to the point where you should stop writing channels by hand.

Channel anatomy: name, codec, two sides

Messages pass asynchronously between the client (your Dart UI) and the host (Kotlin, Swift, and so on) through a channel that is defined by three things:

  1. A name. Both sides must use the identical string. The docs require uniqueness across the app and recommend a domain prefix, for example samples.flutter.dev/battery.
  2. A codec. The standard channel APIs use StandardMessageCodec, which does efficient binary serialization of JSON-like values; MethodChannel wraps it with StandardMethodCodec, which adds the method name and error envelope framing.
  3. A direction. Dart creates a MethodChannel and invokes methods; the host creates a MethodChannel (or FlutterMethodChannel on Apple platforms) and registers a handler. Calls can also run in reverse, with the platform acting as the client of methods implemented in Dart.

Threading is part of the contract: even though Dart sends messages asynchronously, the handler you register must run on the platform’s main thread (the UI thread on iOS).

import 'package:flutter/services.dart';

const MethodChannel battery = MethodChannel('samples.flutter.dev/battery');

Dart side: invoking, and catching what goes wrong

A method call can fail for ordinary reasons - a simulator has no battery, a permission was denied - so wrap invokeMethod in a try/catch and let PlatformException carry the host’s error:

String _batteryLevel = 'Unknown battery level.';

Future<void> _getBatteryLevel() async {
  String batteryLevel;
  try {
    final result = await battery.invokeMethod<int>('getBatteryLevel');
    batteryLevel = 'Battery level at $result % .';
  } on PlatformException catch (e) {
    batteryLevel = "Failed to get battery level: '${e.message}'.";
  }
  if (!mounted) return;
  setState(() => _batteryLevel = batteryLevel);
}

Two habits keep this robust. Always parameterize the generic (invokeMethod<int>) and null-check the result, because a host that returns nothing yields null rather than an exception. And catch PlatformException specifically when you want to show e.code and e.message to the user or to your logs; a bare catch will also swallow your own Dart bugs.

If your Dart code should answer calls from the host, register a handler on the same channel object:

battery.setMethodCallHandler((call) async {
  switch (call.method) {
    case 'onBatteryLow':
      final threshold = (call.arguments as num).toDouble();
      await _enterLowPowerMode(threshold);
      return null;
    default:
      throw MissingPluginException('No implementation for ${call.method}');
  }
});

Kotlin side: register, dispatch, respond

On Android, open the android folder and hook into configureFlutterEngine. Use the exact same channel string:

import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
  private val CHANNEL = "samples.flutter.dev/battery"

  override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)
    MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
      .setMethodCallHandler { call, result ->
        // Invoked on the main thread.
        if (call.method == "getBatteryLevel") {
          val level = getBatteryLevel()
          if (level != -1) {
            result.success(level)
          } else {
            result.error("UNAVAILABLE", "Battery level not available.", null)
          }
        } else {
          result.notImplemented()
        }
      }
  }
}

Every handler resolves exactly one call through one of three paths: result.success(value), result.error(code, message, details), or result.notImplemented(). Failing to call any of them is the classic bug - the Dart future simply never completes.

Swift side: same contract, different types

On iOS the handler lives in AppDelegate.swift and the error type is FlutterError:

let batteryChannel = FlutterMethodChannel(
  name: "samples.flutter.dev/battery",
  binaryMessenger: engineBridge.applicationRegistrar.messenger()
)

batteryChannel.setMethodCallHandler { call, result in
  guard call.method == "getBatteryLevel" else {
    result(FlutterMethodNotImplemented)
    return
  }
  receiveBatteryLevel(result: result)
}

private func receiveBatteryLevel(result: FlutterResult) {
  let device = UIDevice.current
  device.isBatteryMonitoringEnabled = true
  if device.batteryState == UIDevice.BatteryState.unknown {
    result(FlutterError(code: "UNAVAILABLE",
                        message: "Battery level not available.",
                        details: nil))
  } else {
    result(Int(device.batteryLevel * 100))
  }
}

One current gotcha from the docs: if your app adopts the UISceneDelegate lifecycle (the default in Flutter 3.41+), window is nil during application(_:didFinishLaunchingWithOptions:). Create the channel through the FlutterImplicitEngineDelegate protocol’s didInitializeImplicitFlutterEngine hook instead, or you will crash at launch.

StandardMethodCodec type mapping

The codec converts a small set of Dart values to their platform equivalents automatically. The important rows:

DartKotlin / JavaSwiftObjective-C
nullnullnil (NSNull when nested)nil (NSNull when nested)
boolBooleanNSNumber(value: Bool)NSNumber numberWithBool:
int (<= 32 bits)IntNSNumber(value: Int32)NSNumber numberWithInt:
int (> 32 bits)LongNSNumber(value: Int)NSNumber numberWithLong:
doubleDoubleNSNumber(value: Double)NSNumber numberWithDouble:
StringStringStringNSString
Uint8ListByteArrayFlutterStandardTypedData(bytes:)FlutterStandardTypedData typedDataWithBytes:
Int32List / Int64ListIntArray / LongArrayFlutterStandardTypedData variantsFlutterStandardTypedData variants
ListListArrayNSArray
MapHashMapDictionaryNSDictionary

Read that table twice before debugging “type is not a subtype of”. A Dart int crosses as a 32-bit Int only while it fits; big IDs become Long on Android and plain NSNumber on iOS. And there is no implicit conversion between int and double, so a Dart double expectation against a Kotlin Int return will throw a cast error at runtime.

Error envelopes in practice

An error from the host arrives as a PlatformException with three fields: code (a stable machine string like "UNAVAILABLE"), message (human text), and details (anything the codec supports, often a stack or a payload map). Pick codes once and reuse them:

try {
  final token = await channel.invokeMethod<String>('getAuthToken');
} on PlatformException catch (e) {
  switch (e.code) {
    case 'UNAUTHENTICATED':
      return _redirectToLogin();
    case 'UNAVAILABLE':
      return _showRetry(e.message);
    default:
      rethrow;
  }
}

On the Kotlin side, result.error("UNAUTHENTICATED", "Token expired", mapOf("expiredAt" to ts)) and on the Swift side result(FlutterError(code: "UNAUTHENTICATED", message: "Token expired", details: ts)) map onto the same envelope. If the method name is simply unknown, answer notImplemented() / FlutterMethodNotImplemented instead of an error - Dart surfaces that as a MissingPluginException.

MissingPluginException and platform checks

The message you will meet in tests looks like this:

MissingPluginException(No implementation found for method someMethodName on channel some_channel_name)

It has two root causes. The host implementation is not registered (a typo in the channel name, or a platform you never wrote a handler for), or you are running where host code does not exist: the host portion of a plugin is built and registered as part of the app build, so it works under flutter run and in integration tests, but not in plain unit or widget tests.

Handle both:

Future<int> batteryLevel() async {
  if (!Platform.isAndroid && !Platform.isIOS) {
    throw UnsupportedError('Battery API not available here');
  }
  try {
    return await channel.invokeMethod<int>('getBatteryLevel') ?? -1;
  } on MissingPluginException {
    return -1; // host side absent (tests, unsupported platform)
  } on PlatformException catch (e) {
    log('battery failed: ${e.code} ${e.message}');
    return -1;
  }
}

For behavior that differs by platform but stays in pure Dart, the docs point at inspecting defaultTargetPlatform rather than adding a channel.

Testing without the native host

Flutter’s testing guidance lists options in order of preference:

  1. Wrap the plugin. Put the channel call behind your own class and mock that. Your tests stop tracking the plugin’s API and work regardless of how it is implemented.
  2. Mock the plugin’s public API when it is already class-based.
  3. Mock a federated plugin’s platform interface.
  4. Mock the channel itself with TestDefaultBinaryMessenger, only if nothing above applies. The docs warn that channels are untyped internal details that can change in a bugfix release and differ per platform implementation.

A minimal channel mock when you truly need one:

TestWidgetsFlutterBinding.ensureInitialized();

test('returns host value', () async {
  channel.setMockMethodCallHandler((call) async {
    if (call.method == 'getBatteryLevel') return 87;
    return null;
  });
  expect(await channel.invokeMethod<int>('getBatteryLevel'), 87);
});

When to graduate to Pigeon or dart:ffi

Hand-written channels are stringly typed: both sides repeat method names and key strings, and nothing checks that a Kotlin Int matches your Dart int?. The platform channel guide offers two graduation paths.

Pigeon generates type-safe host and client code from a single API definition. You describe the interface once, run the generator, and get Kotlin/Swift (plus Dart) with real method signatures, replacing invokeMethod<String>('x') and manual dispatch with a typed interface. The docs present it as the structured, type-safe alternative to the raw channel APIs, and it is the right move once you have more than a handful of methods or arguments with structure.

dart:ffi is for a different job entirely: calling native APIs or C libraries directly, without message serialization. Since Flutter 3.38 the recommended route is flutter create --template=package_ffi, which uses build hooks (hook/build.dart) plus package:ffigen to compile native code and generate bindings, with no OS-specific build files. Reach for FFI when you want synchronous calls into a C library; reach for Pigeon when you want async request/response against platform APIs.

A sensible progression: raw MethodChannel for one or two methods, Pigeon once the surface grows or the type mismatches start, FFI for C-level performance work, and a wrapped API in your own codebase from day one so tests never care which of the three you picked.

Key Takeaways

  • Channel names must match exactly on both sides and should carry a unique domain prefix; handlers run on the platform’s main thread.
  • Every host handler must resolve the call exactly once with success, error(code, message, details), or notImplemented.
  • PlatformException carries your error envelope - branch on stable code values, and treat MissingPluginException as “no host implementation here”.
  • StandardMessageCodec maps a fixed type set; watch the 32-bit versus 64-bit int split and the lack of implicit int/double coercion.
  • Test by wrapping your channel behind your own API; reserve TestDefaultBinaryMessenger mocks for cases nothing else fits.
  • Graduate to Pigeon for typed multi-method surfaces, and to dart:ffi (the package_ffi template) when you need direct native calls.

References: