Skip to content

Add FFI and JNI support to Swift and Kotlin#11352

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

Add FFI and JNI support to Swift and Kotlin#11352
tarrinneal wants to merge 124 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.

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 have two Pigeon files in this example? I would expect just one, using FFI/JNI (and not including Windows or Linux builds).

Comment thread packages/pigeon/README.md

| Feature | MethodChannels | Native Interop |
| :--- | :--- | :--- |
| **Communication Mechanism** | Asynchronous message passing over platform channels | Direct memory-bound function calls (Dart FFI / 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.

I don't this row is adding any information that's not in the summary introducing this section, so could be removed.

Comment thread packages/pigeon/README.md
| Feature | MethodChannels | Native Interop |
| :--- | :--- | :--- |
| **Communication Mechanism** | Asynchronous message passing over platform channels | Direct memory-bound function calls (Dart FFI / JNI) |
| **Platform Support** | All supported platforms (Android, iOS, macOS, Windows, Linux) | Android, iOS, and macOS only (Windows and Linux not supported) |

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.

No need for the parenthetical in the last cell. Anyone interested in Windows or Linux can see it's not in the list.

Comment thread packages/pigeon/README.md
| :--- | :--- | :--- |
| **Communication Mechanism** | Asynchronous message passing over platform channels | Direct memory-bound function calls (Dart FFI / JNI) |
| **Platform Support** | All supported platforms (Android, iOS, macOS, Windows, Linux) | Android, iOS, and macOS only (Windows and Linux not supported) |
| **Serialization Overhead** | High (data is serialized/deserialized to/from binary) | Low/Zero (uses direct memory sharing or native references) |

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 seems misleading; what Pigeon returns isn't shared memory or native references, it's a copy. I think this row should be something like "Overhead | High (serialization and multiple copies) | Low (minimal copying)"

Comment thread packages/pigeon/README.md
| **Platform Support** | All supported platforms (Android, iOS, macOS, Windows, Linux) | Android, iOS, and macOS only (Windows and Linux not supported) |
| **Serialization Overhead** | High (data is serialized/deserialized to/from binary) | Low/Zero (uses direct memory sharing or native references) |
| **Latency** | Higher (requires message loop scheduling) | Extremely low (direct execution) |
| **Synchronous Host Calls** | Not supported (always asynchronous) | Fully supported (true synchronous execution) |

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.

Neither of the parentheticals is needed here.


String get fullFfiName {
if (type.baseName == 'List' || type.baseName == 'Map') {
return ffiName;

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 supposed to be the same? If so, it seems like some refactoring along the way make this method useless.

}

String get primitiveToDartMethodName {
switch (type.baseName) {

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, consider a switch expression.

if (type.isClass || type.isEnum) {
return '$name${_getNullableSymbol(type.isNullable)}.toJni()';
} else if (!type.isNullable &&
(type.baseName == 'int' || type.baseName == 'double' || type.baseName == 'bool')) {

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 int/double/bool baseName triple occurs a number of times in this file; is there a concept you can abstract to a helper method here?

String getJniCollectionTypeAnnotations({bool isParameter = false, bool isAsynchronous = false}) {
if (type.baseName == 'List') {
return '<${getJniCollectionTypes(isParameter: isParameter, isAsynchronous: isAsynchronous)}>';
} else if (type.baseName == 'Map') {

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 if and else if have the same outcome here, so could be consolidated.

String get jniCollectionTypes => getJniCollectionTypes();
}

String _getNullableSymbol(bool nullable) => nullable ? '?' : '';

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.

_getNullabilitySymbol so it's clearer that it can be non-nullable?

@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.

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