Platform Channels & Native Integration

Writing a Flutter Plugin: Native Android & iOS Implementation

16 min Lesson 9 of 11

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.

Note: If you generated your plugin with 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)
    }
}
Tip: Always call 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/or pluginClass (the Kotlin/Java class name) under flutter.plugin.platforms.android.
  • iOS — set pluginClass to the Swift/ObjC class name under flutter.plugin.platforms.ios.
  • The pluginClass string must match the class name exactly (case-sensitive).
Warning: If you rename the Kotlin or Swift class, you must update 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.