While Flutter’s Method Channels are the standard way to communicate with platform-native code, they aren’t always the most efficient or ergonomic solution. Especially when dealing with existing native libraries or synchronous operations.

In this post, we will explore how to call native code directly using Dart FFI (for C/iOS) and JNI (for Kotlin/Android). We will use code generators (ffigen and jnigen) to automate the binding process, and then wrap them in a clean, platform-agnostic Dart interface.

To demonstrate this, we will implement a simple “Caesar Cipher” encryption utility.

1. The Native Implementations

First, let’s look at the native code we want to access.

Android (Kotlin)

On the Android side, we have a simple class EncryptionUtil that performs character shifting.

// android/src/main/kotlin/com/example/dart_native_bindings/EncryptionUtil.kt
class EncryptionUtil {
    fun encrypt(input: String, shift: Int): String {
        val result = StringBuilder()
        for (character in input.toCharArray()) {
            if (character != ' ') {
                val originalAlphabetPosition = character.code - 'a'.code
                val newAlphabetPosition = (originalAlphabetPosition + shift) % 26
                val newCharacter = ('a'.code + newAlphabetPosition).toChar()
                result.append(newCharacter)
            } else {
                result.append(character)
            }
        }
        return result.toString()
    }
}

iOS (C)

For iOS, we are using C to interact with Dart FFI. Note that this function manually allocates memory for the result, which we will need to manage carefully in Dart.

// ios/Classes/encryption.c
#include <string.h>
#include <stdlib.h>

char* encrypt(char* input, int shift) {
    int length = strlen(input);
    char* result = (char*)malloc(length + 1); 
    
    for(int i = 0; i < length; i++) {
        char ch = input[i];
        if(ch >= 'a' && ch <= 'z'){
            ch = ch + shift;
            if(ch > 'z'){
                ch = ch - 26;
            }
            result[i] = ch;
        } else {
            result[i] = ch;
        }
    }
    result[length] = '\0'; // Null terminator
    return result;
}

2. Generating Bindings

Writing the Dart boilerplate to talk to these languages manually is error-prone. We use ffigen and jnigen to generate the bindings automatically.

Configuring ffigen (iOS)

ffigen scans C header files and creates Dart FFI bindings.

ffigen.yaml

headers:
  entry-points:
    - 'ios/Classes/*.c'
output: 'lib/ios_bindings.dart'

Configuring jnigen (Android)

jnigen works similarly but for Java/Kotlin classes via JNI.

jnigen.yaml

android_sdk_config:
  add_gradle_deps: true

output:
  dart:
    path: lib/android_bindings.dart
    structure: single_file

source_path:
  - "android/src/main/kotlin"
classes:
  - "com.example.dart_native_bindings.EncryptionUtil"

Once configured, running the generation commands (e.g., dart run ffigen --config ffigen.yaml and dart run jnigen --config jnigen.yaml) will produce ios_bindings.dart and android_bindings.dart.


3. The Unified Dart Interface

Now for the fun part: abstracting away the platform differences. We want our Flutter UI to simply call encrypt() without worrying about whether it’s running on Android or iOS.

We will create a CommonEncryptor class that employs the Strategy pattern.

The Code

import 'dart:ffi' as ffi;
import 'dart:io';

import 'package:dart_native_bindings/android_bindings.dart';
import 'package:dart_native_bindings/ios_bindings.dart';
import 'package:ffi/ffi.dart';
import 'package:jni/jni.dart';

/// Provides a single encryption API regardless of platform.
class CommonEncryptor {
  CommonEncryptor() : _delegate = _createDelegate();

  final _PlatformEncryptor _delegate;

  String encrypt(String input, int shift) => _delegate.encrypt(input, shift);

  static _PlatformEncryptor _createDelegate() {
    if (Platform.isAndroid) {
      return _AndroidEncryptor();
    }
    if (Platform.isIOS) {
      return _IosEncryptor();
    }
    throw UnsupportedError('Unsupported platform');
  }
}

abstract interface class _PlatformEncryptor {
  String encrypt(String input, int shift);
}

The Android Implementation (JNI)

Here we interact with the generated EncryptionUtil class.

Key Note on Memory: When using JNI, we must convert Dart strings to JNI strings (toJString()). After the operation, it is crucial to call .release() on JNI objects to prevent memory leaks in the Java Native Interface.

class _AndroidEncryptor implements _PlatformEncryptor {
  _AndroidEncryptor() : _util = EncryptionUtil();

  final EncryptionUtil _util;

  @override
  String encrypt(String input, int shift) {
    // 1. Convert Dart String to Java String
    final inputJString = input.toJString();
    
    // 2. Call the native method
    final encrypted = _util.encrypt(inputJString, shift);
    
    // 3. Convert back to Dart String
    final result = encrypted.toDartString();
    
    // 4. Clean up JNI references
    encrypted.release();
    inputJString.release();
    
    return result;
  }
}

The iOS Implementation (FFI)

Here we use the NativeLibrary generated by ffigen.

Key Note on Memory: Since our C code used malloc to allocate the result, we must use malloc.free in Dart to release that memory. Similarly, we allocate memory for the input string using toNativeUtf8(), which also requires freeing.

class _IosEncryptor implements _PlatformEncryptor {
  // Load the process symbol table
  _IosEncryptor() : _library = NativeLibrary(ffi.DynamicLibrary.process());

  final NativeLibrary _library;

  @override
  String encrypt(String input, int shift) {
    // 1. Allocate native memory for the string
    final inputPtr = input.toNativeUtf8();
    
    // 2. Call the C function
    final encryptedPtr = _library.encrypt(inputPtr.cast<ffi.Char>(), shift);
    
    // 3. Free the input pointer immediately
    malloc.free(inputPtr);
    
    // 4. Convert result to Dart string
    final result = encryptedPtr.cast<Utf8>().toDartString();
    
    // 5. Free the result pointer (allocated by C's malloc)
    malloc.free(encryptedPtr);
    
    return result;
  }
}

Conclusion

By combining jnigen and ffigen, we can leverage the full power of native platforms with strictly typed bindings. While manual memory management (releasing pointers and JNI references) requires care, the performance gains and direct access to native libraries make it a powerful tool in the Flutter developer’s arsenal.

Share: