Calling Native Android and iOS Code from Flutter with Platform Channels
Step‑by‑step guide to set up MethodChannel and EventChannel, handle threading, errors, and use Pigeon for type‑safe bindings.
03 Feb 2026, 13:50 UTC

Desired outcome
Invoke Kotlin or Swift functions from Dart, receive typed results, and stream continuous data without blocking the UI thread.
Prerequisites
- Flutter SDK 3.19+ (stable channel) installed and on PATH.
- A Flutter project with Android (Kotlin) and iOS (Swift) targets (
flutter create my_app). - Basic familiarity with Dart async/await, Android
MainActivity, and iOSAppDelegate. - Optional:
pigeonpackage for type‑safe code generation (add todev_dependencies).
Procedure
1. Define a unique channel name
Use a reverse‑domain prefix to avoid collisions, e.g. com.example.myapp/battery. Keep the same string on Dart, Android, and iOS.
2. Dart side – MethodChannel call
import 'package:flutter/services.dart';
const String channelName = 'com.example.myapp/battery';
final MethodChannel channel = MethodChannel(channelName);
Future getBatteryLevel() async {
try {
final int level = await channel.invokeMethod('getBatteryLevel');
return level;
} on PlatformException catch (e) {
// Handle native error codes
throw Exception('Battery error: ${e.code} – ${e.message}');
}
}Run this from any Dart context (UI, background isolate). Wrap with Future.timeout if you need a hard deadline.
3. Android (Kotlin) – implement MethodCallHandler
In android/app/src/main/kotlin/.../MainActivity.kt:
package com.example.myapp
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Build
import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.MethodCall
class MainActivity : FlutterActivity() {
private val CHANNEL = "com.example.myapp/battery"
override fun configureFlutterEngine(flutterEngine: io.flutter.embedding.engine.FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
if (call.method == "getBatteryLevel") {
val level = getBatteryLevel()
result.success(level)
} else {
result.notImplemented()
}
}
}
private fun getBatteryLevel(): Int {
val intent = registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val level = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
val scale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
return if (level == -1 || scale == -1) -1 else (level * 100 / scale)
}
}Threading note: The handler runs on the platform (UI) thread. Offload any blocking I/O to a coroutine or ExecutorService and call result.success from the background thread.
4. iOS (Swift) – conform to FlutterMethodChannelDelegate
In ios/Runner/AppDelegate.swift:
import UIKit
import Flutter
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
private let channelName = "com.example.myapp/battery"
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let controller = window?.rootViewController as! FlutterViewController
let channel = FlutterMethodChannel(name: channelName, binaryMessenger: controller.binaryMessenger)
channel.setMethodCallHandler { [weak self] call, result in
guard call.method == "getBatteryLevel" else {
result(FlutterMethodNotImplemented)
return
}
self?.receiveBatteryLevel(result: result)
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
private func receiveBatteryLevel(result: @escaping FlutterResult) {
UIDevice.current.isBatteryMonitoringEnabled = true
let level = Int(UIDevice.current.batteryLevel * 100)
result(level >= 0 ? level : -1)
}
}Threading note: The delegate runs on the main thread. Dispatch heavy work to a background queue and invoke result on the main queue.
5. EventChannel for continuous streams (optional)
Replace MethodChannel with EventChannel on Dart side and implement StreamHandler (Android) / FlutterEventChannelDelegate (iOS). Remember to cancel the subscription (streamSubscription.cancel()) and clean up native listeners in onCancel to avoid leaks.
6. Error handling conventions
- Define constant error codes (e.g.,
BATTERY_UNAVAILABLE) on both sides. - Native side calls
result.error(code, message, details)(Android) orresult(FlutterError(code: code, message: message, details: details))(iOS). - Dart catches
PlatformExceptionand inspectscode,message,details.
7. Using Pigeon for type‑safe bindings (recommended)
- Add
dev_dependencies: pigeon: ^9.0.0topubspec.yaml. - Create
pigeon/battery.dart:@HostApi() abstract class BatteryApi { @async func getBatteryLevel() throws -> Int } - Run
flutter pub run pigeon --input pigeon/battery.dartfrom the project root (terminal, no extra permissions). This generates Dart, Kotlin, and Swift files underlib/generated,android/.../generated,ios/.../Generated. - Implement the generated
BatteryApiinterface in Kotlin/Swift and call the generated Dart class instead of rawMethodChannel.
Pigeon eliminates manual argument casting and keeps error codes in sync.
Expected checks
- Run the app on a physical device or emulator (
flutter run). - Call
getBatteryLevel()from a button press; the console should log an integer between 0‑100 (or -1 if unavailable). - Verify no UI jank: open Flutter DevTools → Timeline, ensure the platform thread shows
<16 msper frame during the call. - Unit test the Dart side with a mock:
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/services.dart'; void main() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodChannelHandler(MethodChannel('com.example.myapp/battery'), (call) async { if (call.method == 'getBatteryLevel') return 42; return null; }); test('returns mocked battery level', () async { final level = await getBatteryLevel(); expect(level, 42); }); }
Limitations & recovery
- Payload size: MethodChannel uses JSON‑compatible codec; avoid >1 MB transfers. For bulk data use
EventChannelwith chunked streams or platform‑specific file descriptors. - No cancellation: Dart cannot cancel an in‑flight
invokeMethod. Implement a timeout (Future.timeout(Duration(seconds: 5))) and a native cancellation token if the operation is long‑running. - Memory leaks: EventChannel subscriptions must be cancelled; native listeners must be removed in
onCancel(Android) oronListencleanup (iOS). - Threading: Blocking the platform thread causes jank. Always offload I/O, heavy computation, or synchronous system calls to background threads.
If a native implementation crashes the app, revert the Kotlin/Swift changes and re‑run flutter clean && flutter run – no stateful migration is required.
Verification steps (CI‑ready)
- Add an integration test (
integration_testpackage) that launches the app, taps the battery button, and asserts the returned value is anint. - Run
flutter test integration_test/...on a device farm. - Profile with
flutter run --profileand capture a DevTools timeline; confirm no frames exceed 16 ms on the platform thread. - Trigger the error path (e.g., disable battery monitoring on iOS) and assert the Dart side receives a
PlatformExceptionwith the expected code.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.