Skip to content

Add FFI and JNI support to Swift and Kotlin#11352

Open
tarrinneal wants to merge 131 commits into
flutter:mainfrom
tarrinneal:Kamyshin
Open

Add FFI and JNI support to Swift and Kotlin#11352
tarrinneal wants to merge 131 commits into
flutter:mainfrom
tarrinneal:Kamyshin

Conversation

@tarrinneal

@tarrinneal tarrinneal commented Mar 25, 2026

Copy link
Copy Markdown
Contributor

This PR introduces optional Native Interop to Pigeon, enabling direct communication between Dart and native code without the overhead of traditional MethodChannel serialization. It leverages FFI (Foreign Function Interface) for Swift (iOS/macOS) and JNI (Java Native Interface) for Kotlin (Android).

This represents a significant architectural shift, moving from message-based passing to direct memory sharing and function calls. It also updates the concurrency model for asynchronous methods, moving from completion handlers/callbacks to modern language features: async/await in Swift and Coroutines in Kotlin.

Generators Covered

Swift Generator: Updated to support FFI bindings and async/await for asynchronous methods.
Kotlin Generator: Updated to support JNI bindings and Kotlin Coroutines for asynchronous methods.
Dart Generator: Updated to handle the generated interop bindings on the Dart side.

What's In Scope

  1. Infrastructure: Added core support for useFfi and useJni options.
  2. Automation: Implemented multi-step generation flows that automatically invoke jnigen and ffigen to produce final bindings.
  3. Config Generation: Added jnigen_config_generator.dart and ffigen_config_generator.dart to generate the necessary configuration files for the external tools.
  4. Documentation: Added a detailed native_interop_guide.md explaining prerequisites, setup, and usage.
    Tests: Added ni_tests.dart and associated generated files and integration tests to verify the feature.

What's Out of Scope

  1. Other Languages: This PR specifically targets Swift and Kotlin for the Native Interop feature. Support for Objective-C, C++, and GObject is not included in this interop implementation, and may not be in the future.
  2. Performance Optimization for Complex Classes: As noted in the guide, there is a known performance regression when transferring complex classes with many fields compared to MethodChannel Pigeon. This PR delivers the functional infrastructure, but optimizing this specific case is left for follow-up work.
  3. Non-instant released data. Currently all data that is sent over host or flutter api surfaces is converted to the correct shape and type for the language it is moving toward and the data created in the other language is then discarded. This presents some inefficiencies and potential workflows that are not yet available.

work toward flutter/flutter#182230
design doc flutter/flutter#181430

@stuartmorgan-g stuartmorgan-g left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More WIP review comments; still haven't finished the full pass.

Comment thread packages/pigeon/example/native_interop_app/pigeons/messages.dart Outdated
Comment thread packages/pigeon/README.md
Comment thread packages/pigeon/README.md Outdated
Comment thread packages/pigeon/README.md Outdated
Comment thread packages/pigeon/README.md Outdated
Comment thread packages/pigeon/lib/src/dart/dart_generator.dart Outdated
Comment thread packages/pigeon/lib/src/dart/dart_generator.dart Outdated
Comment thread packages/pigeon/lib/src/dart/dart_generator.dart Outdated
Comment thread packages/pigeon/lib/src/dart/dart_generator.dart Outdated
Comment thread packages/pigeon/lib/src/dart/dart_generator.dart Outdated

@stuartmorgan-g stuartmorgan-g left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another review drop!

I believe I've made it through all the non-test code now, so the end is in sight. (After all, how much test code can there be?)

}
}

ffi_bridge.MessagesPigeonTypedData toPigeonTypedData(TypedData value) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of these helpers aren't file-private; is that intentional? Do we need to make these visible to Pigeon clients?

}
}

String getToDartCall(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of these non-trivial helpers could use a Dart doc comment explaining very briefly what they do/are for, and what any non-obvious parameter means.

return '$name${_getNullableSymbol(type.isNullable)}.toFfi()';
}
if (type.isEnum && !type.isNullable) {
return 'ffi_bridge.${type.baseName}.values[$name]';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The magic string ffi_bridge appears a lot, and should be a file-level constant instead.

return 'NSObject${_getNullableSymbol((forceNullable || type.isNullable) && !forceNonNullable)}';
}

return '${ffiName.replaceAll('ffi_bridge.', prefix)}${_getNullableSymbol((forceNullable || type.isNullable) && !forceNonNullable)}';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If sometimes we want the prefix and sometimes we don't, why not have withPrefix as a parameter to ffiName, instead of unconditionally adding it there and then conditionally removing it?

