Implementing Flutter Platform Channels for Native API Access
Build and verify Flutter MethodChannel and EventChannel implementations for battery level and sensor streaming with Kotlin and Swift native code, including failure recovery checklist.
20 Jun 2026, 04:32 UTC

Problem and Takeaway
Flutter's platform channels enable Dart code to call Android (Kotlin) and iOS (Swift) APIs directly when no suitable plugin exists. This guide walks through building a working MethodChannel to read battery level, plus an EventChannel for streaming sensor data. You'll finish with a verified cross-platform implementation and a checklist to avoid common failures.
Prerequisites
- Flutter SDK >= 3.0 (verify with
flutter --version) - Android Studio with Kotlin plugin and Xcode 14+
- Existing Flutter project with
android/app/src/main/kotlinandios/Runnerdirectories - Basic Kotlin and Swift syntax familiarity
Channel Design Decisions
Use reverse-domain channel names to prevent collisions: com.yourcompany.feature/action. For battery level, a single request-response fits MethodChannel. For continuous sensor updates, use EventChannel with a StreamHandler on each side. BasicMessageChannel is reserved for raw binary payloads (e.g., Protocol Buffers) — avoid it for structured RPC.
Step 1: Dart Side — MethodChannel Setup
Create a service class to encapsulate channel logic. Place it in lib/services/platform_battery.dart.
import 'package:flutter/services.dart';
class PlatformBatteryService {
static const _channel = MethodChannel('com.example.battery/level');
Future<int?> getBatteryLevel() async {
try {
final result = await _channel.invokeMethod<int>('getBatteryLevel');
return result;
} on PlatformException catch (e) {
// Log e.code, e.message, e.details for diagnostics
return null;
}
}
}
Call it from a widget:
final level = await PlatformBatteryService().getBatteryLevel();
if (level != null) setState(() => _batteryLevel = level);
Step 2: Android Implementation (Kotlin)
Edit android/app/src/main/kotlin/com/example/app/MainActivity.kt. Register the handler in configureFlutterEngine so the channel exists before Dart calls it.
package com.example.app
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Build
import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity: FlutterActivity() {
private val CHANNEL = "com.example.battery/level"
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -
if (call.method == "getBatteryLevel") {
val batteryLevel = getBatteryLevel()
if (batteryLevel != -1) result.success(batteryLevel) else result.error("UNAVAILABLE", "Battery level not available", null)
} else {
result.notImplemented()
}
}
}
private fun getBatteryLevel(): Int {
val batteryManager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
} else {
// Legacy API for API < 21
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
if (level == -1 || scale == -1) -1 else (level * 100 / scale)
}
}
}
Critical: The handler runs on the main thread. Heavy I/O must be offloaded to a coroutine or executor.
Step 3: iOS Implementation (Swift)
Edit ios/Runner/AppDelegate.swift. Register the channel in application(_:didFinishLaunchingWithOptions:).
import UIKit
import Flutter
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
private let channelName = "com.example.battery/level"
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let controller = window?.rootViewController as! FlutterViewController
let batteryChannel = FlutterMethodChannel(name: channelName, binaryMessenger: controller.binaryMessenger)
batteryChannel.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)
if level >= 0 {
result(level)
} else {
result(FlutterError(code: "UNAVAILABLE", message: "Battery level not available", details: nil))
}
}
}
Enable battery monitoring before reading; the first read may return -1 until the system updates.
Step 4: EventChannel for Streaming Data (Optional)
For continuous updates (e.g., accelerometer), define an EventChannel on both sides.
Dart
class SensorStreamService {
static const _channel = EventChannel('com.example.sensor/accelerometer');
Stream<Map<String, double>> get accelerometerStream {
return _channel.receiveBroadcastStream().cast()
}
}
Android (Kotlin) — StreamHandler
class AccelerometerStreamHandler(private val context: Context) : EventChannel.StreamHandler {
private var sensorManager: SensorManager? = null
private var listener: SensorEventListener? = null
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val sensor = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
events.success(mapOf(
"x" to event.values[0].toDouble(),
"y" to event.values[1].toDouble(),
"z" to event.values[2].toDouble()
))
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
}
sensor?.let { sensorManager?.registerListener(listener, it, SensorManager.SENSOR_DELAY_NORMAL) }
}
override fun onCancel(arguments: Any?) {
listener?.let { sensorManager?.unregisterListener(it) }
listener = null
sensorManager = null
}
}
// Register in MainActivity.configureFlutterEngine:
EventChannel(flutterEngine.dartExecutor.binaryMessenger, "com.example.sensor/accelerometer")
.setStreamHandler(AccelerometerStreamHandler(this))
iOS (Swift) — FlutterStreamHandler
class AccelerometerStreamHandler: NSObject, FlutterStreamHandler {
private var motionManager: CMMotionManager?
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
motionManager = CMMotionManager()
motionManager?.accelerometerUpdateInterval = 0.1
motionManager?.startAccelerometerUpdates(to: .main) { data, error in
if let error = error { events(FlutterError(code: "ERROR", message: error.localizedDescription, details: nil)) }
else if let data = data {
events(["x": data.acceleration.x, "y": data.acceleration.y, "z": data.acceleration.z])
}
}
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
motionManager?.stopAccelerometerUpdates()
motionManager = nil
return nil
}
}
// Register in AppDelegate:
let sensorChannel = FlutterEventChannel(name: "com.example.sensor/accelerometer", binaryMessenger: controller.binaryMessenger)
sensorChannel.setStreamHandler(AccelerometerStreamHandler())
Dispose Dart subscriptions in dispose() to trigger onCancel and release native resources.
Verification Checklist
- Run
flutter doctor -v— confirm Android toolchain and Xcode are green. - Build and run on Android emulator:
flutter run -d emulator-5554. Observe battery level integer in logs. - Build and run on iOS simulator:
flutter run -d "iPhone 15". Verify same numeric result. - Add
Log.d("BatteryChannel", "Result: $batteryLevel")(Android) andprint("BatteryChannel: \(level)")(iOS) to confirm native invocation. - For EventChannel: subscribe in a widget, rotate device, confirm stream emits maps with x/y/z doubles. Unsubscribe and verify
onCancelfires (log in native handler).
Common Failures and Recovery
| Symptom | Cause | Fix |
|---|---|---|
MissingPluginException | Channel not registered before Dart call | Ensure registration in configureFlutterEngine (Android) or application(_:didFinishLaunchingWithOptions:) (iOS) — both run before Flutter view renders. |
| Null or -1 result | Battery monitoring disabled (iOS) or legacy API path | Call UIDevice.current.isBatteryMonitoringEnabled = true before read; test on physical device. |
| EventChannel leaks | Missing onCancel cleanup | Always unregister listeners/sensors in onCancel; verify with Instruments/Leaks. |
| UI thread crash | Heavy work in handler | Offload to DispatchQueue.global() (iOS) or Coroutines.io (Android); return result via result.success() on main thread. |
Limitations and Alternatives
- MethodChannel calls are asynchronous and unordered — don't rely on call order for stateful operations.
- Data crossing the channel is serialized via standard message codecs (JSON-compatible types: num, String, bool, List, Map). Custom objects require manual encoding.
- For Bluetooth, camera, or secure storage, prefer maintained plugins (
flutter_blue_plus,camera,flutter_secure_storage) — they handle permissions, lifecycle, and edge cases. - Platform channels add maintenance burden: every OS update may require native code changes.
Final Verification Command
Run a clean build on both platforms to catch registration timing issues:
flutter clean && flutter pub get
flutter build apk --debug
flutter build ios --debug --no-codesign
If both produce artifacts without MissingPluginException in device logs, the channel is correctly wired.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.