Week 13: Platform Channels — Calling Native Kotlin/Swift from Dart

Everything in this phase so far has been reachable through a package someone already wrote. Occasionally, nothing does what you need — a platform API with no wrapper, or existing native code to reuse. This week writes a real platform channel from scratch, on both platforms.

Phase 6 of 8 Week 13 of 20 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Understand what a platform channel is, and when writing one is actually the right call
  • Implement a method channel handler in Kotlin for Android
  • Implement the same method channel in Swift for iOS
  • Call the native method from Dart and handle its result

1. Anatomy of a Platform Channel

A platform channel is a named, asynchronous message-passing bridge between Dart and each platform's native code — Dart calls a named method with arguments, native code handles it and returns a result, all over a channel identified by a unique string name shared on both sides.

Before writing one, rule out the alternative — as every earlier week in this phase demonstrated, a package already exists for the overwhelming majority of "I need X from the device" needs. Reach for a platform channel when the platform API genuinely has no wrapper, or you're integrating existing native code that has to be exposed to Dart somehow.

2. Android: a Kotlin MethodChannel Handler

android/app/src/main/kotlin/.../MainActivity.kt
class MainActivity: FlutterActivity() {
    private val CHANNEL = "com.example.app/device_info"

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
            call, result ->
            if (call.method == "getBatteryLevel") {
                val batteryManager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
                val level = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
                result.success(level)
            } else {
                result.notImplemented()
            }
        }
    }
}

result.notImplemented() for any unrecognized method name is important — it's what lets the Dart side receive a clear, catchable error instead of silently hanging when a method name doesn't match on the native side.

3. iOS: the Same Channel in Swift

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/device_info",
                                        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)
      }
    }

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

Same channel name, same method name, same return shape — the Dart side never needs to know which platform it's running on to call this correctly.

4. Calling It from Dart

device_info.dart
class DeviceInfo {
  static const _channel = MethodChannel('com.example.app/device_info');

  static Future<int> getBatteryLevel() async {
    try {
      final level = await _channel.invokeMethod<int>('getBatteryLevel');
      return level ?? -1;
    } on PlatformException catch (e) {
      throw Exception('Failed to get battery level: ${e.message}');
    }
  }
}
using it in a widget
final level = await DeviceInfo.getBatteryLevel();
setState(() => _batteryLevel = level);

From here, DeviceInfo.getBatteryLevel() reads exactly like any other async Dart function — but it's a real requirement of a platform channel that a native rebuild (not just a hot reload) is needed after any change to the Kotlin or Swift side; Dart hot reload has no way to reach the native code at all.

5. Hands-on Exercise

Hands-on

Write and call your own platform channel

Implement a small platform channel on both platforms and call it from a screen.

Requirements:

  1. Pick a small, real capability neither Flutter nor a common package exposes cleanly (battery level, a native toast/alert, or similar).
  2. Implement the method channel handler in Kotlin, returning a result via result.success(...) and handling an unrecognized method with result.notImplemented().
  3. Implement the same channel in Swift with a matching channel name and method name.
  4. Call it from a Dart screen with invokeMethod, wrapped in a try/catch handling PlatformException.
  5. Confirm the exact same Dart call works correctly on both a physical/emulated Android device and an iOS simulator, with no platform-specific branching at the call site.
Hint

If invokeMethod hangs or throws a MissingPluginException, double-check the channel name string matches character-for-character on both the Dart and native sides — a platform channel is matched purely by that string, with no compiler check tying the two together, so a typo on either side fails silently rather than raising a build error.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Before writing a platform channel, what should you check first?

Whether a package already covers the need — the overwhelming majority of device-facing capabilities (as every earlier week in this phase demonstrated) already have one. A platform channel is worth its extra maintenance cost — two native implementations to write and keep working — only once that search comes up genuinely empty.

Q2

Why does calling result.notImplemented() for an unrecognized method name matter, rather than just doing nothing?

Without it, an unrecognized method call would receive no response at all from the native side, which leaves the Dart side's invokeMethod call hanging indefinitely with no way to know something went wrong. notImplemented() (and the equivalent FlutterMethodNotImplemented on iOS) gives Dart a clear, immediate, catchable signal instead.

Q3

Why doesn't a hot reload pick up a change made to the Kotlin or Swift side of a platform channel?

Hot reload only re-injects updated Dart code into the already-running Dart VM — it has no mechanism to recompile and relink native Android/iOS code into the running app binary. A change to either native side requires a full native rebuild (a fresh flutter run from a stopped state, effectively) to actually take effect.

Q4

Why does a channel name typo on either the Dart or native side fail silently instead of causing a build error?

The channel name is just a runtime string matched at the moment a call is made — there's no compile-time link between the Dart MethodChannel('name') and the native MethodChannel(..., name) checked by either toolchain's compiler. A mismatched string on either side simply means the two sides never find each other at runtime, which surfaces as a hang or a MissingPluginException rather than anything caught earlier.