Flutter has three ways to call native code in 2026, and the right one depends on what you are calling, not on habit. Platform channels were the only option for years; dart:ffi with ffigen and jnigen now cover cases that channels handle badly. This tutorial wraps one small native capability — reading the device's battery level — all three ways so you can see the trade-offs in code.
The decision in one table
| Platform channels | dart:ffi + ffigen | jnigen | |
|---|---|---|---|
| Targets | Any platform | C, C++, Objective-C, Swift (via C ABI), Rust | Java and Kotlin (Android, plus JVM on desktop) |
| Call mechanism | Async message passing, serialized | Direct function call, no serialization | Direct JNI calls, generated bindings |
| Threading | Platform thread, async by design | Synchronous on the calling isolate | Synchronous, with explicit thread handling |
| Best for | Platform APIs with async UI semantics, small call volume | Native libraries, hot loops, large data | Android SDK and Kotlin libraries without hand-written glue |
| Code you write | Dart + Kotlin/Swift handlers | Dart only (bindings generated) | Dart only (bindings generated) |
Option 1: a platform channel
Channels send a message from Dart to a handler running on the host platform and receive an asynchronous reply. This is still the right tool for wrapping platform UI behavior or infrequent calls.
Dart side:
import 'package:flutter/services.dart';
class BatteryChannel {
static const _channel = MethodChannel('com.example.app/battery');
Future<int> level() async {
final result = await _channel.invokeMethod<int>('getBatteryLevel');
return result ?? -1;
}
}
Android (Kotlin, in MainActivity):
import android.content.Context
import android.os.BatteryManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.example.app/battery")
.setMethodCallHandler { call, result ->
if (call.method == "getBatteryLevel") {
val bm = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
result.success(bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY))
} else {
result.notImplemented()
}
}
}
}
iOS (Swift, in AppDelegate):
import Flutter
import UIKit
@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/battery",
binaryMessenger: controller.binaryMessenger)
channel.setMethodCallHandler { call, result in
guard call.method == "getBatteryLevel" else { result(FlutterMethodNotImplemented); return }
UIDevice.current.isBatteryMonitoringEnabled = true
result(Int(UIDevice.current.batteryLevel * 100))
}
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
Costs: three codebases to keep in sync, every argument and return value serialized through the standard message codec, and a round trip to the platform thread per call. For a battery level that is fine. For a function called per frame, or one passing a megabyte of audio samples, it is not.
Option 2: dart:ffi with ffigen
FFI calls a C-ABI function directly from Dart with no serialization and no platform thread hop. Most native libraries — codecs, crypto, databases, ML runtimes, anything in Rust or C++ — are best wrapped this way.
Write a tiny C shim (Swift and Kotlin cannot be called directly over FFI, but a C function can call into either):
// src/battery.h
#include <stdint.h>
int32_t battery_level(void);
On Android the implementation would read /sys/class/power_supply/battery/capacity; on iOS it calls UIDevice through an Objective-C .m file with a C entry point. Add the sources to a Flutter FFI plugin (flutter create --template=plugin_ffi battery_ffi), which generates the CMake and podspec wiring.
Generate bindings instead of hand-writing them:
# ffigen.yaml
name: BatteryBindings
description: Bindings for battery.h
output: lib/src/battery_bindings.g.dart
headers:
entry-points:
- src/battery.h
dart run ffigen --config ffigen.yaml
Then call it:
import 'dart:ffi';
import 'dart:io';
import 'src/battery_bindings.g.dart';
final _lib = Platform.isAndroid
? DynamicLibrary.open('libbattery_ffi.so')
: DynamicLibrary.process();
final _bindings = BatteryBindings(_lib);
int batteryLevel() => _bindings.battery_level();
The call is synchronous and as fast as a Dart function call. For long-running native work, run it on a helper isolate with Isolate.run so the UI isolate is never blocked — and if the native side needs to call back into Dart asynchronously, NativeCallable.listener handles that safely.
Option 3: jnigen for Android and Kotlin APIs
For Android-only features where the native code is the Android SDK itself, jnigen generates Dart bindings to Java and Kotlin classes, so you never write Kotlin glue at all.
# jnigen.yaml
output:
dart:
path: lib/src/android_bindings.g.dart
android_sdk_config:
add_gradle_deps: true
classes:
- android.os.BatteryManager
- android.content.Context
dart run jnigen --config jnigen.yaml
Then use the Android API from Dart:
import 'package:jni/jni.dart';
import 'src/android_bindings.g.dart';
int batteryLevel() {
final context = Context.fromReference(Jni.getCachedApplicationContext());
final service = context.getSystemService(Context.BATTERY_SERVICE);
final bm = service.as(BatteryManager.type);
final level = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY);
service.release();
context.release();
return level;
}
You get the full Android API surface with generated, typed bindings, direct calls, and no MethodChannel boilerplate. The cost is that JNI references must be released (the release() calls above, or using scopes), and it is Android-only: the iOS side of a cross-platform feature still needs FFI or a channel. The equivalent for Apple platforms, ffigen's Objective-C and Swift binding support, is maturing quickly and is worth evaluating for new plugin work.
Choosing in practice
- Wrapping a platform feature with async semantics (share sheet, permissions, a native view, an existing plugin pattern): platform channel. Use pigeon to generate type-safe channel code rather than hand-writing the codec.
- Calling a native library or doing anything performance-sensitive:
dart:ffi+ ffigen. Consider this the default for new work that is not platform UI. - Android SDK or Kotlin library access with no iOS counterpart: jnigen.
- An existing channel-based plugin that works: leave it. Migrating working channels for purity is not a good use of anyone's budget.
Need help picking the approach for a specific SDK, or untangling an interop layer that has grown all three? Contact us — native integration is a large part of our services.