
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.
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:
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:
Quick recap:
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',
],
));
}
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);
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);
To cast JObject onto a desired type, use as() with the generated type:
final brightnessMonitor = native.getBrightnessMonitor();
final brightness = brightnessMonitor.as(ScreenBrightnessMonitor.type).brightness;
It's a bit cumbersome, but once you know, you know:
final array = JArray.of<JString>(JString.type, ["[email protected]".toJString()]);
$async: true FlagI 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.
@KeepProGuard/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) { /* ... */ }
}
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
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 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.
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 SwiftFileInputSwiftFileInput: 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.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:
NSObject (direct or indirect).@objc public on everything ffigen should see.init() requires override + calling super.init().Int, String, Bool, NSObject subclasses, protocols. No Swift structs, enums with associated values, or generics.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.
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);
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?
swiftgen produces two files:
lib/src/..._ios.g.dart) -- extension types wrapping ObjC objectsios/Classes/....m) -- C functions that ffigen's Dart code calls via dart:ffiBoth must be committed. The .m file must be in a location picked up by the podspec (Classes/**/*).
Swift func startObserving(callback:) becomes startObservingWithCallback: in ObjC (and thus in the Dart binding). Check the generated .g.dart for actual method names.
source_filesMust include both the Swift source and the generated .m file. Classes/**/* covers both.
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.
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.
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;
}
| 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) |
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.
aiMost of us have seen a coding agent fail to complete a task we know it can do. We just don't...
googlecloudWhen building Generative AI applications, developers often encounter a massive bottleneck: sequential...
discussI’ve been thinking about sharing some electronic circuit posts on Dev.to — small circuits, DIY...
agentsWhat nobody tells you about exporting your multi-agent prototype to a local workspace. Every...
agenticarchitectAutonomous agents are genuinely good at answering messy business questions. Give one an LLM and a set...
aiPR 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.
Workflows from the Neura Market marketplace related to this Stable Diffusion resource