Advanced · Lesson 4 · 13 min read
Platform Channels and Native Integration
Call Android and iOS APIs from Dart with method channels, stream native events back, and use FFI for direct C interop.
Updated July 31, 2026
What you will learn
- Call native platform code with MethodChannel
- Receive continuous native events with EventChannel
- Choose between channels, Pigeon and FFI
- Package native functionality as a reusable plugin
Flutter covers most of what apps need through packages, but eventually you hit something with no plugin: a vendor SDK, a device-specific API, a C library. Platform channels are the bridge — asynchronous, message-based, and available on every platform.
MethodChannel: request and response
import 'package:flutter/services.dart';
class BatteryService {
// Namespace the channel name to avoid collisions with plugins
static const _channel = MethodChannel('dev.flutterlearn/battery');
Future<int> getBatteryLevel() async {
try {
final level = await _channel.invokeMethod<int>('getBatteryLevel');
if (level == null) throw const BatteryException('No level returned');
return level;
} on PlatformException catch (e) {
throw BatteryException(e.message ?? 'Platform error: ${e.code}');
} on MissingPluginException {
// The platform has no implementation — e.g. running on web
throw const BatteryException('Not supported on this platform');
}
}
Future<void> setLowPowerWarning({required int threshold}) {
return _channel.invokeMethod<void>('setThreshold', {
'threshold': threshold,
'enabled': true,
});
}
}class MainActivity : FlutterActivity() {
private val channelName = "dev.flutterlearn/battery"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
.setMethodCallHandler { call, result ->
when (call.method) {
"getBatteryLevel" -> {
val level = getBatteryLevel()
if (level != -1) {
result.success(level)
} else {
result.error("UNAVAILABLE", "Battery level unavailable", null)
}
}
"setThreshold" -> {
val threshold = call.argument<Int>("threshold") ?: 20
saveThreshold(threshold)
result.success(null)
}
else -> result.notImplemented()
}
}
}
private fun getBatteryLevel(): Int {
val bm = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
return bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
}
}@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: "dev.flutterlearn/battery",
binaryMessenger: controller.binaryMessenger
)
channel.setMethodCallHandler { call, result in
switch call.method {
case "getBatteryLevel":
UIDevice.current.isBatteryMonitoringEnabled = true
let level = UIDevice.current.batteryLevel
if level < 0 {
result(FlutterError(code: "UNAVAILABLE",
message: "Battery level unavailable",
details: nil))
} else {
result(Int(level * 100))
}
default:
result(FlutterMethodNotImplemented)
}
}
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}Supported types
The standard codec handles primitives, strings, byte arrays, lists and maps. Anything else must be serialised — usually to JSON or a typed byte buffer.
| Dart | Kotlin | Swift |
|---|---|---|
null | null | nil |
bool | Boolean | NSNumber(value:) |
int | Int/Long | NSNumber(value:) |
double | Double | NSNumber(value:) |
String | String | String |
Uint8List | ByteArray | FlutterStandardTypedData |
List | List | Array |
Map | HashMap | Dictionary |
EventChannel: a stream of native events
Method channels are request/response. For continuous data — sensor readings, connectivity changes, download progress — use an EventChannel.
class ConnectivityService {
static const _events = EventChannel('dev.flutterlearn/connectivity');
Stream<bool> get onConnectivityChanged => _events
.receiveBroadcastStream()
.map((event) => event as bool)
.handleError((Object error) {
debugPrint('Connectivity stream error: $error');
});
}
// In the UI
StreamBuilder<bool>(
stream: connectivity.onConnectivityChanged,
builder: (context, snapshot) {
final online = snapshot.data ?? true;
return online ? const SizedBox.shrink() : const OfflineBanner();
},
)EventChannel(messenger, "dev.flutterlearn/connectivity").setStreamHandler(
object : EventChannel.StreamHandler {
private var callback: ConnectivityManager.NetworkCallback? = null
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = events.success(true)
override fun onLost(network: Network) = events.success(false)
}
connectivityManager.registerDefaultNetworkCallback(callback!!)
}
// Called when Dart cancels the subscription — clean up or leak
override fun onCancel(arguments: Any?) {
callback?.let { connectivityManager.unregisterNetworkCallback(it) }
callback = null
}
}
)Pigeon: type-safe channels
Hand-written channels are stringly-typed on both sides — a typo in a method name or argument key becomes a runtime failure. Pigeon generates the Dart, Kotlin and Swift glue from a single Dart definition.
import 'package:pigeon/pigeon.dart';
class BatteryInfo {
BatteryInfo({required this.level, required this.isCharging});
final int level;
final bool isCharging;
}
@HostApi() // Dart calls native
abstract class BatteryApi {
BatteryInfo getBatteryInfo();
void setThreshold(int threshold);
}
@FlutterApi() // native calls Dart
abstract class BatteryEvents {
void onLowBattery(int level);
}dart run pigeon --input pigeons/battery_api.dartFFI for C and C++ libraries
dart:ffi calls native functions directly with no channel, no serialisation and no asynchronous hop. It is the right tool for compute-heavy C/C++ libraries — image codecs, cryptography, ML kernels.
import 'dart:ffi';
import 'dart:io';
import 'package:ffi/ffi.dart';
// C: int32_t sum_array(const int32_t* values, int32_t length);
typedef _SumArrayC = Int32 Function(Pointer<Int32>, Int32);
typedef _SumArrayDart = int Function(Pointer<Int32>, int);
final DynamicLibrary _lib = Platform.isAndroid
? DynamicLibrary.open('libnative_math.so')
: DynamicLibrary.process();
final _sumArray = _lib.lookupFunction<_SumArrayC, _SumArrayDart>('sum_array');
int sumArray(List<int> values) {
final pointer = calloc<Int32>(values.length);
try {
for (var i = 0; i < values.length; i++) {
pointer[i] = values[i];
}
return _sumArray(pointer, values.length);
} finally {
calloc.free(pointer); // manual memory management — no GC here
}
}Packaging as a plugin
flutter create --template=plugin \
--platforms=android,ios \
--org dev.flutterlearn \
flutter_battery_plusA plugin uses the federated structure: a platform-interface package defining the contract, one package per platform implementation, and an app-facing package. That is what lets a third party add Windows support to your plugin without you shipping a release.
- Always provide a graceful fallback for unsupported platforms rather than letting
MissingPluginExceptionreach users. - Native work must not block the platform's main thread — dispatch to a background thread and post results back.
- Test the native side with the platform's own tooling (JUnit, XCTest); Dart tests cannot cover it.
Key takeaways
MethodChannelfor request/response,EventChannelfor continuous native events.- Every native handler branch must call
resultexactly once. - Use Pigeon for type-safe generated channels once you have more than a couple of methods.
- FFI is fastest and least safe — wrap it carefully and always free what you allocate.
Practice
Native device info plugin
Build a plugin exposing device model, OS version and available storage on Android and iOS. Implement it with Pigeon, add an EventChannel for battery level changes, provide a sensible fallback on web, and write native unit tests for both platforms.
Show hints
- Start with the Pigeon definition — it forces you to design the API before writing native code.
- Handle the permission-denied path explicitly; storage APIs differ significantly between platforms.