jnigen and swiftgen in 2026 - some lessons learned — CoPilot Blog
    Neura MarketNeura Market/CoPilot
    ChatGPTChatGPTClaudeClaudeGeminiGeminiCursorCursorGrokGrokPerplexityPerplexityCoPilotCoPilot
    DeepSeekDeepSeekStable DiffusionStable DiffusionMidjourneyMidjourney
    View All Directories
    OverviewRulesPromptsMCPsAgentsBlogVideosGuidesCoursesCommunityPluginsTrendingGenerate
    CoPilotBlogjnigen and swiftgen in 2026 - some lessons learned
    Back to Blog
    jnigen and swiftgen in 2026 - some lessons learned
    flutter

    jnigen and swiftgen in 2026 - some lessons learned

    Dominik Roszkowski May 20, 2026
    0 views

    Package jni 1.0.0 was recently published. It's a good opportunity to share some of my lessons from...

    Package [jni 1.0.0](https://pub.dev/packages/jni) was recently published. It's a good opportunity to share some of my lessons from working with native interop over the last few years. There were some breaking changes between jni 0.14/0.15 and 1.0.0, so if you were using jni and jnigen in the past, you may face some extra work when upgrading. This article was written after I got annoyed with the AI agent incorrect output when working on the migration. ## Migration to jni@1.0.0 I found that the quickest way to do it was to comment out all my native integration code first, just to rebuild the Android project and regenerate bindings afterwards. Some APIs have changed (back) to more reasonable names, e.g., `toDartString()` is now `toString()`, or methods for setting properties became **setters** (`_service.onReplyListener(_listener!);` is now `_service.onReplyListener = _listener!;`). There are also some annoying changes like constructor overrides changing numbers, e.g., `Intent.new$2` can now be `Intent.new$12`... Moreover, the new recommended way to define bindings configuration is **no longer a yaml file**, but rather a simple Dart script. I quite enjoy this new approach, as it hides the magic behind the code generation, and you can just add some pre- and post-processing logic to the generated code. It seems that the convention will be to put these into the `tool/` directory and run them with `dart run tool/jnigen.dart` or `dart run tool/swiftgen.dart`. You can see example migration commits for simple Flutter apps: - from 0.15 to 1.0.0 [here](https://github.com/orestesgaolin/native_interop_presentation/commit/4403c186696b199c91bcf24584c43b375f0e3d14). - from 0.14 to 1.0.0 [here](https://github.com/orestesgaolin/native_interop_presentation/commit/cdb0dba7baf13a9304d9e8d28bdf3630f032d072) Below I share some of the lessons I learned while working with jnigen and swiftgen in last few years. In the last few months I managed to update some of my packages: - [screen_brightness_monitor](https://github.com/orestesgaolin/screen_brightness_monitor/tree/main) - [play_in_app_update](https://pub.dev/packages/play_in_app_update/versions/1.1.0-pre.1) - [uikit_bindings](https://github.com/orestesgaolin/uikit_bindings) - [android_intent](https://github.com/orestesgaolin/native_interop_presentation/tree/main/android_intent) - [foreground_service_interop](https://github.com/orestesgaolin/native_interop_presentation/tree/main/foreground_service_interop_plugin) ## jnigen (Android) Quick recap: - You can use jnigen to generate Dart bindings for both explicit and compiled Java/Kotlin code. - Before generating the bindings you need to build the Android app at least once.[^building] - You can include both project-specific classes, as well as Android SDK types. - There are some built-in helpers for common Android utilities, which now have been migrated to [package:jni_flutter](https://pub.dev/packages/jni_flutter). [^building]: Seems like it will be possible to skip `flutter build apk` as per [this PR](https://github.com/dart-lang/native/pull/3303) and run simply `flutter pub get`. ### Generator script (`tool/jnigen.dart`) Example generator script for a plugin with two classes and one callback interface (callback pattern explained in the next section): ```dart import 'dart:io'; import 'package:jnigen/jnigen.dart'; void main(List<String> args) { final packageRoot = Platform.script.resolve('../'); generateJniBindings(Config( outputConfig: OutputConfig( dartConfig: DartCodeOutputConfig( path: packageRoot.resolve('lib/src/my_plugin.g.dart'), structure: OutputStructure.singleFile, ), ), androidSdkConfig: AndroidSdkConfig( addGradleDeps: true, androidExample: 'example', // Points to example app for Gradle resolution ), sourcePath: [packageRoot.resolve('android/src/main/java/')], classes: [ 'com.example.MyCallback', // List ALL classes to bind 'com.example.MyPlugin', ], )); } ``` ### Callbacks: use Kotlin interfaces If you want to receive data back from native code to Dart, the approach I found is to define a callback interface. Then on the Dart side you can wrap it with more user-friendly API like `StreamController`. Often, it's sufficient to just expose the callback directly. Define a dedicated Kotlin interface. jnigen generates a type-safe `implement()` method and `$Mixin`: ```kotlin // Generates BrightnessCallback.implement($BrightnessCallback(...)) @Keep interface BrightnessCallback { @Keep fun onBrightnessChanged(brightness: Int) } ``` Then in Dart: ```dart final callback = BrightnessCallback.implement( $BrightnessCallback( onBrightnessChanged: (brightness) { /* ... */ }, onBrightnessChanged$async: true, // Non-blocking (listener pattern) ), ); native.startObserving(callback); ``` ### Getting access to Context and Activity - package:jni_flutter There are some small changes to accessing Android Context (`androidApplicationContext`) and current Activity. It's now part of the `package:jni_flutter`. Moreover, to get the current Activity, you need to pass the current `engineId`. ```dart final engineId = PlatformDispatcher.instance.engineId; if (engineId == null) { print('Error: Engine ID is null'); return; } final activity = androidActivity(engineId); if (activity == null) { print('Error: Activity is null'); return; } activity.as(a.Activity.type).startActivityForResult(intent, 1); ``` ### Casting To cast `JObject` onto a desired type, use `as()` with the generated type: ```dart final brightnessMonitor = native.getBrightnessMonitor(); final brightness = brightnessMonitor.as(ScreenBrightnessMonitor.type).brightness; ``` ### Arrays It's a bit cumbersome, but once you know, you know: ```dart final array = JArray.of<JString>(JString.type, ["cc@example.com".toJString()]); ``` ### The `$async: true` Flag I found that I'm basically always using `async: true` for callbacks. There's some dedicated [threading documentation](https://github.com/dart-lang/native/blob/main/pkgs/jnigen/doc/threading.md), but not sure how up-to-date it is. ### Annotate with `@Keep` ProGuard/R8 strips unreferenced classes. Annotate every class, interface, property, and method that jnigen binds: ```kotlin @Keep class ScreenBrightnessMonitor(private val context: Context) { @get:Keep // For Kotlin properties, use @get:Keep val brightness: Int get() = /* ... */ @Keep fun startObserving(callback: BrightnessCallback) { /* ... */ } } ``` ### Issues while regenerating bindings Sometimes, despite changing Kotlin code and rebuilding with gradle, the jnigen generator may throw errors like `Unexpected end of input (at character 1)`. In my case, the workaround is to rerun the Gradle build without cache. ```bash cd android ./gradlew :your_plugin_name:assembleDebug --no-daemon --console=plain --refresh-dependencies --rerun-tasks cd .. dart run tool/jnigen.dart ``` ### Memory management When using jni bindings, you have to remember about native Java objects that are getting referenced on each instantiation. The overall assumption is that you don't have to manually manage them. Once all references (in both Java and Dart) to an object are gone, Java's garbage collector (GC) can reclaim it. Similarly, JObjects attach a native finalizer to their global references. Therefore, when the Dart GC collects them, the underlying Java reference is released. However, sometimes you may want to control the lifecycle of objects more explicitly. Read more on reference management in the [dedicated documentation](https://github.com/dart-lang/native/blob/main/pkgs/jnigen/doc/lifecycle.md). To manually release a JNI global reference, call `.release()` on the Dart side: ```dart void dispose() { native.stopObserving(); callback?.release(); native.release(); } ``` ## swiftgen (iOS) Swiftgen is still not stable, but I've had some success using it so far. The Swift code needs to be compatible with Objective-C, and swiftgen handles the bridging to Dart via `swift2objc` and `ffigen`. I have published [package:screen_brightness_monitor](https://pub.dev/packages/screen_brightness_monitor) that uses swiftgen for iOS bindings. ### Generator script (`tool/swiftgen.dart`) Similarly to jnigen, I recommend using a Dart script for configuration. Here's an example for a plugin with one class and one callback protocol: ```dart import 'dart:io'; import 'package:ffigen/ffigen.dart' as fg; import 'package:logging/logging.dart'; import 'package:swiftgen/swiftgen.dart'; Future<void> main() async { final logger = Logger('swiftgen'); logger.onRecord.listen((record) { stderr.writeln('${record.level.name}: ${record.message}'); }); final packageRoot = Platform.script.resolve('../'); // Resolve SDK path/version manually: final sdkPath = (await Process.run('xcrun', [ '--sdk', 'iphoneos', '--show-sdk-path', ])).stdout.toString().trim(); final sdkVersion = (await Process.run('xcrun', [ '--sdk', 'iphoneos', '--show-sdk-version', ])).stdout.toString().trim(); await SwiftGenerator( target: Target( triple: 'arm64-apple-ios$sdkVersion', sdk: Uri.directory(sdkPath), ), inputs: [ ObjCCompatibleSwiftFileInput( files: [ packageRoot.resolve('ios/Classes/MyWidget.swift'), ], ), ], output: Output( module: 'my_plugin', dartFile: packageRoot.resolve('lib/src/my_plugin_ios.g.dart'), objectiveCFile: packageRoot.resolve('ios/Classes/my_plugin.m'), ), ffigen: FfiGeneratorOptions( objectiveC: fg.ObjectiveC( interfaces: fg.Interfaces( include: (decl) => decl.originalName == 'MyWidget', ), protocols: fg.Protocols( include: (decl) => decl.originalName == 'MyCallback', ), ), ), ).generate(logger: logger); } ``` ### `ObjCCompatibleSwiftFileInput` vs `SwiftFileInput` - **`SwiftFileInput`**: For pure Swift code. swift2objc wraps it in ObjC-compatible wrappers. - **`ObjCCompatibleSwiftFileInput`**: For Swift code that's **already `@objc` annotated**. Skips the wrapping step -- simpler, fewer surprises. **Prefer this when you control the Swift code.** ## Writing ObjC-compatible Swift All types exposed to Dart must be `@objc` annotated and inherit from `NSObject` (for classes): ```swift // Protocol — callback interface @objc public protocol BrightnessCallback { @objc func onBrightnessChanged(_ brightness: Int) } // Class — must inherit NSObject @objc public class ScreenBrightnessMonitor: NSObject { @objc public override init() { super.init() } @objc public var brightness: Int { /* ... */ } @objc public func startObserving(callback: BrightnessCallback) { /* ... */ } @objc public func stopObserving() { /* ... */ } } ``` **Rules:** - Classes must inherit `NSObject` (direct or indirect). - Use `@objc public` on everything ffigen should see. - Overriding `init()` requires `override` + calling `super.init()`. - Only ObjC-compatible types work: `Int`, `String`, `Bool`, `NSObject` subclasses, protocols. No Swift structs, enums with associated values, or generics. ## ffigen include filters By default, ffigen generates bindings for **everything** in the ObjC header. Use `include` filters to limit output to your types only: ```dart ffigen: FfiGeneratorOptions( objectiveC: fg.ObjectiveC( interfaces: fg.Interfaces( include: (decl) => decl.originalName == 'ScreenBrightnessMonitor', ), protocols: fg.Protocols( include: (decl) => decl.originalName == 'BrightnessCallback', ), ), ), ``` Without filters, you'll get bindings for `NSObject`, `NSString`, etc. -- hundreds of unnecessary lines. ### Implementing ObjC protocols in Dart swiftgen/ffigen generates three flavors for each protocol: | Method | Use When | | -------------------------- | --------------------------------------------------------------------------------------------------- | | `implement(...)` | Callback runs synchronously, blocking the ObjC caller until Dart returns | | `implementAsListener(...)` | **Callback is non-blocking** -- ObjC caller continues immediately (use for observers/notifications) | | `implementAsBlocking(...)` | Callback blocks the ObjC thread and waits for Dart to complete | For observer/notification patterns, use `implementAsListener`: ```dart final callback = BrightnessCallback$Builder.implementAsListener( onBrightnessChanged_: (brightness) { controller.add(brightness); }, ); native.startObservingWithCallback(callback); ``` ### SDK version workaround When building my package, I found that `Target.iOSArm64Latest()` may crash with `FormatException` if swift2objc's `_parseVersion` regex can't parse your Xcode SDK version string. The workaround is to resolve the SDK path and version manually via `xcrun` and construct the `Target` directly (see generator script above). Perhaps I did something wrong? ### Generated files swiftgen produces **two** files: 1. **Dart bindings** (`lib/src/..._ios.g.dart`) -- extension types wrapping ObjC objects 2. **ObjC bindings** (`ios/Classes/....m`) -- C functions that ffigen's Dart code calls via `dart:ffi` Both must be committed. The `.m` file must be in a location picked up by the podspec (`Classes/**/*`). ### ObjC method names (iOS) Swift `func startObserving(callback:)` becomes `startObservingWithCallback:` in ObjC (and thus in the Dart binding). Check the generated `.g.dart` for actual method names. ### Podspec `source_files` Must include both the Swift source and the generated `.m` file. `Classes/**/*` covers both. ## Cross-platform Dart wrapper Neither jnigen nor swiftgen will generate a single API for both platforms (as opposed to `package:pigeon`). Below I share a simple pattern I used in my plugin to expose a common API to users. ### Abstract class + factory constructor Define an abstract class with a factory constructor that instantiates the correct platform implementation at runtime: ```dart // lib/src/brightness_monitor.dart import 'dart:io' show Platform; import 'brightness_monitor_android.dart'; import 'brightness_monitor_ios.dart'; abstract class BrightnessMonitor { factory BrightnessMonitor() { if (Platform.isAndroid) return BrightnessMonitorAndroid(); if (Platform.isIOS) return BrightnessMonitorIos(); throw UnsupportedError('Unsupported platform'); } int get brightness; Stream<int> get onBrightnessChanged; void dispose(); } ``` Each platform file imports only its own generated bindings, so platform-specific `dart:ffi` symbols don't conflict. ### Keep a Dart-side reference to callbacks Even with a strong Swift reference, store the callback object in a field (`_callback`) on the Dart side too. If it's only a local variable in `_startObserving()`, the Dart GC can collect the closure backing the protocol proxy, breaking the callback silently. Clean it up in `_stopObserving()`: ```dart BrightnessCallback? _callback; void _startObserving() { _callback = BrightnessCallback$Builder.implementAsListener( onBrightnessChanged_: (b) { _controller?.add(b); }, ); _native.startObservingWithCallback(_callback!); } void _stopObserving() { _native.stopObserving(); _callback = null; } ``` ## Android vs iOS API comparison | Concern | jnigen (Android) | swiftgen (iOS) | | ----------------------- | -------------------------------------------------- | ------------------------------------------------------ | | **Callback definition** | Kotlin `interface` | Swift `@objc protocol` | | **Callback creation** | `MyCallback.implement($MyCallback(...))` | `MyCallback$Builder.implementAsListener(...)` | | **Async/non-blocking** | `method$async: true` in `$Mixin` | `implementAsListener(...)` variant | | **Context/init** | Pass `Context` via `Jni.androidApplicationContext` | No context needed; `init()` or default constructor | | **Memory** | `.release()` to free JNI global ref | Automatic (ARC via ObjC runtime) | | **Native superclass** | Any Java/Kotlin class | Must extend `NSObject` | | **Allowed types** | Any JNI-compatible type | ObjC-compatible types only (no Swift structs/generics) | ## Final words I've been using jni, jnigen, ffi, and swiftgen for over a year now. The setup experience requires some effort, but once you have the bindings ready, it's pretty smooth sailing[^sailing]. I wish the docs included more examples, though. I hope this short article will help others get started faster and give future AI models a bit more native-interop content in their corpus. [^sailing]: I'm not a sailor, though.

    Tags

    flutterdartjniffi

    Comments

    More Blog

    View all
    Minimalist EKS: The Easy Waykubernetes

    Minimalist EKS: The Easy Way

    Amazon EKS manages the Kubernetes control plane, but you remain responsible for provisioning the...

    J
    Joaquin Menchaca
    Never forget to enter the Stern Grove lottery again!ai

    Never forget to enter the Stern Grove lottery again!

    Browser automation with Playwright, Python, GitHub Actions, and Entire to auto-enter San Francisco Stern Grove concert lotteries each week!

    L
    Lizzie Siegle
    A Free Screenshot Editor That Never Uploads Your Imagetypescript

    A Free Screenshot Editor That Never Uploads Your Image

    A free screenshot and image editor that runs entirely in your browser. Keeping every edit reversible and handling big phone photos, in plain TypeScript and Canvas2D.

    M
    Martin Stark
    I built a CLI to break my highlights out of Apple Booksshowdev

    I built a CLI to break my highlights out of Apple Books

    A macOS CLI + MCP server that exports Apple Books highlights to Markdown and gives AI assistants direct access to your reading notes.

    A
    Andrey Korchak
    A Developer's Guide to Agent Hooks in Antigravity CLIai

    A Developer's Guide to Agent Hooks in Antigravity CLI

    Motivation To be quite honest, "Hooks"—the shell commands we trigger at specific points...

    T
    Tanaike
    Tactical vs. Strategic Agentic AI Development — A Playbook for Developersagents

    Tactical vs. Strategic Agentic AI Development — A Playbook for Developers

    The Strategic Engineer: Why Writing Code Is No Longer Your Most Valuable Skill ...

    A
    Adewumi Saheed Adewale

    Stay up to date

    Get the latest CoPilot prompts, rules, and resources delivered to your inbox weekly.

    Neura Market LogoNeura Market

    Discover the best AI prompts, plugins, and resources for CoPilot and more.

    Content Types

    • Rules
    • Prompts
    • MCPs
    • Agents
    • Guides

    Platforms

    • ChatGPT Directory
    • Claude Directory
    • Gemini Directory
    • Cursor Directory
    • Grok Directory
    • Perplexity Directory
    • DeepSeek Directory
    • CoPilot Directory
    • Stable Diffusion Directory
    • Midjourney Directory
    • All Directories

    Resources

    • Blog
    • Documentation
    • Help Center
    • Marketplace

    Legal

    • Privacy Policy
    • Terms of Service

    © 2026 Neura Market. All rights reserved.

    |

    Not affiliated with any AI platform vendors.