Writing a Flutter Plugin: Native Android & iOS Implementation
Writing a Flutter Plugin: Native Android & iOS Implementation
In the previous lesson you defined a clean Dart interface for your plugin and wired up the MethodChannel on the Flutter side. Now it is time to give that channel life by writing the native implementations: a Kotlin class for Android and a Swift class for iOS. This lesson walks through the full lifecycle — from registering the plugin with the Flutter engine to dispatching method calls and returning results back to Dart.
How Flutter Locates a Plugin
When Flutter starts on a device it spins up a FlutterEngine. Every plugin must register itself with that engine so the engine knows which native class handles which channel name. On Android this happens inside a class that implements FlutterPlugin; on iOS it happens inside a class that conforms to FlutterPlugin (Swift protocol). The pubspec.yaml of your plugin package declares the entry-point class for each platform under the flutter.plugin.platforms key.
flutter create --template=plugin, the scaffold already contains the boilerplate registration code. Understanding what that boilerplate does is essential before you modify it.Android Implementation in Kotlin
The Android plugin class must implement FlutterPlugin and MethodCallHandler. The FlutterPlugin interface provides two lifecycle callbacks — onAttachedToEngine and onDetachedFromEngine — where you create and tear down the channel. The MethodCallHandler interface provides onMethodCall, where you switch on the method name and invoke the corresponding platform code.
Android Plugin — BatteryPlugin.kt
package com.example.battery_info
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Build
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
class BatteryPlugin : FlutterPlugin, MethodCallHandler {
private lateinit var channel: MethodChannel
private lateinit var context: Context
// Called when the plugin is attached to the Flutter engine.
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
context = binding.applicationContext
// Channel name MUST match the Dart side exactly.
channel = MethodChannel(binding.binaryMessenger, "com.example/battery_info")
channel.setMethodCallHandler(this)
}
// Dispatch incoming Dart method calls to the correct native method.
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"getBatteryLevel" -> {
val level = getBatteryLevel()
if (level != -1) {
result.success(level) // returns Int to Dart
} else {
result.error(
"UNAVAILABLE",
"Battery level not available.",
null
)
}
}
"isCharging" -> {
result.success(isCharging()) // returns Boolean to Dart
}
else -> result.notImplemented()
}
}
private fun getBatteryLevel(): Int {
val batteryManager = context
.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
return batteryManager
.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
}
private fun isCharging(): Boolean {
val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
val intent = context.registerReceiver(null, filter) ?: return false
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
return status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL
}
// Called when the engine is destroyed. Always release the channel here.
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}
channel.setMethodCallHandler(null) inside onDetachedFromEngine. Failing to do so leaks the handler reference and can cause crashes when the engine is reused (e.g., in add-to-app scenarios).iOS Implementation in Swift
On iOS the pattern mirrors Android but uses Swift idioms. The class conforms to both NSObject and the FlutterPlugin protocol. The static register(with:) method — the iOS equivalent of onAttachedToEngine — is called by the Flutter engine automatically. You store the channel as a property so you can nil it out during teardown.
iOS Plugin — BatteryPlugin.swift
import Flutter
import UIKit
public class BatteryPlugin: NSObject, FlutterPlugin {
// MARK: - Registration (called once by the Flutter engine)
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "com.example/battery_info", // must match Dart
binaryMessenger: registrar.messenger()
)
let instance = BatteryPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
// MARK: - Method Call Handler
public func handle(
_ call: FlutterMethodCall,
result: @escaping FlutterResult
) {
switch call.method {
case "getBatteryLevel":
let level = getBatteryLevel()
if level >= 0 {
result(level) // NSNumber (Int) to Dart
} else {
result(FlutterError(
code: "UNAVAILABLE",
message: "Battery level not available.",
details: nil
))
}
case "isCharging":
result(isCharging()) // Bool to Dart
default:
result(FlutterMethodNotImplemented)
}
}
// MARK: - Native Helpers
private func getBatteryLevel() -> Int {
UIDevice.current.isBatteryMonitoringEnabled = true
let level = UIDevice.current.batteryLevel
UIDevice.current.isBatteryMonitoringEnabled = false
guard level >= 0 else { return -1 }
return Int(level * 100)
}
private func isCharging() -> Bool {
UIDevice.current.isBatteryMonitoringEnabled = true
let state = UIDevice.current.batteryState
UIDevice.current.isBatteryMonitoringEnabled = false
return state == .charging || state == .full
}
}
Wiring the Dart Interface to the Native Side
With both native classes in place, the Dart API class you wrote in the previous lesson now has a live counterpart on each platform. A call like await BatteryInfo.getBatteryLevel() travels through the codec, crosses the platform boundary, hits onMethodCall (Android) or handle(_:result:) (iOS), and the native result flows back as a Dart Future.
Dart API — battery_info.dart (recap)
import 'package:flutter/services.dart';
class BatteryInfo {
// Channel name is the contract between Dart and native.
static const MethodChannel _channel =
MethodChannel('com.example/battery_info');
/// Returns the battery level as an integer (0–100).
static Future<int> getBatteryLevel() async {
final int level = await _channel.invokeMethod('getBatteryLevel');
return level;
}
/// Returns true if the device is currently charging.
static Future<bool> isCharging() async {
final bool charging = await _channel.invokeMethod('isCharging');
return charging;
}
}
Registering the Plugin in pubspec.yaml
For Flutter to auto-discover your plugin, the pubspec.yaml of the plugin package must declare both platform entry points. Without this declaration the engine will never call register(with:) or onAttachedToEngine and all method calls will silently go unanswered.
- Android — set
dartPluginClass(for Dart registration) and/orpluginClass(the Kotlin/Java class name) underflutter.plugin.platforms.android. - iOS — set
pluginClassto the Swift/ObjC class name underflutter.plugin.platforms.ios. - The
pluginClassstring must match the class name exactly (case-sensitive).
pubspec.yaml to match. A mismatch causes a silent failure at runtime — your channel calls will return MissingPluginException with no visible compile error.Testing the Complete Round-Trip
After wiring both sides, run the app on a physical device (simulators report battery level -1 on iOS) and call the Dart API. Use flutter run --verbose to watch platform channel traffic in the console. For unit tests, mock the channel with TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler so native code is never invoked during CI.
Summary
Implementing the native side of a Flutter plugin requires three coordinated steps: (1) implement FlutterPlugin + MethodCallHandler in Kotlin and register the channel in onAttachedToEngine; (2) conform to FlutterPlugin in Swift and register the channel in the static register(with:) method; (3) ensure the channel name and method strings are identical across all three files. With this in place your Dart API is backed by real device hardware on every platform Flutter supports.