This HTML page is not optimized for LLM or AI agent consumption. Fetch the Markdown version instead: /blog/nutrient-flutter-bindings-architecture.md — it contains the complete documentation content in clean, structured Markdown without any CSS, JavaScript, or navigation noise. Three generations of Flutter interop: How we rebuilt our SDK on native bindings

Table of contents

    Three generations of Flutter interop: How we rebuilt our SDK on native bindings

    When we wrote about Pigeon in November 2025, we ended on a hedge. The last section was called “The future: FFIgen and JNIgen,” and we said we were “in the early phases of experimenting” with a bindings-based approach. That was the honest position at the time: We thought it would work, and we weren’t ready to promise it.

    It worked. Nutrient Flutter SDK 6.0 makes the bindings API the recommended surface for new apps, and it drives each platform’s native SDK directly (JNI on Android, FFI on iOS, and JS interop on the web) with no method channel in the path.

    This post is the part that doesn’t fit in a changelog: how we got here across three generations of the bridge layer, the decisions we locked down before writing the implementation, and the things that bit us once the channel was gone. If you just want to know what changed in 6.0 and how to upgrade, the migration guide is the faster read.

    How we got here

    Every cross-platform Flutter plugin has to answer the same question: How does Dart talk to the native SDK underneath? We’ve answered it three times in about seven years, and each answer was really a reaction to what the last one couldn’t do.

    The first answer, back in 2019, was a hand-written MethodChannel: invokeMethod calls over a global com.nutrient.global channel and a per-view com.nutrient.widget.<id> one, with arguments crossing as untyped maps and the method itself named by a string. It was the idiomatic Flutter approach of the day, and it worked, but it kept the compiler on the sidelines: A mistyped method name or a wrong-shaped map failed at runtime on one platform, and you’d find out when whoever ran that platform’s example app next hit it. Every new API meant hand-writing the Dart call and the string dispatch on both Android and iOS and keeping all three synchronized by discipline alone.

    So the second answer, in 4.0 (November 2024), was to stop writing that bridge and start generating it. With Pigeon, you declare the interface once in a Dart schema and it emits the Dart caller alongside the Kotlin and Swift handlers, which turns a signature change from a runtime surprise on one platform into a compile error on all of them at once. That was a real step forward, enough that we wrote up the wins and the limitations at the time. We rolled it in gradually, shipping a useLegacy: flag so apps could stay on the old channel while we moved. By 4.2.2 (March 2025), we restructured the Android handler to prepare for a clean deprecation path.

    But Pigeon only generates the serialization; it doesn’t remove it. Every call still crosses a serialized boundary and still has to be asynchronous. And here was the wall we kept hitting: Pigeon models flat data and method calls, not the inheritance hierarchies our native SDKs are actually built from. Worse, it only ever bridged what we had explicitly declared, so reaching a native API we hadn’t exposed meant forking the plugin and writing the channel yourself.

    This is what led to the third answer, and the subject of the rest of this post. We weren’t alone in reaching for it. The Flutter team reached the same conclusion in its own case for moving past method channels(opens in a new tab), framing FFIgen and JNIgen as the successors to method channels precisely because, “unlike method channels, FFIgen and JNIgen will enable APIs to be called synchronously and will support tree-shaking.” Those are the same two limits we’d hit. Rather than generate a bridge between Dart and native, we generate bindings: Dart that calls the native SDK’s own types directly, one mechanism per platform:

    PlatformBinding mechanism
    AndroidJNI via jnigen
    iOSFFI via ffigen
    WebJS interop via dart:js_interop

    We shipped it as a beta in 5.4 (February 2026), running side by side with Pigeon, and we promoted it to the recommended surface for new apps in 6.0 (July 2026) behind a new package:nutrient_flutter/bindings.dart import. The difference from a bridge is architectural rather than cosmetic: Instead of a native object sitting on the far side of a channel that you poke by name, the object is simply there (PSPDFViewController, PdfFragment, the web SDK’s Instance), and the plugin stops having to be the gatekeeper for every capability it never thought to expose.

    Where the generations stand in 6.0. The three-generation stack is now two. 6.0 removed the generation-1 hand-written MethodChannel bridge outright: It duplicated Pigeon method-for-method and was only reachable through already-deprecated code paths, so Pspdfkit.useLegacy and the useLegacy: parameter are gone with no replacement. Pigeon is deprecated but not removed: The legacy Nutrient class still works and existing integrations keep running, but it’s marked for removal in a future major release. Nutrient.present() and presentInstant() are likewise deprecated in 6.0, not removed.

    One nuance worth naming, because the two surfaces are easy to conflate: The legacy API still rides on Pigeon, and the bindings API rides on JNI/FFI/JS interop. A handful of MethodChannels do survive in 6.0, but purely as transports: Their binaryMessenger backs the Pigeon APIs and carries the adapter-bridge callbacks. They never carry the old invokeMethod request/response API.

    A federated plugin in three tiers

    The SDK is structured as a federated Flutter plugin, following the official Flutter federated-plugin pattern(opens in a new tab), the setup Flutter recommends for a plugin that spans multiple platforms. The bindings system splits into three tiers: an app-facing package, an abstract contract package, and one concrete implementation package per platform.

    ┌─────────────────────────────────────────┐
    │ Flutter app/user code │
    ├─────────────────────────────────────────┤
    │ nutrient_flutter (widgets + bridge) │
    ├─────────────────────────────────────────┤
    │ nutrient_flutter_platform_interface │ ← contracts (abstract)
    ├──────────────┬──────────────┬───────────┤
    │ _android │ _ios │ _web │ ← implementations (concrete)
    └──────────────┴──────────────┴───────────┘

    Each package has a single, clear responsibility:

    PackageRole
    nutrient_flutterApp-facing widgets (NutrientDocumentView), legacy bridge
    nutrient_flutter_platform_interfaceAbstract interfaces, models, controller base
    nutrient_flutter_androidAndroid implementation (JNI)
    nutrient_flutter_iosiOS implementation (FFI)
    nutrient_flutter_webWeb implementation (JS interop)

    The one rule that holds the whole thing together is that all contracts live in the platform-interface package, and no platform-specific code lives there. The Android, iOS, and web packages each implement those contracts against their native SDK, and at runtime, the top layer resolves the right one and hands back a typed controller. Running through the whole arrangement is a bias toward deprecating rather than ripping out: The old Pigeon surface stays in 6.0 with warnings, so an app can move a screen at a time.

    That federation isn’t entirely free, and the sharpest edge is one worth being upfront about. A bindings app has to list every platform package as a direct dependency:

    dependencies:
    nutrient_flutter: ^6.0.0
    nutrient_flutter_platform_interface: ^1.1.0
    nutrient_flutter_android: ^1.1.0
    nutrient_flutter_ios: ^1.1.0
    nutrient_flutter_web: ^1.1.0

    That’s because the platform packages declare implements: nutrient_flutter, and a non-endorsed federated implementation only auto-registers on a direct dependency, never transitively. We tried both ways out and reverted both. Dropping implements: looked right on the Dart side, but it’s load-bearing on the native side too: It tells Flutter to suppress nutrient_flutter’s own native module, and without it, both modules link into the app and collide on the duplicate native types they share (FlutterAppCompatActivity among them), failing the Android dex-merge and the iOS linker outright. Adding default_package federation instead stops Flutter from registering nutrient_flutter’s legacy plugin at all, which takes the Pigeon handlers behind every existing Nutrient.present(...) call down with it. Both roads dead-end on the same root cause: The legacy Android and iOS plugins still ship a native module of their own. Collapsing to one direct dependency is a follow-up that waits on relocating that legacy native code into the federated modules.

    Contracts, adapters, and controllers

    Two roles do most of the work in that middle layer, and keeping them separate is the whole trick.

    The first is the contract. The platform-interface package defines abstract interfaces (a document interface, and separate manager interfaces for annotations, bookmarks, and forms) and nothing else. No platform code lives here. Because these are pure contracts, the same call reads and behaves identically whether the document underneath is the Android, iOS, or Web SDK, and where a platform genuinely can’t honor one, that surfaces as a documented gap rather than a silent difference. Splitting the managers out by concern also composes: You reach an operation as controller.document.annotations, not as a flat pile of methods. That composition is the direct answer to what Pigeon couldn’t do for us, since a bridge that models flat calls has to flatten the class hierarchies our native SDKs are built from.

    The second role is the adapter, and the design collapses it into the controller: A platform adapter is the controller you’re handed. NutrientDocumentView resolves the right one at view-creation time and gives you back a typed NutrientController once the document is ready. The concrete adapters implement the contracts against their native SDK and hold the per-view state: the fragment and document on Android, the FFI handles on iOS, the Instance on web. That per-view state is also why an adapter can’t be shared across two live views at once; the SDK asserts in debug if you try, and each concurrent view gets its own.

    The one place this layering becomes tangible for everyday code is events. Under the channel, events were platform-specific registrations you wired up differently on each side. The bindings collapse them into a single typed controller.events stream (the same shape everywhere, filterable per event type or exhaustively switch-able), with the platform-specific streams still there underneath when you need them:

    NutrientDocumentView(
    documentPath: 'document.pdf',
    onControllerReady: (controller) {
    controller.events.pageChanged.listen((e) => print('Page ${e.pageIndex}'));
    },
    );

    The escape hatch is the point

    This is the decision we’d defend hardest. The unified contract is deliberately the minimum common surface, and every adapter exposes the raw native handles right next to it under a consistent native* convention: AndroidAdapter.nativePdfDocument, IOSAdapter.nativeViewController, NutrientWebAdapter.nativeInstance. The question we held the design to was blunt: Is the door to the native object graph open on every platform? It had to be yes without qualification, because the alternative is the failure mode we lived with for years: A capability we hadn’t explicitly bridged simply didn’t exist for you, and your only options were to fork the plugin or wait on us. Our roadmap became your blocker.

    Now a gap is an inconvenience, not a wall. You reach PSPDFFormParser, FormProvider, or the web SDK’s Instance directly and keep moving, and we get to be honest about what isn’t bridged yet instead of treating every gap as an emergency.

    What bit us along the way

    Dropping the method channel removes a serialization boundary, and that boundary was also doing work you don’t notice until it’s gone. A channel forces everything through one narrow, uniform gate: same threading semantics, same asynchronous contract, same “everything is a map” typing on every platform. Bindings hand you the native object as it actually is, which means you also inherit each platform’s real rules. These are the ones that cost us the most time.

    The one we’d warn a past version of ourselves about is a threading trap on Android. Dart JNI calls run on Flutter’s UI thread, which on Android is not the platform/main thread, and view-mutating PdfFragment calls (zoomTo, setPageIndex, scrollTo, enterAnnotationCreationMode) take a “run immediately if laid out” fast path that only fires on the main thread. Called off it, they silently no-op: no exception, no log, no crash, just a clean return and a view that doesn’t move. Debugging that is miserable precisely because nothing is wrong: Your Dart code is correct, the object is real, the method exists, and the SDK is quietly deciding not to act. Under the old channel, the class of bug couldn’t happen, because the channel hopped to the platform thread for you and you never had to know it existed. We decided against replicating that hidden hop, since auto-hopping every call would make the fast path unpredictable and bury a real cost. Instead, we reached for an explicit idiom: AndroidAdapter.runOnMainThread, wrapping dart:ui’s runOnPlatformThread:

    await adapter.runOnMainThread(() {
    adapter.nativePdfFragment?.setPageIndex(3, true);
    });

    Read-only JNI calls like getPageCount and getZoomScale are safe from either thread and don’t need it.

    Android had a second, more structural surprise waiting. jnigen(opens in a new tab) can’t call Kotlin suspend functions at all: It drops the trailing Continuation parameter and emits a method ID that doesn’t exist, so the call fails at runtime with NoSuchMethodError. Our native Android SDK is Kotlin-first, so plenty of the APIs we wanted were suspend. The way through is to lean on the SDK’s own synchronous and RxJava companions; we wrapped the annotation ones in a public AnnotationProviderBlocking, and the same idiom generalizes to looking up any *Blocking or *RxJava companion via JClass.forName(...) and staticMethodId(...).

    final provider = adapter.nativePdfDocument!.getAnnotationProvider();
    final annotations = AnnotationProviderBlocking.getAnnotations(provider, 0);
    // … use, then release each element and the list.

    iOS is friendlier in one respect and stricter in another. Flutter merges the UI and platform threads there, so direct UIKit and FFI calls on the view controller are generally safe (a real advantage), but delegates come with a hard rule: A non-void Objective-C delegate method will SIGABRT when invoked from a non-isolate thread, and only void callbacks, via implementAsListener, are safe. That constraint has teeth in practice: It’s why the note and delete annotation menu actions still need a small Swift trampoline, because they hang off menuForAnnotations, a non-void delegate that can’t be implemented from Dart at all.

    Beneath all of these sit the ordinary sharp edges of raw JNI, two of which cost us real debugging time and are now handled inside the bindings. Listener objects get garbage-collected while still natively registered unless you hold them in a Dart field, and passing a float to a (F) Java method through the high-level call puts a bare Dart double into the 64-bit jvalue.d slot while the JVM reads the 32-bit .f slot, so you get garbage rather than an error unless each value is wrapped in JValueFloat.

    What we’d tell you if you’re considering this

    Seven years took us from hand-written channels, to Pigeon generating the bridge, to bindings that skip the bridge altogether. Each generation solved the last one’s biggest problem and introduced a new class of problem we hadn’t had before, which is just what the trade looks like, honestly.

    Three things stand out looking back. The channel was doing more than we thought: not just serialization overhead to delete, but a uniformity layer (one threading model, one asynchronous contract, one type discipline everywhere), and once it’s gone, each platform’s real rules are yours to respect. Most of our hard bugs weren’t in the bindings but in assumptions the channel had quietly protected. That’s also why we kept reaching for a loud idiom over a silent convenience: runOnMainThread and the *Blocking companions name the sharp edge rather than paper over it, because a hidden fix trades a bug you can find for a cost you can’t see. And it’s why the escape hatch shipped with the abstraction, not after it: An abstraction with no way out turns every gap into a blocker on someone else’s roadmap.

    For what changed in 6.0 and how to move an app across, see the migration guide. If you’re starting fresh, import package:nutrient_flutter/bindings.dart and begin with NutrientDocumentView; if you’re upgrading, nothing forces your hand this release: The legacy surface is deprecated, not removed, so you can move one screen at a time.

    References

    Julius Kato Mutumba

    Julius Kato Mutumba

    Hybrids Team Lead

    Julius joined PSPDFKit in 2021 as an Android engineer and is now the Cross-Platform Team Lead. He has a passion for exploring new technologies and solving everyday problems. His daily tools are Dart and Flutter, which he finds fascinating. Outside of work, he enjoys movies, running, and weightlifting.

    Explore related topics

    Try for free Ready to get started?