Skip to content

[pigeon] Pass data classes and collections in a single buffer per native interop call - #13265

Merged
auto-submit[bot] merged 28 commits into
flutter:mainfrom
tarrinneal:Large-classes
Nov 10, 2026
Merged

auto-submit[bot] merged 28 commits into
flutter:mainfrom
tarrinneal:Large-classes

Conversation

@tarrinneal

@tarrinneal tarrinneal commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Updates Kotlin JNI and Swift FFI native interop so that data classes, List, Map, and Object values 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, and Object into 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

  • Single-Buffer Composite Value Transport:
    • Direct Types: Scalars (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.
    • Buffered Composite Types: Data classes (including sealed class hierarchies), List, Map, and Object/Any arguments in a method call are encoded together into a single byte buffer using the generated PigeonInteropCodec (StandardMessageCodec format) and decoded in one pass on the receiving side. Composite return values and Kotlin FlutterError.details are similarly passed as a single encoded buffer.
    • Unchanged User-Facing APIs: The public Dart, Kotlin, and Swift APIs and registration methods (HostApiRegistrar / HostApiSetup, FlutterApiRegistrar / FlutterApiProtocol, data classes, and method signatures) are unchanged.
    • Reduced Binding Surface: Because composite types cross as ByteArray / NSData buffers, jnigen and ffigen only need to generate bindings for the *Registrar, *Setup, and *Bridge classes—not for every data class, sealed subclass, or enum.
  • Additional Native Interop Optimizations:
    • Kotlin: Replaces entries.firstOrNull { it.raw == raw } with constant-time entries.getOrNull(raw) in generated enum ofRaw lookups.
    • Dart (JNI / FFI): Caches JClass lookups per class in lazy top-level variables (_jniClass$<Name>), converts JNI typed-data arrays directly via typed helpers, and copies FFI NSData to Uint8List once when deriving typed list views.
  • Tests & Documentation:
    • Updates the native interop integration test suite (pigeons/native_interop_tests.dart) and unit tests across the Dart, Kotlin, Swift, jnigen, and ffigen generators.
    • Updates README.md and native_interop_guide.md.

Pre-launch Checklist

  • I read the [Contributor Guide] and followed the process outlined there for submitting PRs.
  • I read the [Tree Hygiene] page, which explains my responsibilities.
  • I read and followed the [relevant style guides] and ran the auto-formatter. (Unlike the flutter/flutter repo, the flutter/packages repo does use dart format.)
  • I signed the [CLA].
  • The title of the PR starts with the name of the package surrounded by square brackets, e.g. [shared_preferences]
  • I [linked to at least one issue that this PR fixes] in the description above.
  • I updated pubspec.yaml with an appropriate new version according to the [pub versioning philosophy], or this PR is [exempt from version changes].
  • I updated CHANGELOG.md to add a description of the change, [following repository CHANGELOG style], or this PR is [exempt from CHANGELOG changes].
  • I wrote/updated relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making, or this PR is [test-exempt].
  • All existing and new tests are passing.

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:

 

  1. Platform Channel Pigeon (BasicMessageChannel + StandardMessageCodec)
  2. Native Interop — Objects (NativeInteropTransport.objects — JNI/FFI object-graph transport)
  3. Native Interop — Hybrid (NativeInteropTransport.hybrid — direct interop for scalars/strings/enums/typed-data + single-buffer StandardMessageCodec for data classes and collections)

 


1. Architectural Evolution Across the Three Versions

Dimension 1. Platform Channel Pigeon 2. Native Interop (objects) 3. Native Interop (hybrid)
Call Dispatch Asynchronous (BinaryMessenger hop across platform thread) Direct synchronous JNI (package:jni) / FFI (dart:ffi) Direct synchronous JNI (package:jni) / FFI (dart:ffi)
Scalars, Strings, Enums Serialized into ByteData + async channel hop 1 JNI/FFI call per parameter 1 JNI/FFI call per parameter
Top-Level Typed Data (Uint8List, etc.) Copied through channel ByteData 1 bulk buffer copy (getRange / memcpy) 1 bulk buffer copy (getRange / memcpy)
Data Classes & Collections 1 buffer (StandardMessageCodec) $O(N \cdot F)$ JNI/FFI calls (1 call per field, collection element, and wrapper allocation) 1 JNI/FFI call carrying a single StandardMessageCodec Uint8List buffer
Boundary Crossings for List<AllNullableTypes> ($N=100$) 1 async channel message ~7,500+ JNI/FFI calls 1 synchronous JNI/FFI call

 

[!IMPORTANT] Why NativeInteropTransport.objects regressed on complex types: While objects mode eliminated the async platform-channel hop (making noop and scalar calls 35×–3,000× faster), it translated every class field and collection element into individual JNI/FFI calls (JArrayList.add, JHashMap.put, JLong.valueOf, and per-field getters/constructors). Under hybrid mode, Pigeon keeps the 1-call fast path for primitives, strings, enums, and Uint8List, while packing data classes and collections into a single contiguous Uint8List payload that crosses JNI/FFI in one bulk transfer.

 


2. Release-Mode Per-Call Latency (tool/benchmark.dart)

Measured via tool/benchmark.dart running native_interop_benchmarks.dart in --release mode (up to 500 ms warmup and 201 timed samples per workload; objects mode includes the Phase 1 object-graph fixes such as cached JClass, bulk JByteBuffer, and pre-sized JArrayList).

macOS Release (Swift FFI, Apple Silicon arm64)

Benchmark Workload 1. Platform Channel (p50) 2. Native Interop objects (p50 / p90) 3. Native Interop hybrid (p50 / p90) objects vs. Channel hybrid vs. objects hybrid vs. Channel
noop 11.63 µs 0.20 µs / 0.23 µs 0.20 µs / 0.22 µs 58.15× faster 1.00× 58.15× faster
echoInt 14.00 µs 0.39 µs / 0.44 µs 0.39 µs / 0.42 µs 35.90× faster 1.00× 35.90× faster
echoString 14.18 µs 1.23 µs / 3.73 µs 1.22 µs / 3.64 µs 11.53× faster 1.01× 11.62× faster
echoUint8List 1MB 289.00 µs 263.00 µs / 449.00 µs 228.00 µs / 442.00 µs 1.10× faster 1.15× faster 1.27× faster
echoAllTypes (1 object) 198.00 µs 1.03 ms / 1.60 ms 136.50 µs / 142.50 µs 🔴 5.20× slower 7.55× faster 1.45× faster
echoClassList (AllNullableTypes ×1) 254.00 µs 1.35 ms / 1.93 ms 162.00 µs / 171.00 µs 🔴 5.32× slower 8.34× faster 1.57× faster
echoClassList (AllNullableTypes ×10) 2.37 ms 14.52 ms / 15.70 ms 1.62 ms / 1.69 ms 🔴 6.13× slower 8.96× faster 1.46× faster
echoClassList (AllNullableTypes ×100) 23.94 ms 149.62 ms / 155.24 ms 16.36 ms / 17.03 ms 🔴 6.25× slower 9.15× faster 1.46× faster
echoClassList (AllNullableTypes ×1000) 246.17 ms 1,482.94 ms / 1,554.68 ms 170.37 ms / 174.54 ms 🔴 6.02× slower 8.70× faster 1.44× faster
echoIntList (×10) 21.13 µs 17.42 µs / 18.33 µs 2.10 µs / 2.69 µs 1.21× faster 8.30× faster 10.06× faster
echoIntList (×100) 62.00 µs 138.00 µs / 145.00 µs 9.26 µs / 9.96 µs 🔴 2.23× slower 14.90× faster 6.70× faster
echoIntList (×1000) 433.00 µs 1.31 ms / 1.38 ms 77.33 µs / 83.33 µs 🔴 3.02× slower 16.91× faster 5.60× faster
echoIntList (×10000) 4.10 ms 18.79 ms / 19.48 ms 756.00 µs / 890.00 µs 🔴 4.58× slower 24.85× faster 5.42× faster
echoIntMap (×1000) 891.00 µs 8.87 ms / 14.44 ms 178.00 µs / 189.00 µs 🔴 9.96× slower 49.83× faster 5.01× faster
callFlutterEchoClassList (×100) 49.50 ms 1,620.53 ms / 1,818.44 ms 33.00 ms / 34.24 ms 🔴 32.74× slower 49.11× faster 1.50× faster

Android Release (Kotlin JNI, emulator-5554 arm64)

Benchmark Workload 1. Platform Channel (p50) 2. Native Interop objects (p50 / p90) 3. Native Interop hybrid (p50 / p90) objects vs. Channel hybrid vs. objects hybrid vs. Channel
noop 671.00 µs 0.23 µs / 1.66 µs 0.20 µs / 0.67 µs 2,917× faster 1.15× faster 3,355× faster
echoInt 512.00 µs 0.53 µs / 2.83 µs 0.23 µs / 0.51 µs 966× faster 2.30× faster 2,226× faster
echoString 362.00 µs 6.33 µs / 33.95 µs 6.38 µs / 16.16 µs 57.19× faster 0.99× 56.74× faster
echoUint8List 1MB 8.22 ms 2.77 ms / 9.23 ms 3.48 ms / 10.60 ms 2.96× faster 0.80× 2.37× faster
echoAllTypes (1 object) 1.06 ms 2.61 ms / 4.75 ms 370.00 µs / 1.35 ms 🔴 2.47× slower 7.05× faster 2.86× faster
echoClassList (AllNullableTypes ×1) 1.01 ms 3.35 ms / 6.05 ms 414.00 µs / 3.15 ms 🔴 3.30× slower 8.09× faster 2.45× faster
echoClassList (AllNullableTypes ×10) 5.92 ms 42.78 ms / 68.42 ms 3.01 ms / 6.62 ms 🔴 7.23× slower 14.20× faster 1.96× faster
echoClassList (AllNullableTypes ×100) 24.98 ms 717.45 ms / 936.56 ms 35.67 ms / 58.50 ms 🔴 28.72× slower 20.11× faster 0.70× (parity)
echoClassList (AllNullableTypes ×1000) 431.27 ms 8,006.16 ms / 9,503.80 ms 338.73 ms / 421.96 ms 🔴 18.56× slower 23.64× faster 1.27× faster
echoIntList (×10) 103.50 µs 188.40 µs / 390.80 µs 14.50 µs / 17.00 µs 🔴 1.82× slower 12.99× faster 7.14× faster
echoIntList (×100) 311.00 µs 1.61 ms / 4.48 ms 72.33 µs / 129.33 µs 🔴 5.18× slower 22.26× faster 4.30× faster
echoIntList (×1000) 1.06 ms 24.75 ms / 41.37 ms 705.00 µs / 1.72 ms 🔴 23.33× slower 35.11× faster 1.50× faster
echoIntList (×10000) 9.77 ms 258.70 ms / 330.16 ms 7.37 ms / 14.15 ms 🔴 26.48× slower 35.12× faster 1.33× faster
echoIntMap (×1000) 2.53 ms 41.48 ms / 71.02 ms 1.56 ms / 2.88 ms 🔴 16.40× slower 26.63× faster 1.62× faster
callFlutterEchoClassList (×100) 92.82 ms 1,577.38 ms / 1,764.70 ms 69.23 ms / 106.86 ms 🔴 16.99× slower 22.78× faster 1.34× faster

 


3. Pre-Optimization objects Baseline (comparison_benchmarks.dart)

Before the Phase 1 object-graph fixes (when objects mode also performed uncached JClass.forName lookups on every call and element-by-element byte buffer copies), the original comparison_benchmarks.dart workloads measured in baseline_android_release.log and baseline_macos.log showed even larger gaps:

 

Legacy Workload (15-object list of AllNullableTypes) 1. Platform Channels 2. Original objects (Pre-Phase 1) 3. Phase 1 objects 4. hybrid Total Speedup (hybrid vs. Original objects)
Android Host echoClassList (×15) 11.0 ms 593.0 ms 64.2 ms 4.5 ms 131.8× faster
Android Flutter callFlutterEchoClassList (×15) 11.0 ms 1,485.0 ms 236.6 ms 10.4 ms 142.8× faster
macOS Host echoClassList (×15) 16.0 ms 176.0 ms 21.8 ms 2.4 ms 73.3× faster
macOS Flutter callFlutterEchoClassList (×15) 53.0 ms 294.0 ms 243.1 ms 4.9 ms 60.0× faster

 


4. Summary of Key Takeaways

  1. Platform Channels → Native Interop (objects):
    • Delivered 36×–3,355× lower latency on noop and scalar calls by replacing asynchronous platform channel messages with synchronous JNI/FFI calls.
    • However, even with Phase 1 optimizations (cached JClass, bulk JByteBuffer, pre-sized JArrayList), objects mode remained 5×–33× slower than Platform Channels on complex data classes and collections (1,482.94 ms on macOS and 8,006.16 ms on Android for 1,000 AllNullableTypes objects) because every field and collection element still crossed the JNI/FFI boundary individually.
  2. Native Interop (objects) → Native Interop (hybrid):
    • Preserves 100% of the scalar/primitive/string/typed-data speedups of objects mode (0.20 µs noop on both macOS and Android).
    • Speeds up data class and collection workloads by 7.5×–49.8× over Phase 1 objects mode (and 60×–143× over the original unoptimized objects mode) by serializing complex payloads into a single StandardMessageCodec byte buffer that crosses JNI/FFI in 1 call.
    • Outperforms Platform Channels across both simple and complex workloads:
      • 36×–3,355× faster on scalars and noop
      • 11.6×–56.7× faster on strings
      • 1.33×–10.06× faster on List<int> and Map<int, int> collections
      • 1.27×–2.86× faster on deeply nested AllNullableTypes data classes

 

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +3427 to +3436
final int length = data.length;
if (length == 0) {
return Uint8List(0);
}
return Uint8List.fromList(data.bytes.cast<Uint8>().asTypedList(length));
} finally {
dataRef.release();
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

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.

Suggested change
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));

