jnigen and jni_util: Calling Java and Kotlin Code from Dart
How Dart-to-JVM interop actually works: what jnigen generates, what package:jni and jni_util provide at runtime, how to bind Java and Kotlin APIs step by step, and when this beats platform channels.
Published on • October 11, 2026
AI Assistant

Your team has a battle-tested Android library — say, an OCR engine or a billing client — written in Java or Kotlin, and a Flutter app that needs it. The plugin route means writing a channel, a method dispatcher, and serialization glue for every call, then maintaining it forever. There is another way: generate typed Dart bindings straight from the JVM bytecode and call the Java API as if it were a Dart API. That is what package:jnigen does, with package:jni running underneath and package:jni_util sharing plumbing between the two.
The catch is that this is a real FFI boundary, not a sandbox. Calls cross from the Dart heap into the JVM, references have lifecycles, exceptions can come from either side, and the whole mechanism is Android-only in Flutter today. This post walks the full path — architecture, setup, generation, runtime behavior — so you can decide where it fits before you commit.
In this tutorial, you will learn how to:
- Explain how Dart calls into the JVM via
dart:ffi, and why JNI is not the same as plain C interop - Configure the programmatic
JniGeneratorAPI and generate bindings withdart run tool/jnigen.dart - Bind your own Java code and Gradle library classes, including Kotlin sources compiled to bytecode
- Understand what the
package:jniruntime provides:Jni.spawn,GlobalJniEnv,JObject, and the collection wrappers - Handle JVM exceptions, global-reference lifetime, and manual release in hot loops
- Choose between JNI,
MethodChannel/Pigeon, and plugins for a given Android API - Avoid the classic failure modes: ProGuard pruning, missing classes, and isolate traps
Key technologies: package:jnigen (1.0.x, generator), package:jni (1.x, runtime support library), package:jni_util (shared utilities), dart:ffi, Android Gradle, JDK 17–21.
Prerequisites
- A Flutter project targeting Android (the
in_app_javaexample in the jnigen repo is a good scaffold) - Android SDK and a JDK; jnigen officially supports Java 17 through 21, and uses the JDK bundled with Flutter when available
- CMake and a C toolchain, because
package:jniships native code - On Windows, the directory containing
jvm.dllonPATH(typically%JAVA_HOME%\bin\server) so the JVM can be loaded
How the interop actually works
dart:ffi lets Dart call C functions and manipulate C memory. The JVM does not expose a C API — it exposes the Java Native Interface, a C ABI with an opaque JNIEnv vtable full of function pointers like CallVoidMethod and FindClass. So the stack looks like this:
Your Dart code
-> generated bindings (package:jnigen output)
-> package:jni helpers (typed wrappers, global refs)
-> dart:ffi calls into the JNI C ABI
-> JVM (Android runtime, or a spawned JVM on desktop)
JNIgen scans compiled JAR files or Java source code, builds an API description, and emits Dart bindings. Those bindings call through the C bindings, which call Java through JNI. The support library package:jni provides the base classes and common Java types the generated code depends on; package:jni_util is the shared utility layer both jni and jnigen build on (its pub.dev description is literally “Shared utility functions for package:jni and package:jnigen”, and it depends only on path).
Two properties of this design matter in practice:
- Calls are synchronous. Unlike a
MethodChannel, a JNI call blocks the calling Dart isolate until Java returns. On Android this happens on the UI thread if you call it there. - Objects cross as global references. Every Java object returned into Dart becomes a JNI global reference held by a
JObjectwrapper, cleaned up by aNativeFinalizerunless you release it sooner.
Setting up the project
Add the runtime and the generator:
flutter pub get # at least once: jnigen reads Gradle classpaths from your project
flutter pub add jni dev:jnigen
Write the Java (or Kotlin) you want to call inside the android/ subproject. The jnigen getting-started example places a helper at android/app/src/main/java/com/example/in_app_java/AndroidUtils.java:
package com.example.in_app_java;
import android.app.Activity;
import android.widget.Toast;
import androidx.annotation.Keep;
@Keep
public abstract class AndroidUtils {
private AndroidUtils() {}
public static void showToast(Activity mainActivity, CharSequence text, int duration) {
mainActivity.runOnUiThread(() -> Toast.makeText(mainActivity, text, duration).show());
}
}
The @Keep annotation is not decoration. R8/ProGuard prunes code it believes is unreachable, and JNI class lookup happens at runtime, so the optimizer cannot see the reference. Without @Keep (or an equivalent keep rule), release builds fail with ClassNotFoundException even though debug builds work.
Configuring the generator
The YAML configuration format is deprecated and scheduled for removal. The supported path is a programmatic Dart script, conventionally at tool/jnigen.dart:
import 'dart:io';
import 'package:jnigen/jnigen.dart';
void main(List<String> args) async {
final packageRoot = Platform.script.resolve('../');
final generator = JniGenerator(
input: Input(
classes: [
'com.example.in_app_java',
'androidx.emoji2.text.EmojiCompat',
'android.os.Build',
],
sourcePath: [packageRoot.resolve('android/app/src/main/java')],
androidSdk: AndroidSdk(
addGradleDeps: true,
androidExample: packageRoot,
),
),
output: Output(
dart: DartOutput(
path: packageRoot.resolve('lib/android_utils.g.dart'),
structure: OutputStructure.singleFile,
),
),
);
await generator.generate();
}
Run it whenever the configuration or the Java sources change:
dart run tool/jnigen.dart
Notes on the configuration:
classestakes fully-qualified class or package names. Many corejava.langandjava.utiltypes ship withpackage:jnialready; listing them again fails withFatal: Trying to re-import the generated classes.AndroidSdk(addGradleDeps: true)runs a Gradle stub so JAR dependencies from yourbuild.gradleare on the classpath during analysis.OutputStructure.singleFilewrites one Dart file; the default writes one file per class.
Calling the bindings from Dart
The generated API mirrors the Java surface. From the jnigen example app:
import 'package:flutter/widgets.dart';
void showToast() {
final activity =
androidActivity(PlatformDispatcher.instance.engineId!)?.as(Activity.type);
final message = 'This is a native toast shown from a Flutter app via JNI.';
AndroidUtils.showToast(
activity,
message.toJString().as(CharSequence.type),
0,
);
}
Three idioms are worth internalizing:
'string'.toJString()(frompackage:jni’sToJStringMethodextension) creates aJString, the wrapper forjava.lang.String..as(Activity.type)casts a genericJObjectto a generated subclass using the class’sJTypetoken — this is how the type system recovers Java’s nominal typing.- Nested Java classes map to Dart classes with
_as separator:Example.NestedClassbecomesExample_NestedClass.
Kotlin support
jnigen generates bindings for Java and Kotlin. Kotlin compiles to JVM bytecode, so the generator consumes Kotlin classes the same way it consumes Java ones: point Input.classes at the compiled output (or include Kotlin sources via the classpath) and generate. Anything not visible in the bytecode signature — Kotlin default arguments, suspend functions, extension functions, value classes — needs a Java-friendly facade: @JvmStatic, @JvmOverloads, or a thin Java wrapper. The dart.dev interop guide states this plainly: “You can compile Kotlin to Java bytecode, allowing package:jnigen to generate bindings for Kotlin as well.”
The runtime: what package:jni gives you
Generated bindings are not self-contained. package:jni supplies:
Jniutilities —Jni.spawn()starts a JVM on desktop and standalone targets (on Android the app already runs inside the Android JVM).Jni.envexposes aGlobalJniEnv, a thread-safe thin wrapper overJNIEnv*that always returns global references, because Dart makes no guarantee that straight-line code stays on one thread.JObject— the base class for every generated binding; a high-level wrapper over a JNI global object reference.- Standard library types —
JString,JList,JMap,JSet,JInteger,JArrayList, array types likeJIntArray, plus converters such astoDartObject/toJObjectandJListToDartList. JImplementer— a builder for proxy objects that implement Java interfaces from Dart, which is how you hand callbacks across the boundary.- Manual-access API —
JClass.forName,instanceMethodId,JInstanceMethodId, and friends for one-off calls without generated bindings.
On Dart standalone (non-Flutter) targets there is no automatic bundling of native libraries. The stopgap is:
dart run jni:setup # builds JNI native deps into build/jni_libs
then Jni.spawn() at startup, and Jni.setDylibDir again for every new isolate.
Exceptions, lifetime, and the GC boundary
Exceptions. Java exceptions do not vanish at the boundary. package:jni defines a family of Dart errors for JNI failures — JniError, JniGenericError, NoSuchMethodError, JNullError, JniOutOfMemoryError, JniThreadDetachedError, UseAfterReleaseError, DoubleReleaseError — and JThrowable wraps a java.lang.Throwable. Treat generated calls like any FFI: catch narrowly, and remember that an uncaught Java exception surfaces as a Dart error with a different stack shape than you are used to. On desktop, Jni.env.ExceptionDescribe() prints a pending JNI exception to stdout when you are debugging something opaque; on Android, enable CheckJNI for clearer diagnostics.
Lifetime. Each Java object returned to Dart creates a JNI global reference. The docs are explicit that NativeFinalizer deletion is “usually sufficient.” When you create many references — the FAQ’s example is a loop — release eagerly instead:
using((arena) {
// Allocations inside are freed when the arena scope exits.
}, arena);
// Or release a single reference explicitly:
// obj.release();
UseAfterReleaseError and DoubleReleaseError exist precisely because manual management goes wrong; they turn a silent JVM crash into a catchable Dart error.
The general rule is the one the jnigen FAQ states: keep the interface between languages sparse. Batch data across the boundary; do not call Java once per list item from a Dart for loop.
Platform support and caveats
| Platform | Dart standalone | Flutter |
|---|---|---|
| Android | n/a | Supported (app runs in the Android JVM) |
| Linux | Supported | Supported |
| Windows | Supported | Supported |
| macOS | Supported | Not yet |
Practical caveats that bite real projects:
- Android-only in Flutter for now. If you need the same native code on iOS, JNI is not the bridge; you would need a separate Objective-C/Swift interop path.
- Missing classes at runtime. JNIgen does not get classes onto the device. A Gradle dependency does that on Android; on desktop you pass the classpath to
Jni.spawn. Libraries that depend onjava.awtand other desktop-only namespaces simply do not exist on Android. - Android SDK stubs. The
android.**namespace is not distributed through Gradle. SDK stub JARs (android.jar) are usable but post-API-28 stubs are incomplete for source parsing, and compiled JARs lack JavaDoc and parameter names. - Standalone uses the Flutter SDK’s
dart. Due to a pubspec issue, thedartcommand must come from the Flutter SDK even for pure Dart projects.
JNI vs platform channels vs plugins
Flutter’s own guidance on calling Android APIs offers two answers: FFI (with ffigen or jnigen) and MethodChannel (typically with Pigeon). The tradeoffs:
| Concern | jnigen/JNI | MethodChannel + Pigeon |
|---|---|---|
| Call style | Synchronous, direct | Always asynchronous |
| Typing | Generated Dart classes | Generated message codecs |
| Overhead per call | One FFI trampoline | Channel hop + serialization |
| Sharing code | Android only (Flutter) | Android, iOS, desktop via same channel API |
| Best for | Dense calls into large Java/Kotlin APIs | Sparse calls, platform UI, iOS parity |
Use JNI when you are pulling a substantial Java or Kotlin API surface into an Android-first app and call it frequently enough that channel latency and serialization matter. Use Pigeon when the call is occasional, you want multi-platform symmetry, or you need platform UI (dialogs, permission flows). Plugins remain the easiest path whenever a maintained package already exists on pub.dev.
Performance notes
- Cross-boundary calls are cheap relative to channels for chatty APIs — there is no codec — but they still leave Dart and enter the JVM. Amortize with batched methods.
- String conversion (
toJString,JString.toDartString) copies; avoid it in inner loops over large payloads. - Because JNI calls are synchronous, long-running Java work blocks the UI thread if called from it. Push heavy work onto a Java executor or a Dart isolate and marshal results back.
- Keep the boundary surface narrow: a handful of coarse methods beats hundreds of generated one-liners called from UI code.
Common pitfalls
ClassNotFoundExceptiononly in release — add@Keepor ProGuard keep rules for every class reached via JNI. Thein_app_javaexample ships aproguard-rules.profor external libraries.- Trying to re-import core classes — do not list
java.lang.Stringand friends inclasses;package:jnialready provides them. NoClassDefFound/NoSuchMethodFoundon desktop — almost always a typo in the class or method signature, or a missing classpath entry forJni.spawn.- Calling from a non-main isolate — each new isolate needs
Jni.setDylibDir(standalone) and a correctly attached thread; test multi-isolate paths explicitly. - Forgetting to regenerate — a stale
*.g.dartafter a Java signature change produces confusingNoSuchMethodErrors. Regenerate in CI if the Java side changes often.
Further reading
- jni_util on pub.dev — shared utilities for
jniandjnigen - jnigen on pub.dev — generator documentation, getting-started guide, FAQ
- jni on pub.dev — runtime support library
- C interop using dart:ffi — the FFI foundation JNI is built on
- Java interop using package:jnigen — Dart.dev’s end-to-end guide
- Calling JetPack APIs — Flutter’s framing of FFI vs MethodChannel
- in_app_java example — full working Flutter app with ProGuard rules