+1 (415) 480-3939

Calling Native Code in 2026: Platform Channels vs. dart:ffi vs. jnigen

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 channelsdart:ffi + ffigenjnigen
TargetsAny platformC, C++, Objective-C, Swift (via C ABI), RustJava and Kotlin (Android, plus JVM on desktop)
Call mechanismAsync message passing, serializedDirect function call, no serializationDirect JNI calls, generated bindings
ThreadingPlatform thread, async by designSynchronous on the calling isolateSynchronous, with explicit thread handling
Best forPlatform APIs with async UI semantics, small call volumeNative libraries, hot loops, large dataAndroid SDK and Kotlin libraries without hand-written glue
Code you writeDart + Kotlin/Swift handlersDart 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.