Sitemap
Flutter Community

Articles and Stories from the Flutter Community

Direct Native Interop in Flutter 3.50: Let’s understand it! 💙

It’s time to say goodbye to platform channel boilerplate and call native code directly from Dart!

5 min read1 day ago

--

Flutter 3.50 is here, and one feature I’m personally very excited about is direct native interop going stable!

If you have ever written a platform channel, you know the drill. A channel name string in Dart. The same string again in Kotlin. The same again in Swift. And if any one of them doesn’t match, you find out at runtime with a MissingPluginException. We all have been there 😅

With Flutter 3.50, that changes. Let’s understand what it is and how to use it!

What is Direct Native Interop?

Until now, the standard way to talk to Android or iOS from Flutter was platform channels. Your Dart code sends a message, it gets serialized, it reaches the native side, native code does the work, and the reply comes back the same way. Every call is async and pays this cost.

Direct native interop means your Dart code calls Java, Kotlin, or Objective-C APIs directly, using type-safe Dart bindings generated for you.

In simple words, calling a native API becomes just like calling a normal Dart function. No channel names, no MethodCall switch, no Future for something that should be a simple function call.

Press enter or click to view image in full size

What’s new in Flutter 3.50?

  • JNIgen (for Java and Kotlin) is now production-ready.
  • FFIgen (for Objective-C) is now production-ready. Swift works too, as long as your Swift code is exposed to Objective-C using @objc.
  • A Pigeon Native Interop (Preview) that generates type-safe bindings on top of both.
  • You can call native code synchronously, and even from background isolates.

PS: This was possible because of the thread merge that started in Flutter 3.29. Dart code now runs on the platform’s main thread on Android and iOS (and later on desktop too), so there is no wall between Dart and native anymore.

How to use it?

Let’s take the classic “show a toast” example.

Earlier, with a platform channel, you had to write Dart:

const channel = MethodChannel('com.example/toast');
Future<void> showToast(String message) async {
await channel.invokeMethod('showToast', {'message': message});
}

And Kotlin in MainActivity:

MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.example/toast",
).setMethodCallHandler { call, result ->
if (call.method == "showToast") {
val message = call.argument<String>("message")
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
result.success(null)
} else {
result.notImplemented()
}
}

Now, with JNIgen, you just need 3 steps:

Step 1: Add the packages:

flutter pub add jni jni_flutter
flutter pub add dev:jnigen

As of today (10th November 2026), the latest versions are jni 1.1.2, jni_flutter 1.0.4 and jnigen 1.0.1.

Step 2: Create a config file, tool/jnigen.dart:

import 'dart:io';
import 'package:jnigen/jnigen.dart';
void main(List<String> args) async {
final packageRoot = Platform.script.resolve('../');
final generator = JniGenerator(
input: Input(
classes: ['android.widget.Toast', 'java.lang.CharSequence'],
androidSdk: AndroidSdk(
addGradleDeps: true,
androidExample: packageRoot,
),
),
output: Output(
dart: DartOutput(
path: packageRoot.resolve('lib/android_utils.g.dart'),
structure: OutputStructure.singleFile,
),
),
);
await generator.generate();
}

Step 3: Run dart run tool/jnigen.dart and call Android directly from Dart:

import 'package:jni/jni.dart';
import 'package:jni_flutter/jni_flutter.dart';
import 'android_utils.g.dart';
void showToast(String message) {
Toast.makeText$1(
androidApplicationContext,
message.toJString().as(CharSequence.type),
Toast.LENGTH_LONG,
)!.show();
}

That’s it! No Kotlin file, no channel. And notice that it returns void, not Future<void>. It's a direct call!

Get Abhishek Doshi’s stories in your inbox

Join Medium for free to get updates from this writer.

The $1 suffix is how JNIgen handles Java method overloads, since Dart doesn't have overloading. Always check the generated file to make sure you picked the right one.

On iOS, it’s the same idea with FFIgen. You generate bindings from the Objective-C headers (for example, Apple’s EventKit) and use them like this:

final store = EKEventStore();
final start = NSDate.date();
final end = NSDate.dateWithTimeIntervalSinceNow(30.0 * 24 * 60 * 60);
final predicate = store.predicateForEventsWithStartDate(
start,
endDate: end,
);
final events = store.eventsMatchingPredicate(predicate);

You can find complete examples in the official Flutter native interop demos (android_toast_demo and ios_calendar_demo).

Already using Pigeon?

You can try the new Native Interop (Preview) by turning it on in your Pigeon file. Pigeon then runs JNIgen and FFIgen for you:

@ConfigurePigeon(
PigeonOptions(
kotlinOptions: KotlinOptions(useJni: true),
swiftOptions: SwiftOptions(useFfi: true, ffiModuleName: 'Runner'),
),
)

Keep in mind that it is still a preview: it supports Android, iOS and macOS only (no Windows or Linux), and @TaskQueue is not supported. Check out the Pigeon Native Interop guide for the full setup.

Advantages of Direct Native Interop

  • Type safety: No more string-based method names. If something is wrong, the compiler tells you.
  • Synchronous calls: Get results directly, without await.
  • Better performance: No serialization and no thread hops. Great for frequent calls or large data.
  • Less code: You can use Android SDK or Apple framework APIs without writing a native wrapper.

Things to keep in mind

Direct interop takes you closer to native, so there are a few rules to follow:

  • Memory: On Android, every Java object you hold in Dart uses a JNI global reference, and there is a limit of around 50,000. Call .release() in dispose() for long-lived objects. On iOS, use WeakReference to avoid reference cycles.
  • Threads: Synchronous doesn’t mean free. Slow calls on the main thread will still drop frames, so move heavy work to a background isolate. Also, UI-related Objective-C calls must run on the main thread.
  • Type checks: Dart’s is and as don't check the native runtime type. Use .isA(Foo.type) and .as(Foo.type) instead.
  • R8 / ProGuard: Release builds can strip classes that are only used from Dart. Add @Keep to your own Kotlin classes and methods.
  • Build first: JNIgen needs your Android app to be built at least once before generating bindings.
  • Swift: FFIgen talks to Swift through Objective-C, so classes need @objc and should extend NSObject. Pure Swift interop (swiftgen) is still experimental.

Should I migrate my platform channels now?

No need to rush! If your platform channel code works well and is tested, it’s not suddenly bad code. Please don’t feel pressured to rewrite it.

Use direct interop for new native integrations, or for calls where you really need sync results or better performance. If you maintain a plugin, a planned migration in a future major version is totally fine. Your users will thank you for stability.

Also, a big thank you to the Dart and Flutter teams and to everyone in the community who tested this early and reported issues. That’s the reason it’s stable today 💙

Hope you enjoyed this article!

Doubts? Feel free to drop a message @AbhishekDoshi26
Checkout abhishekdoshi.dev for more info 💙

Don’t stop, until you are breathing!💙
- Abhishek Doshi

--

--

Abhishek Doshi
Abhishek Doshi

Written by Abhishek Doshi

Google Developer Expert — Dart, Flutter & Firebase 💙💛

Flutter Community
Flutter Community

Published in Flutter Community

Articles and Stories from the Flutter Community