Flutter Platform Channels

Flutter draws its own screen and runs Dart code, but phones hold many features that only native code can reach: battery level, special sensors, vendor SDKs, and brand-new system APIs. A platform channel is a messaging bridge between Dart and the native side, written in Kotlin or Java on Android, and in Swift on iOS. Plugins from pub.dev use this bridge behind the scenes. Writing your own channel covers the cases where no plugin exists.

The Bridge Picture


  Dart (Flutter)                       Native (Android / iOS)
  ┌─────────────┐   method name +     ┌────────────────┐
  │ invokeMethod│ ──── arguments ───→ │ method handler │
  │ 'getBattery'│                     │ reads battery  │
  │             │ ←───── result ───── │ returns 87     │
  └─────────────┘                     └────────────────┘
          both sides use the same channel name

Think of two offices connected by a phone line. Each office must dial the same number. That number is the channel name.

When to Use a Channel

  • No plugin offers the feature you need.
  • A company provides a native SDK without a Flutter version.
  • You must call a new platform API that existing plugins have not covered.

Search pub.dev first. A maintained plugin saves time and covers edge cases you would otherwise discover through bug reports.

Example: Reading the Battery Level

Dart Side


import 'package:flutter/services.dart';

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

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

Pick a channel name that starts with your reversed domain, which prevents clashes with other plugins.

Android Side (Kotlin)

Edit android/app/src/main/kotlin/.../MainActivity.kt:


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 ->
                if (call.method == "getBatteryLevel") {
                    val manager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
                    val level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
                    result.success(level)
                } else {
                    result.notImplemented()
                }
            }
    }
}

iOS Side (Swift)

Edit ios/Runner/AppDelegate.swift:


import UIKit
import Flutter

@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: "com.example.app/battery",
      binaryMessenger: controller.binaryMessenger)

    channel.setMethodCallHandler { call, result in
      if call.method == "getBatteryLevel" {
        UIDevice.current.isBatteryMonitoringEnabled = true
        let level = Int(UIDevice.current.batteryLevel * 100)
        result(level)
      } else {
        result(FlutterMethodNotImplemented)
      }
    }
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

Confirm the exact file layout against the current Flutter template, because the default project files change between releases.

Passing Arguments

Send a map to give the native side more information.


await _channel.invokeMethod('share', {'text': 'Hello', 'subject': 'Greeting'});

On the Kotlin side, read values with call.argument<String>("text").

Supported Data Types

DartKotlinSwift
nullnullnil
boolBooleanNSNumber (bool)
intInt or LongNSNumber (int)
doubleDoubleNSNumber (double)
StringStringString
ListListArray
MapHashMapDictionary

EventChannel for Continuous Data

A MethodChannel handles one question and one answer. An EventChannel streams a flow of values from native code to Dart, such as sensor readings or connectivity changes. The Dart side receives a Stream that works with StreamBuilder.

Type-Safe Alternative: Pigeon

The Pigeon tool generates matching Dart, Kotlin, and Swift code from one definition. It removes typos in method names and mismatched argument types. Teams with many channel calls adopt it to reduce bugs.

Threading Rules

Handlers run on the main thread of the native app. A slow task, such as reading a large file, freezes the screen. Start long work on a background thread and send the result back when it finishes.

Practice Ideas

Build the battery example for Android first and test it on a physical device. Add a second method that returns the device model name. Wrap the channel in a Dart service class so screens never see the channel name.

Keeping the Bridge Tidy

Treat the channel as a private detail. Wrap it in a Dart class with clear method names, so screens call batteryService.getLevel() and never see the channel name or the argument map. When the native side changes later, one Dart class absorbs the update. A tidy wrapper gives tests an easy target: replace the real service with a fake that returns a fixed number.

Handling Platforms Without an Implementation

A channel that exists on Android may have no counterpart on iOS or the web. Calls to a missing handler throw a MissingPluginException. Catch it and return a sensible fallback, or hide the feature on platforms that lack it. Users on every platform deserve an app that works, even when one feature stays unavailable.