jnigen and swiftgen in 2026 - some lessons learned — Stable…
    Neura MarketNeura Market/Stable Diffusion
    ChatGPTChatGPTClaudeClaudeGeminiGeminiCursorCursorGrokGrokPerplexityPerplexityStable DiffusionStable Diffusion
    DeepSeekDeepSeekCoPilotCoPilotMidjourneyMidjourney
    View All Directories
    OverviewPromptsBlogVideosGuidesCoursesCommunityModelsLoRAsComfyUI WorkflowsTrending
    Stable DiffusionBlogjnigen 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 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 [email protected]

    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.
    • from 0.14 to 1.0.0 here

    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
    • play_in_app_update
    • uikit_bindings
    • android_intent
    • foreground_service_interop

    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.1
    • 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.

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

    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:

    // Generates BrightnessCallback.implement($BrightnessCallback(...))
    @Keep
    interface BrightnessCallback {
        @Keep
        fun onBrightnessChanged(brightness: Int)
    }
    

    Then in 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.

    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:

    final brightnessMonitor = native.getBrightnessMonitor();
    final brightness = brightnessMonitor.as(ScreenBrightnessMonitor.type).brightness;
    

    Arrays

    It's a bit cumbersome, but once you know, you know:

    final array = JArray.of<JString>(JString.type, ["[email protected]".toJString()]);
    

    The $async: true Flag

    I found that I'm basically always using async: true for callbacks. There's some dedicated threading documentation, 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:

    @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.

    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. To manually release a JNI global reference, call .release() on the Dart side:

    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 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:

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

    // 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:

    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:

    MethodUse 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:

    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:

    // 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():

    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

    Concernjnigen (Android)swiftgen (iOS)
    Callback definitionKotlin interfaceSwift @objc protocol
    Callback creationMyCallback.implement($MyCallback(...))MyCallback$Builder.implementAsListener(...)
    Async/non-blockingmethod$async: true in $MixinimplementAsListener(...) variant
    Context/initPass Context via Jni.androidApplicationContextNo context needed; init() or default constructor
    Memory.release() to free JNI global refAutomatic (ARC via ObjC runtime)
    Native superclassAny Java/Kotlin classMust extend NSObject
    Allowed typesAny JNI-compatible typeObjC-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 sailing2. 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.

    Footnotes

    1. Seems like it will be possible to skip flutter build apk as per this PR and run simply flutter pub get. ↩

    2. I'm not a sailor, though. ↩

    Tags

    flutterdartjniffi

    Comments

    More Blog

    View all
    Context bankruptcy: The case for strategic forgetting for AI Agentsai

    Context bankruptcy: The case for strategic forgetting for AI Agents

    Most of us have seen a coding agent fail to complete a task we know it can do. We just don't...

    J
    James O'Reilly
    Parallel Compliance Engine: Drive-to-Sheets Multi-Agent Orchestrationgooglecloud

    Parallel Compliance Engine: Drive-to-Sheets Multi-Agent Orchestration

    When building Generative AI applications, developers often encounter a massive bottleneck: sequential...

    A
    Aryan Irani
    Is It Ethical to Post and Ask About Circuits on Dev.to?discuss

    Is It Ethical to Post and Ask About Circuits on Dev.to?

    I’ve been thinking about sharing some electronic circuit posts on Dev.to — small circuits, DIY...

    C
    codebunny20
    The One-Click Exporter: AI Studio Antigravity, Probed to Its Limitsagents

    The One-Click Exporter: AI Studio Antigravity, Probed to Its Limits

    What nobody tells you about exporting your multi-agent prototype to a local workspace. Every...

    L
    leslysandra
    Guarding the till while autonomous data agents do the diggingagenticarchitect

    Guarding the till while autonomous data agents do the digging

    Autonomous agents are genuinely good at answering messy business questions. Give one an LLM and a set...

    S
    Sireesha Pulipati
    Return on Attention: Why AI Code Reviews Are Wearing Us Outai

    Return on Attention: Why AI Code Reviews Are Wearing Us Out

    PR volume went up, ticket quality didn't, and the gap got filled with LLMs on both sides of the review: bots reviewing, bots replying, bots occasionally arguing with bots about priorities that only existed in a teammate's head. Our CEO named the actual problem, and it's bigger than code review.

    C
    christine

    Stay up to date

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

    Neura Market LogoNeura Market

    Discover the best AI prompts, plugins, and resources for Stable Diffusion 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.

    Ready-made automations for this

    Workflows from the Neura Market marketplace related to this Stable Diffusion resource

    • Upwork Opportunity Aggregator & AI-Powered Notifiern8n · $19.99 · Related topic
    • Narrative Threat & Opportunity Detection (AskRally)n8n · $24.99 · Related topic
    • Filter Gong Calls Synced to Salesforce by Opportunity Stagen8n · $19.99 · Related topic
    • Get Slack notifications when new WooCommerce product is publishedn8n · $8.99 · Related topic
    Browse all workflows