← Back to blog Khalil Drissi

Flutter platform channels: calling native Android and iOS code

Listen to article
0:00

Flutter covers an enormous amount of ground, but eventually you hit something it does not expose. A specific sensor, a vendor SDK, a piece of platform behaviour with no plugin. When that happens you reach for platform channels, which let your Dart code call native Kotlin on Android and Swift on iOS. I have used them for everything from Bluetooth hardware to a payment SDK, and they are less scary than they look.

How a channel works

A platform channel is a named pipe between Dart and the native side. You give it a string name, you send a method call with optional arguments, and the native side responds with a result or an error. Messages are serialised with a standard codec that handles common types like strings, numbers, lists, and maps. You do not get to pass objects directly, so you design a small flat contract and stick to it.

import 'package:flutter/services.dart';

class Battery {
  static const _channel = MethodChannel('app/battery');

  Future<int> level() async {
    final result = await _channel.invokeMethod('getLevel');
    return result as int;
  }
}

The channel name has to match exactly on both sides. A typo gives you a missing implementation error at runtime and no compiler will warn you, so I keep the channel name in one constant and reference it everywhere.

The Android side in Kotlin

On Android you register a handler in your main activity. It receives the method name as a string, switches on it, and replies through the result callback. Anything you do here runs on the platform thread, so heavy work needs to move off it or you will jank the UI, a topic I cover in Flutter performance optimization.

class MainActivity : FlutterActivity() {
  override fun configureFlutterEngine(engine: FlutterEngine) {
    super.configureFlutterEngine(engine)
    MethodChannel(engine.dartExecutor.binaryMessenger, "app/battery")
      .setMethodCallHandler { call, result ->
        if (call.method == "getLevel") {
          result.success(readBatteryLevel())
        } else {
          result.notImplemented()
        }
      }
  }
}

The iOS side in Swift

The iOS setup mirrors the Android one. You register the same channel name inside the app delegate and handle the call. The shape is identical even though the language differs, which is one of the things I appreciate about the design. Once you learn the pattern on one platform the other feels familiar.

let channel = FlutterMethodChannel(
  name: "app/battery",
  binaryMessenger: controller.binaryMessenger)

channel.setMethodCallHandler { call, result in
  if call.method == "getLevel" {
    result(self.readBatteryLevel())
  } else {
    result(FlutterMethodNotImplemented)
  }
}

Handling errors and threads

Native calls fail. The hardware is missing, a permission is denied, the SDK throws. Send those failures back as errors rather than swallowing them, and catch them on the Dart side so the UI can react. I wrap every channel call in a try block and surface a clear message to the user instead of a raw platform exception.

When to write a plugin instead

If the native code is something other apps could use, package it as a plugin rather than burying it in one app. A plugin wraps the same channel mechanics but gives you a clean Dart API and a reusable structure. Before you write either, search the package registry, because the thing you need often already exists and is better tested than a fresh attempt. Platform channels are powerful, but the best native code is the code you did not have to write. For the basics of project setup before you get here, see getting started with Flutter.

Comments
Leave a comment