Add FFI and JNI support to Swift and Kotlin#11352
Conversation
stuartmorgan-g
left a comment
There was a problem hiding this comment.
More WIP review comments; still haven't finished the full pass.
stuartmorgan-g
left a comment
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
A lot of these helpers aren't file-private; is that intentional? Do we need to make these visible to Pigeon clients?
| } | ||
| } | ||
|
|
||
| String getToDartCall( |
There was a problem hiding this comment.
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]'; |
There was a problem hiding this comment.
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)}'; |
There was a problem hiding this comment.
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>'; |
There was a problem hiding this comment.
As above, List and Map are the same and can be collapsed.
| environment: env, | ||
| ); | ||
| if (jnigenResult.exitCode != 0) { | ||
| print(''' |
There was a problem hiding this comment.
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')); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
We want to return no errors if we couldn't find a pubspec? If so, please add a comment here saying why.
| } | ||
| } | ||
| } | ||
| } catch (_) {} |
There was a problem hiding this comment.
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.', |
There was a problem hiding this comment.
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', |
There was a problem hiding this comment.
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', | |||
There was a problem hiding this comment.
Nit: keep the alphabetical order
| @@ -41,3 +41,89 @@ kotlin { | |||
| flutter { | |||
| source = "../.." | |||
| } | |||
| // Gradle stub for listing dependencies in JNIgen. If found in | |||
There was a problem hiding this comment.
Is this something that jnigen auto-adds to the file, or something we need to document somewhere?
stuartmorgan-g
left a comment
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
Nit: apps should not be capitalized
| guard let flutterApi = NIFlutterIntegrationCoreApi.getInstance() else { | ||
| throw NiTestsError( | ||
| code: "not_registered", message: "NIFlutterIntegrationCoreApi not registered", details: nil) | ||
| } |
There was a problem hiding this comment.
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 |
| // override suspend fun voidVoid() { | ||
| // return | ||
| // } | ||
| // } |
| wrappedError.code = "\(error)" | ||
| wrappedError.message = "\(type(of: error))" | ||
| wrappedError.details = "Stacktrace: \(Thread.callStackSymbols)" | ||
| } |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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', () { |
There was a problem hiding this comment.
Please audit the full PR diff for commented-out code, and ensure we aren't landing any.
| ## 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). |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
This should be a step in the section below, rather than a prereq.
|
|
||
| --- | ||
|
|
||
| ## 2. Migrating Native Code signatures |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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 ?? ''; |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
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
Tests: Added ni_tests.dart and associated generated files and integration tests to verify the feature.
What's Out of Scope
work toward flutter/flutter#182230
design doc flutter/flutter#181430