@tarrinneal tarrinneal Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both premises here are mistaken:

  1. In package:objective_c, ObjCObjectRef.release() deletes the Dart_FinalizableHandle (deleteFinalizableHandle) before calling objectRelease, so it does not double-free when the Dart wrapper is later GC'd—and releasing it here deterministically frees the temporary NSData transport buffer instead of waiting for GC.
  2. NSData (an extension type on ObjCObject extends _ObjCRefHolder) does not implement Finalizable; only ObjCObjectRef (data.object$.ref) implements Finalizable. Holding dataRef and releasing it in finally is what keeps data.bytes alive until Uint8List.fromList finishes copying.

Comment on lines +70 to +73
final String transport;
final double median;
final double p90;
final int samples;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we get docs explaining what these values mean?

Comment thread packages/pigeon/tool/benchmark.dart Outdated
final done = Completer<void>();
void handleLine(String line) {
final int start = line.indexOf(_resultPrefix);
if (start >= 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

small nit for readability:

Suggested change
if (start >= 0) {
if (line.contains(_resultPrefix)) {

And then move start variable into if statement.

Comment thread packages/pigeon/tool/benchmark.dart Outdated
? '${(microseconds / 1000).toStringAsFixed(2)} ms'
: '${microseconds.toStringAsFixed(2)} µs';

final names = <String>[];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this list necessary? It looks like it is just the keys in byName.

Comment thread packages/pigeon/tool/benchmark.dart Outdated
: '${microseconds.toStringAsFixed(2)} µs';

final names = <String>[];
final byName = <String, Map<String, _Result>>{};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: I think this could use a more descriptive name.


const String _platformOption = 'platform';
const String _deviceOption = 'device';
const String _resultPrefix = 'PIGEON_BENCHMARK|';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this needs a comment that it should match the value in platform_tests/shared_test_plugin_code/lib/native_interop_benchmarks.dart.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +2147 to +2153
entryReturnType = returnType.isVoid
? 'Future<JObject>'
: nativeInteropReturnsBuffer(returnType)
? 'Future<JByteArray?>'
: returnType.isEnum
? 'Future<JLong>'
: 'Future<${_JniType.fromTypeDeclaration(returnType).getJniCallReturnType(true)}>';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: I prefer this be an if statement or switch. It's just a bit easier to read with this formatting. Same for below.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should hybrid helper methods go in there own file like proxy_api_generator_helper.dart? Same for other languages.

@Piinks Piinks left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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!)

Comment on lines +4384 to +4392
static String _hybridJvmTypeKey(TypeDeclaration type) {
if (nativeInteropReturnsBuffer(type)) {
return 'Uint8List';
}
if (type.isEnum) {
return type.isNullable ? 'int?' : 'int';
}
return _jvmTypeKey(type);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I this method, I think there are a couple of edge cases with the JNI temporaries here:

  1. 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:
    @HostApi()
    abstract class CommandApi {
      void execute(String args, List<String> extra);
    }
    That emits both final JString pigeonVar_args and final JByteArray pigeonVar_args inside execute.
  2. 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(', ')})';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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)>[

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why does this not extend FlutterStandardWriter like how Kotlin extends StandardMessageCodec?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@tarrinneal

Copy link
Copy Markdown
Contributor Author

I'm still in need of an approval on this pr if @bparrishMines @Piinks or @dkwingsmt have a moment

@Piinks Piinks left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
@tarrinneal tarrinneal added the autosubmit Merge PR when tree becomes green via auto submit App label Nov 10, 2026
@auto-submit
auto-submit Bot merged commit 08faf64 into flutter:main Nov 10, 2026
12 checks passed
rmacnak-google pushed a commit to rmacnak-google/flutter that referenced this pull request Nov 10, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

autosubmit Merge PR when tree becomes green via auto submit App CICD Run CI/CD p: pigeon platform-android platform-ios platform-macos

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants