jnigen and swiftgen in 2026 - some lessons learned

9 minute read

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

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, ["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, 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:

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:

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

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

  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.