Repository navigation
[pigeon] Pass data classes and collections in a single buffer per native interop call - #13265
Conversation
…eon platform tests and update JNIgen config generator.
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a new 'hybrid' transport mode for Pigeon's native interop, designed to improve performance by passing scalars, strings, enums, and typed data directly while batching other types into a single buffer per call. The changes include updates to the Dart, Kotlin, and Swift generators to support this hybrid transport, the addition of buffer helper classes, and updates to the Pigeon configuration options. A critical issue was identified in the Swift generator regarding manual memory management of NSData objects, which could lead to double-free errors; the review suggests removing the manual release calls as the objects are already managed by Dart's finalizer.
| final int length = data.length; | ||
| if (length == 0) { | ||
| return Uint8List(0); | ||
| } | ||
| return Uint8List.fromList(data.bytes.cast<Uint8>().asTypedList(length)); | ||
| } finally { | ||
| dataRef.release(); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Manually calling release() on the underlying ObjCObjectRef of an NSData object that is still managed by Dart's NativeFinalizer will cause a double-free when the Dart object is garbage collected, or a premature free if the caller still holds a reference to data.
Since NSData (via ObjCObjectBase) implements Finalizable, the Dart VM guarantees that data is kept alive until the end of the enclosing scope (the toUint8List function). Therefore, we can safely remove the try-finally block and the manual release() call entirely, which simplifies the code and prevents the double-free bug.
| final int length = data.length; | |
| if (length == 0) { | |
| return Uint8List(0); | |
| } | |
| return Uint8List.fromList(data.bytes.cast<Uint8>().asTypedList(length)); | |
| } finally { | |
| dataRef.release(); | |
| } | |
| } | |
| final int length = data.length; | |
| if (length == 0) { | |
| return Uint8List(0); | |
| } | |
| return Uint8List.fromList(data.bytes.cast<Uint8>().asTypedList(length)); |
There was a problem hiding this comment.
Both premises here are mistaken:
- In
package:objective_c,ObjCObjectRef.release()deletes theDart_FinalizableHandle(deleteFinalizableHandle) before callingobjectRelease, so it does not double-free when the Dart wrapper is later GC'd—and releasing it here deterministically frees the temporaryNSDatatransport buffer instead of waiting for GC. NSData(an extension type onObjCObject extends _ObjCRefHolder) does not implementFinalizable; onlyObjCObjectRef(data.object$.ref) implementsFinalizable. HoldingdataRefand releasing it infinallyis what keepsdata.bytesalive untilUint8List.fromListfinishes copying.
| final String transport; | ||
| final double median; | ||
| final double p90; | ||
| final int samples; |
There was a problem hiding this comment.
Can we get docs explaining what these values mean?
| final done = Completer<void>(); | ||
| void handleLine(String line) { | ||
| final int start = line.indexOf(_resultPrefix); | ||
| if (start >= 0) { |
There was a problem hiding this comment.
small nit for readability:
| if (start >= 0) { | |
| if (line.contains(_resultPrefix)) { |
And then move start variable into if statement.
| ? '${(microseconds / 1000).toStringAsFixed(2)} ms' | ||
| : '${microseconds.toStringAsFixed(2)} µs'; | ||
|
|
||
| final names = <String>[]; |
There was a problem hiding this comment.
Is this list necessary? It looks like it is just the keys in byName.
| : '${microseconds.toStringAsFixed(2)} µs'; | ||
|
|
||
| final names = <String>[]; | ||
| final byName = <String, Map<String, _Result>>{}; |
There was a problem hiding this comment.
nit: I think this could use a more descriptive name.
|
|
||
| const String _platformOption = 'platform'; | ||
| const String _deviceOption = 'device'; | ||
| const String _resultPrefix = 'PIGEON_BENCHMARK|'; |
There was a problem hiding this comment.
I think this needs a comment that it should match the value in platform_tests/shared_test_plugin_code/lib/native_interop_benchmarks.dart.
There was a problem hiding this comment.
I don't know if I like that the test file prints the benchmarks and this then parses them and then this prints a table of those benchmarks. This scatters the code a little too much. I think the test should handle printing the tables or maybe output json that can be easily parsed in this file.
| entryReturnType = returnType.isVoid | ||
| ? 'Future<JObject>' | ||
| : nativeInteropReturnsBuffer(returnType) | ||
| ? 'Future<JByteArray?>' | ||
| : returnType.isEnum | ||
| ? 'Future<JLong>' | ||
| : 'Future<${_JniType.fromTypeDeclaration(returnType).getJniCallReturnType(true)}>'; |
There was a problem hiding this comment.
nit: I prefer this be an if statement or switch. It's just a bit easier to read with this formatting. Same for below.
There was a problem hiding this comment.
Should hybrid helper methods go in there own file like proxy_api_generator_helper.dart? Same for other languages.
Piinks
left a comment
There was a problem hiding this comment.
I am still learning a lot here, so if I am off the mark on some of these let me know (and thanks for helping me learn!)
| static String _hybridJvmTypeKey(TypeDeclaration type) { | ||
| if (nativeInteropReturnsBuffer(type)) { | ||
| return 'Uint8List'; | ||
| } | ||
| if (type.isEnum) { | ||
| return type.isNullable ? 'int?' : 'int'; | ||
| } | ||
| return _jvmTypeKey(type); | ||
| } |
There was a problem hiding this comment.
It looks like _hybridJvmTypeKey returns 'int?' for nullable enums, but on the Kotlin side both nullable and non-nullable enums compile to unboxed Long (primitive long, using -1L for null).
Since jnigen merges JavaBean getter/setter pairs into a single Dart property whenever their JVM types match, nullable enums get out of sync with the generated .jni.dart bindings. For example:
enum Mode { a, b }
@HostApi()
abstract class SettingsApi {
Mode getMode();
void setMode(Mode? mode);
int? getCount();
void setCount(Mode? count);
}- getMode() and setMode(Mode?) both compile to primitive long and merge into a single mode property in .jni.dart, but _JniBindingNamer treats them as different types and emits
_jniApi.mode$1 = .... - getCount() (boxed Long) and setCount(Mode?) (primitive long) don't merge in .jni.dart (count vs count$1), but _JniBindingNamer treats both as 'int?' and tries to use _jniApi.count for both the JLong? getter and the int setter.
|
|
||
| /// Writes the body of the JNI branch of a host API [method] for the hybrid | ||
| /// transport. | ||
| void _writeJniHybridHostCall(Indent indent, Method method, _JniMember jniMember) { |
There was a problem hiding this comment.
I this method, I think there are a couple of edge cases with the JNI temporaries here:
- If a HostApi method has a direct parameter named args alongside an encoded parameter, both the direct temporary and the encoded buffer get named pigeonVar_args in the same scope:
That emits both
@HostApi() abstract class CommandApi { void execute(String args, List<String> extra); }
final JString pigeonVar_argsandfinal JByteArray pigeonVar_argsinside execute. - All the direct JNI temporaries (toJString, JLong, _uint8ListToJni, etc.) are allocated before _PigeonJniBuffers.encode and outside the try/finally block. For something like void send(String header, Object? payload), if encode throws (e.g. StandardMessageCodec throwing ArgumentError on an unsupported value in payload), pigeonVar_header is never released.
| .join(', '); | ||
| arguments.add('$codec.encode(listOf<Any?>($encodedNames))'); | ||
| } | ||
| final call = 'bridge.${method.name}(${arguments.join(', ')})'; |
There was a problem hiding this comment.
If a FlutterApi method has a parameter named bridge (e.g. void notify(String bridge)), won't it shadows the adapter's bridge property?
private class ShadowFlutterApiBridgeAdapter(private val bridge: ShadowFlutterApiBridge) : ShadowFlutterApi {
override fun notify(bridge: String) {
bridge.notify(bridge) // resolves bridge to the String parameter instead of this.bridge
}
}Same thing happens in _writeJniHybridHostEntryPoint below (carried over from objects mode) when a HostApi has a direct parameter named api, where val pigeonVar_api = api ?: error(...) resolves api to the parameter instead of this.api.
| } | ||
| } | ||
|
|
||
| mutating func writeTypedData<T>(_ type: UInt8, _ array: [T]?, alignment: Int) { |
There was a problem hiding this comment.
writeTypedData takes an optional [T]?, while writeValue has already unwrapped value to non-optional Any and passes value as! [UInt8] (and the other typed array casts below).
Running swiftc -typecheck on NativeInteropExample.g.swift and NativeInteropTests.gen.swift produces 5 warnings per file:
warning: treating a forced downcast to '[UInt8]' as optional will never produce 'nil'
writeTypedData(8, value as! [UInt8], alignment: 1)
That will break consumers building with warnings-as-errors enabled (and the checked-in .g.swift / .gen.swift files will need regenerating once updated).
| 'final NSData $argsVar = _PigeonFfiBuffers.encode(<Object?>[${encodedParameters.map((Parameter p) => p.name).join(', ')}]);', | ||
| ); | ||
| } | ||
| final arguments = <(String, String)>[ |
There was a problem hiding this comment.
Nit (looks carried over from objects mode): _errorOnSwiftFfiUnsupportedFeatures rejects wrappedError as a HostApi parameter name, but doesn't check completionHandler on async methods. For something like @async void fetch(int id, String completionHandler), callWith ends up emitting completionHandler: twice in the same _ffiApi.fetchWithId call. Might be worth rejecting completionHandler alongside wrappedError (or matching ffigen's disambiguated name) while we're touching this.
| } | ||
|
|
||
| /// Writes values in the format of Flutter's standard message codec. | ||
| private struct NativeInteropExamplePigeonInteropWriter { |
There was a problem hiding this comment.
Why does this not extend FlutterStandardWriter like how Kotlin extends StandardMessageCodec?
There was a problem hiding this comment.
On iOS/macOS, FlutterStandardWriter / FlutterStandardReader / FlutterStandardMessageCodec are Objective-C classes from the Flutter / FlutterMacOS framework rather than pure Foundation types.
Implementing PigeonInteropWriter and PigeonInteropReader directly against Foundation (Data) allows swiftgen (in ffigen) to compile the generated Swift file standalone without needing Flutter.framework in the header/module search path, and avoids per-element Objective-C message-send and NSNumber boxing overhead during serialization.
|
I'm still in need of an approval on this pr if @bparrishMines @Piinks or @dkwingsmt have a moment |
Piinks
left a comment
There was a problem hiding this comment.
Thanks for addressing all the feedback and adding the regression tests! LGTM!
# Conflicts: # packages/pigeon/CHANGELOG.md # packages/pigeon/lib/src/dart/dart_generator.dart # packages/pigeon/lib/src/generator_tools.dart # packages/pigeon/platform_tests/shared_test_plugin_code/lib/src/generated/native_interop_tests.native_interop.gen.dart # packages/pigeon/pubspec.yaml
…r#196296) flutter/packages@565c657...08faf64 2026-11-10 [email protected] [pigeon] Pass data classes and collections in a single buffer per native interop call (flutter/packages#13265) 2026-11-10 [email protected] [ci] Fix release workflows (flutter/packages#13611) 2026-11-09 [email protected] [pigeon] Fix standalone Native Interop @FlutterApi issues (flutter/packages#13609) 2026-11-09 [email protected] [cross_file_web] Adds support to instantiate the web PlatformXFile without needing to import web apis (flutter/packages#13395) 2026-11-09 [email protected] Sync release-go_router-18.1.4 to main (flutter/packages#13606) 2026-11-09 [email protected] Sync release-material_ui-1.11.0 to main (flutter/packages#13605) 2026-11-09 [email protected] [vector_graphics_compiler] Resolve Flutter artifact cache via package:cli_util (flutter/packages#13252) 2026-11-09 [email protected] [ci] Remove `pull_request_target` workflows (flutter/packages#13581) 2026-11-09 [email protected] Roll Flutter from acade0f to d463821 (1 revision) (flutter/packages#13601) If this roll has caused a breakage, revert this CL and set the roller to dry run mode using the controls here: https://autoroll.skia.org/r/flutter-packages-flutter-autoroll Please CC [email protected] on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md
Updates Kotlin JNI and Swift FFI native interop so that data classes,
List,Map, andObjectvalues are passed in a single buffer per call while keeping scalars, strings, enums, and typed data direct.Background & Motivation
Previously, native interop converted every data class,
List,Map, andObjectinto a graph of JVM or Objective-C objects, making a separate call across the language boundary for every field and collection element. For calls passing large or deeply nested data classes and collections, this per-element boundary overhead made native interop significantly slower than platform channels.Summary of Changes
bool,int,double),String, enums, and typed data (Uint8List,Int32List,Int64List,Float64List)—both non-nullable and nullable—continue to cross the JNI / FFI boundary directly as their own parameters and return values.sealedclass hierarchies),List,Map, andObject/Anyarguments in a method call are encoded together into a single byte buffer using the generatedPigeonInteropCodec(StandardMessageCodecformat) and decoded in one pass on the receiving side. Composite return values and KotlinFlutterError.detailsare similarly passed as a single encoded buffer.HostApiRegistrar/HostApiSetup,FlutterApiRegistrar/FlutterApiProtocol, data classes, and method signatures) are unchanged.ByteArray/NSDatabuffers,jnigenandffigenonly need to generate bindings for the*Registrar,*Setup, and*Bridgeclasses—not for every data class, sealed subclass, or enum.entries.firstOrNull { it.raw == raw }with constant-timeentries.getOrNull(raw)in generated enumofRawlookups.JClasslookups per class in lazy top-level variables (_jniClass$<Name>), converts JNI typed-data arrays directly via typed helpers, and copies FFINSDatatoUint8Listonce when deriving typed list views.pigeons/native_interop_tests.dart) and unit tests across the Dart, Kotlin, Swift,jnigen, andffigengenerators.README.mdandnative_interop_guide.md.Pre-launch Checklist
dart format.)[shared_preferences]pubspec.yamlwith an appropriate new version according to the [pub versioning philosophy], or this PR is [exempt from version changes].CHANGELOG.mdto add a description of the change, [following repository CHANGELOG style], or this PR is [exempt from CHANGELOG changes].///).Pigeon Benchmark Comparison: Platform Channels vs. Native Interop (Objects) vs. Native Interop (Hybrid)
This document compares the evolution of Pigeon's cross-language transport across three versions using measured release-mode benchmarks:
BasicMessageChannel+StandardMessageCodec)NativeInteropTransport.objects— JNI/FFI object-graph transport)NativeInteropTransport.hybrid— direct interop for scalars/strings/enums/typed-data + single-bufferStandardMessageCodecfor data classes and collections)1. Architectural Evolution Across the Three Versions
objects)hybrid)BinaryMessengerhop across platform thread)package:jni) / FFI (dart:ffi)package:jni) / FFI (dart:ffi)ByteData+ async channel hopUint8List, etc.)ByteDatagetRange/memcpy)getRange/memcpy)StandardMessageCodec)StandardMessageCodecUint8ListbufferList<AllNullableTypes>(2. Release-Mode Per-Call Latency (
tool/benchmark.dart)Measured via
tool/benchmark.dartrunningnative_interop_benchmarks.dartin--releasemode (up to 500 ms warmup and 201 timed samples per workload;objectsmode includes the Phase 1 object-graph fixes such as cachedJClass, bulkJByteBuffer, and pre-sizedJArrayList).macOS Release (Swift FFI, Apple Silicon arm64)
objects(p50 / p90)hybrid(p50 / p90)objectsvs. Channelhybridvs.objectshybridvs. ChannelnoopechoIntechoStringechoUint8List 1MBechoAllTypes(1 object)echoClassList(AllNullableTypes×1)echoClassList(AllNullableTypes×10)echoClassList(AllNullableTypes×100)echoClassList(AllNullableTypes×1000)echoIntList(×10)echoIntList(×100)echoIntList(×1000)echoIntList(×10000)echoIntMap(×1000)callFlutterEchoClassList(×100)Android Release (Kotlin JNI,
emulator-5554arm64)objects(p50 / p90)hybrid(p50 / p90)objectsvs. Channelhybridvs.objectshybridvs. ChannelnoopechoIntechoStringechoUint8List 1MBechoAllTypes(1 object)echoClassList(AllNullableTypes×1)echoClassList(AllNullableTypes×10)echoClassList(AllNullableTypes×100)echoClassList(AllNullableTypes×1000)echoIntList(×10)echoIntList(×100)echoIntList(×1000)echoIntList(×10000)echoIntMap(×1000)callFlutterEchoClassList(×100)3. Pre-Optimization
objectsBaseline (comparison_benchmarks.dart)Before the Phase 1 object-graph fixes (when
objectsmode also performed uncachedJClass.forNamelookups on every call and element-by-element byte buffer copies), the originalcomparison_benchmarks.dartworkloads measured in baseline_android_release.log and baseline_macos.log showed even larger gaps:AllNullableTypes)objects(Pre-Phase 1)objectshybridhybridvs. Originalobjects)echoClassList(×15)callFlutterEchoClassList(×15)echoClassList(×15)callFlutterEchoClassList(×15)4. Summary of Key Takeaways
objects):noopand scalar calls by replacing asynchronous platform channel messages with synchronous JNI/FFI calls.JClass, bulkJByteBuffer, pre-sizedJArrayList),objectsmode remained 5×–33× slower than Platform Channels on complex data classes and collections (1,482.94 mson macOS and8,006.16 mson Android for 1,000AllNullableTypesobjects) because every field and collection element still crossed the JNI/FFI boundary individually.objects) → Native Interop (hybrid):objectsmode (0.20 µsnoopon both macOS and Android).objectsmode (and 60×–143× over the original unoptimizedobjectsmode) by serializing complex payloads into a singleStandardMessageCodecbyte buffer that crosses JNI/FFI in 1 call.noopList<int>andMap<int, int>collectionsAllNullableTypesdata classes