if (type.baseName == 'List') {
return '<$ffiCollectionTypes>';
} else if (type.baseName == 'Map') {
return '<$ffiCollectionTypes>';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above, List and Map are the same and can be collapsed.

environment: env,
);
if (jnigenResult.exitCode != 0) {
print('''

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I highly recommend printing this second, not first, so it's the most obvious thing someone sees when the command fails.


String? pubspecPath;
if (appDirectory != null && appDirectory.isNotEmpty) {
pubspecPath = findPubspecPath(path.join(appDirectory, 'placeholder.dart'));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's just adjust findPubspecPath to take a Directory instead of appending placeholders in some cases, and then the places where we start with a Dart path we can call it with File(dartPath).parent

pubspecPath ??= findPubspecPath(path.join(Directory.current.path, 'placeholder.dart'));

if (pubspecPath == null) {
return errors;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want to return no errors if we couldn't find a pubspec? If so, please add a comment here saying why.

}
}
}
} catch (_) {}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we silently dropping all exceptions without returning any errors?

Error(
message:
'Missing required dependency "$dep" in pubspec.yaml for $apiName native interop support.\n'
'Please add "$dep" to your dependencies or dev_dependencies in your pubspec.yaml file.',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be distinguishing between dev and non-dev dependencies in both the validation and the error message. E.g., if someone adds objective_c as a dev dependency, it shouldn't pass validation. Similarly, if they are completely missing objective_c, as a dependency, they shouldn't have to guess where to add it.

suppressVersion: true,
dartOut: 'lib/src/native_interop_example.g.dart',
kotlinOut:
'android/app/src/main/kotlin/dev/flutter/pigeon_example_app/NativeInteropExample.g.kt',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Shouldn't the namespace not have _ (per the recent issue)?

@@ -84,6 +103,7 @@ Future<int> generateTestPigeons({required String baseDir, bool includeOverflow =
'nullable_returns',
'primitive',
'proxy_api_tests',
'ni_tests',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: keep the alphabetical order

@@ -41,3 +41,89 @@ kotlin {
flutter {
source = "../.."
}
// Gradle stub for listing dependencies in JNIgen. If found in

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this something that jnigen auto-adds to the file, or something we need to document somewhere?

Comment thread packages/pigeon/native_interop_guide.md Outdated
Comment thread packages/pigeon/example/native_interop_app/ffigen_config.dart

@stuartmorgan-g stuartmorgan-g left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I've finished the first pass over everything now (although it's hard to be 100% sure since GitHub really struggles with scrolling in the review view for this PR). Next I'll try it out on a plugin again, and take a closer look at the generated code in a more digestible size.

(The comprehensive test generated files have reached the point where they are nearly unreviewable, and the examples are intentionally quite minimal, so I want to drill down with a real-world-sized example.)

dartOptions: DartOptions(),
kotlinOptions: KotlinOptions(
useJni: true,
// Optional: Paths to search for compiled local classes (primarily needed for standalone Apps)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: apps should not be capitalized

guard let flutterApi = NIFlutterIntegrationCoreApi.getInstance() else {
throw NiTestsError(
code: "not_registered", message: "NIFlutterIntegrationCoreApi not registered", details: nil)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to make this file less verbose by making a file-level helper:

func getNativeInteropFlutterApi -> NIFlutterIntegrationCoreApi {
  guard let flutterApi = NIFlutterIntegrationCoreApi.getInstance() else {
    throw NiTestsError(
      code: "not_registered", message: "NIFlutterIntegrationCoreApi not registered", details: nil)
  }
  return flutterApi
}

The all the handlers can just do let flutterApi = getNativeInteropFlutterApi()


/** This plugin handles the native side of the integration tests in example/integration_test/. */
class TestPlugin : FlutterPlugin, HostIntegrationCoreApi {
private var flutterApi: FlutterIntegrationCoreApi? = null
private var flutterSmallApiOne: FlutterSmallApi? = null
private var flutterSmallApiTwo: FlutterSmallApi? = null
private var proxyApiRegistrar: ProxyApiRegistrar? = null
private var niMessageApi: NIHostIntegrationCoreApiRegistrar? = null
// private var niSmallApiOne: NIHostSmallApiRegistrar? = null
// private var niSmallApiTwo: NIHostSmallApiRegistrar? = null

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

// override suspend fun voidVoid() {
// return
// }
// }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

wrappedError.code = "\(error)"
wrappedError.message = "\(type(of: error))"
wrappedError.details = "Stacktrace: \(Thread.callStackSymbols)"
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should definitely do a short-ish term follow-up PR to improve the code gen for these; it looks like the do/catch/catch could be extracted to a helper to significantly reduce the amount of code generated.


private class PigeonApiImplementation : NativeInteropExampleApi() {
override fun doSomething() {
// Do nothing or print

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is example code, I would expect this to have some explanation of what would go here in a real app. I'm not sure what the current comment is meant to convey.

final NativeInteropExampleApiForNativeInterop? _nativeInteropApi;

Future<void> doSomething() async {
if ((Platform.isAndroid || Platform.isIOS || Platform.isMacOS) && _nativeInteropApi != null) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need the platform checks at the call point? Can't we just expect that _nativeInteropApi won't be set if it's not a platform that supports native interop?

});
});

// group('Host API with suffix', () {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please audit the full PR diff for commented-out code, and ensure we aren't landing any.

Comment thread packages/pigeon/native_interop_guide.md Outdated
## 1. Overview

Pigeon Native Interop allows Dart code to make direct function calls into native platform code, and vice versa, without the overhead of MethodChannel-based message passing. Instead of serializing data into binary buffers, Native Interop establishes direct memory-bound bridges using native pointers and JVM references.
For a detailed comparison between MethodChannel-based communication and Native Interop—including advantages, limitations, and recommended use cases—see the [Pigeon README](file:///Users/tarrinneal/work/packages/packages/pigeon/README.md#communication-options-methodchannels-vs-native-interop).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just hit this locally: this and the migration guide have some absolute file path links that aren't going to be very useful to 99.9999% of clients 😉


### Android (JNI / JNIgen)
- **Java 17**: Required by the Android build tools and JNIgen.
- **Maven (`mvn`)**: Required to resolve dependencies during generation.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is no longer needed I believe.

### iOS/macOS (FFI / FFIgen)
- **LLVM (version 9+)**: Required to parse header files.
- On macOS, this is included with Xcode Command Line Tools.
- **Dart Dependencies**: The `package:ffi` and `package:ffigen` packages must be added to your pubspec.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a step in the section below, rather than a prereq.


---

## 2. Migrating Native Code signatures

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first step in the migration guide should be summarizing the changes people need to make to start (adding the Dart deps, what options to add to their pigeon config to enable it, re-running generation), rather than making people jump back to the other doc at this point.

)
```

#### Kotlin Options for JNI

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's confusing to not have a similar section for Swift.


Pigeon automatically orchestrates running `jnigen` and `ffigen` as part of the generation process.

- **Android (JNI)**: When `kotlinOptions.useJni` is enabled and `kotlinOut` is specified:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These lines and/or the lists below them need to be reworded; currently they sound like instructions to the reader. The intro sentence above correctly says that they aren't, but it doesn't read that way here so someone could either be confused by the apparent contraction, or just not read the first sentence carefully enough and try to do all of these steps.

Indent indent, {
required String dartPackageName,
}) {
_classNamePrefix = generatorOptions.fileSpecificClassNameComponent ?? '';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure where in the overall plumbing the problem is, but when I took one of our simple plugins and added the FFI flag to its config, I ended up with things like messagesNumberWrapper as class names that failed Swift format; something should be fixing the casing before it's used here. Obj-C and Kotlin may well have the same issue.

Do we not have any tests that don't explicitly set the file class name component?

1. Generate the JNI-compatible Kotlin bridge and the `jnigen_config.dart` configuration.
2. Run `jnigen` (via the config script) to parse the generated Kotlin bridge and produce Dart JNI bindings.
3. Generate the final pigeon Dart output that wraps and imports those JNI bindings.
- **iOS/macOS (FFI)**: When `swiftOptions.useFfi` is enabled and `swiftOptions.appDirectory` is specified:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to figure out some guess fallback or some warning output for when appDirectory isn't set. Right now it silently doesn't actually do what the user would want.

This is especially confusing because Step 1 above does not set it.

- *Note*: If you are building a Flutter Plugin, this option is generally not needed because JNIgen automatically resolves classes defined inside plugin packages via standard Gradle dependency classpaths.
- *CLI Equivalent*: `--kotlin_jni_classpaths <path>` (can be specified multiple times).

### Step 2: Automated Interop Generation

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For iOS, Step 2 fails with:

Error: pigeons/messages.dart: Missing required dependency "ffi" in pubspec.yaml for Swift FFI native interop support.
Please add "ffi" to your dependencies or dev_dependencies in your pubspec.yaml file.

because Step 1 didn't say to do that. It's good that we have an error message for it, but the guided steps shouldn't trigger that error case.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was also not at all clear from the error message when I ran this in a plugin that what it was telling me to do was add it to example/'s pubspec, so I added it to the plugin pubspec and then confusingly got the same error message.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And it appears that clients need to add ffigen, swiftgen, and swift2objc to example/pubspec.yaml as well, which isn't in the instructions, nor is there a check with error (just a bunch of "Couldn't resolve the package" errors).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And then when I fixed that I got an error because swiftc seems to be fed a path that assumes that the swiftOut relative path is relative to appDirectory, which is never going to be true for a plugin. So I'm not sure how configuring this is supposed to work exactly.

I think I'll hold off on continuing the "attempt to try it on a plugin" tests until you've had a chance to do a simple run-through yourself on a plugin (I was trying quick_actions) to iron out the major bumps locally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants