HomeDocumentationc_004_flutter_enterprise
c_004_flutter_enterprise
10

Flutter & C++: A Practical Guide to Dart FFI

Flutter & Dart FFI: Integrating C++ Libraries Into Your Flutter App

March 18, 2026

The Problem FFI Solves

A medical device company has a C++ signal processing library. It took four years to write and eighteen months to validate against regulatory requirements. It processes sensor data and produces diagnostics. They want to build a Flutter app that uses it.

Rewriting the library in Dart is not an option — not because it's impossible, but because the validated, certified version is in C++. Rewriting it means revalidating it. That's years of work and regulatory cost. The library works. It needs to run in Flutter.

That's FFI's real use case in enterprise. Not "we need more performance" — though that's also valid — but "we have existing native code that Flutter needs to talk to."

The performance case is real too. Dart runs fast for most things, but it's not C++. For operations that run thousands of times per second — filtering camera frames, processing audio buffers, running physics simulations — the gap matters. A real-time camera filter that takes 40ms per frame in Dart takes 2ms in C++. That's the difference between shipping the feature and removing it.

What FFI Actually Does

FFI creates a bridge between Dart's managed memory world and C's unmanaged memory world. Through it, Dart can:

  • Call C functions by name
  • Pass primitive values (integers, floats, booleans) directly
  • Pass and receive pointers to memory
  • Read and write C structs
  • Manage native memory manually (allocate, use, free)

The key word is "manually." Dart's garbage collector manages Dart objects automatically. It knows nothing about memory you allocate on the C side. If you allocate native memory and forget to free it, it leaks — permanently, until the app exits. FFI is the one place in Flutter development where you're responsible for memory yourself.

C++ and the `extern "C"` Problem

FFI speaks C, not C++. This matters because C++ does something called name mangling — it encodes type information into function names to support overloading. A C++ function named encrypt might be compiled into a symbol called _Z7encryptPKci. Dart can't look up _Z7encryptPKci. It doesn't know that's encrypt.

The solution is extern "C". Any C++ function you want to call from Dart must be wrapped in an extern "C" block, which tells the C++ compiler to use plain C naming for those functions:

cpp
// encryption.cpp
#include <cstring>
#include <cstdint>

// Internal C++ implementation — uses C++ freely
namespace crypto {
  class AESCipher {
  public:
    static int32_t encrypt(const uint8_t* input, int32_t length, uint8_t* output);
  };
}

// Public C API — what Dart can see
extern "C" {
  int32_t aes_encrypt(const uint8_t* input, int32_t input_length, uint8_t* output) {
    return crypto::AESCipher::encrypt(input, input_length, output);
  }

  int32_t aes_decrypt(const uint8_t* input, int32_t input_length, uint8_t* output) {
    return crypto::AESCipher::decrypt(input, input_length, output);
  }
}

The extern "C" block is the contract. Everything inside it is callable from Dart. Everything outside it is C++ internal detail that Dart never sees.

A Simple Example First

Before the real use case, the simplest possible FFI call — adding two numbers — to show the mechanics clearly.

The C function:

c
// native/add.c
#include <stdint.h>

int32_t add(int32_t a, int32_t b) {
    return a + b;
}

The Dart bindings:

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

// Step 1: Define the C function signature in native types
typedef AddNative = Int32 Function(Int32 a, Int32 b);

// Step 2: Define the equivalent Dart function signature
typedef AddDart = int Function(int a, int b);

void main() {
  // Step 3: Load the compiled library
  final lib = Platform.isAndroid
      ? DynamicLibrary.open('libnative.so')
      : DynamicLibrary.process(); // iOS links statically

  // Step 4: Look up the function
  final add = lib.lookupFunction<AddNative, AddDart>('add');

  // Step 5: Call it like any Dart function
  print(add(3, 4)); // 7
}

Two type definitions for every function: the native signature (using FFI types like Int32, Float, Pointer<T>) and the Dart signature (using normal Dart types). Dart needs both to handle the type conversion automatically.

This is the pattern for every FFI call. The ceremony varies with complexity but the structure is always the same: define types, load library, look up function, call it.

Memory Management: The Sharp Edge

Strings and complex data can't cross the FFI boundary as-is. You need to move them into native memory first.

dart
import 'dart:ffi';
import 'package:ffi/ffi.dart'; // The ffi package provides allocators and helpers

// C function that takes a string and returns its length
typedef StringLengthNative = Int32 Function(Pointer<Utf8> str);
typedef StringLengthDart = int Function(Pointer<Utf8> str);

void example() {
  final lib = DynamicLibrary.process();
  final stringLength = lib.lookupFunction<StringLengthNative, StringLengthDart>(
    'string_length',
  );

  // Convert Dart String to native UTF-8 pointer
  final nativeString = 'Hello, World!'.toNativeUtf8();

  try {
    final length = stringLength(nativeString);
    print('Length: $length');
  } finally {
    // ⚠️ Always free native memory — the GC won't do this for you
    calloc.free(nativeString);
  }
}

The try/finally pattern is not optional. If stringLength throws and calloc.free never runs, that memory is gone until the app exits. For a function that runs a few times it doesn't matter. For a function that runs on every camera frame, it crashes the app.

For production code where allocations outlive a single function — stored in a class field, passed between methods — try/finally alone isn't enough. Dart 2.17+ provides NativeFinalizer, which binds a C cleanup function to a Dart object's lifecycle: if dispose() is never called, the GC runs the cleanup automatically when the object becomes unreachable. It's a safety net, not a replacement for explicit disposal. The series post on production FFI patterns covers the full pattern.

For larger data — byte arrays, buffers — the same principle applies:

dart
void processImageBuffer(Uint8List imageData) {
  // Allocate native buffer
  final nativeBuffer = calloc<Uint8>(imageData.length);

  try {
    // Copy Dart data into native memory
    nativeBuffer.asTypedList(imageData.length).setAll(0, imageData);

    // Call the C++ function
    _processImage(nativeBuffer, imageData.length);

    // Read results back
    final result = nativeBuffer.asTypedList(imageData.length);
    // use result...
  } finally {
    calloc.free(nativeBuffer);
  }
}

Structs: Mapping C Types to Dart

C structures — struct — have a direct Dart equivalent in the FFI API:

cpp
// C++ side
extern "C" {
  struct ImageDimensions {
    int32_t width;
    int32_t height;
    int32_t channels;
  };

  ImageDimensions get_image_info(const uint8_t* data, int32_t length);
}
dart
// Dart side — mirrors the C struct exactly
final class ImageDimensions extends Struct {
  @Int32()
  external int width;

  @Int32()
  external int height;

  @Int32()
  external int channels;
}

typedef GetImageInfoNative = ImageDimensions Function(
  Pointer<Uint8> data,
  Int32 length,
);
typedef GetImageInfoDart = ImageDimensions Function(
  Pointer<Uint8> data,
  int length,
);

Field order and types must match exactly. If the C struct has padding, the Dart struct needs to account for it. Mismatches here produce subtle data corruption rather than clear errors — one of the more frustrating categories of FFI bugs.

A Real Use Case: Image Processing

Here's a complete example of using a C++ image processing function from Flutter — the kind of thing that actually motivates FFI in production.

The C++ library (simplified):

cpp
// native/image_processor.cpp
#include <cstdint>
#include <cmath>

extern "C" {

// Apply a greyscale filter to an RGBA image buffer in-place
void apply_greyscale(uint8_t* pixels, int32_t width, int32_t height) {
    int32_t total_pixels = width * height;

    for (int32_t i = 0; i < total_pixels; i++) {
        int32_t offset = i * 4; // RGBA — 4 bytes per pixel
        uint8_t r = pixels[offset];
        uint8_t g = pixels[offset + 1];
        uint8_t b = pixels[offset + 2];
        // Luminance formula
        uint8_t grey = (uint8_t)(0.299 * r + 0.587 * g + 0.114 * b);
        pixels[offset] = grey;
        pixels[offset + 1] = grey;
        pixels[offset + 2] = grey;
        // Alpha unchanged
    }
}

} // extern "C"

The Dart service that wraps it:

dart
import 'dart:ffi';
import 'dart:io';
import 'dart:typed_data';
import 'dart:isolate';
import 'package:ffi/ffi.dart';

typedef GreyscaleNative = Void Function(Pointer<Uint8> pixels, Int32 width, Int32 height);
typedef GreyscaleDart = void Function(Pointer<Uint8> pixels, int width, int height);

class ImageProcessor {
  static late final GreyscaleDart _applyGreyscale;

  static void initialize() {
    final lib = Platform.isAndroid
        ? DynamicLibrary.open('libimage_processor.so')
        : DynamicLibrary.process();

    // isLeaf: true removes VM safepoint overhead — safe here because
    // apply_greyscale is pure C with no Dart callbacks
    _applyGreyscale = lib.lookupFunction<GreyscaleNative, GreyscaleDart>(
      'apply_greyscale',
      isLeaf: true,
    );
  }

  // Run in an Isolate — this may take several milliseconds
  // and we don't want to block the UI thread.
  // Note: passing pixels to Isolate.run() copies the data into the new isolate.
  // For buffers above a few MB, use TransferableTypedData to avoid doubling
  // peak memory usage during the transfer.
  static Future<Uint8List> applyGreyscale(
    Uint8List pixels,
    int width,
    int height,
  ) async {
    return Isolate.run(() => _processSync(pixels, width, height));
  }

  static Uint8List _processSync(Uint8List pixels, int width, int height) {
    final nativeBuffer = calloc<Uint8>(pixels.length);
    try {
      nativeBuffer.asTypedList(pixels.length).setAll(0, pixels);
      _applyGreyscale(nativeBuffer, width, height);
      // Copy result back to Dart-managed memory before freeing native buffer
      return Uint8List.fromList(nativeBuffer.asTypedList(pixels.length));
    } finally {
      calloc.free(nativeBuffer);
    }
  }
}

Notice the Isolate.run() wrapping the synchronous call. The C++ function is fast, but at full camera resolution (3000×4000 pixels = 48MB of data) it still takes a few milliseconds. That belongs off the main thread — as covered in the performance article, anything over ~2ms of synchronous work risks dropping a frame.

ffigen: Stop Writing Bindings by Hand

Hand-writing Dart bindings for a large C++ library is tedious and error-prone. The ffigen tool generates them automatically from C header files.

Add it to pubspec.yaml:

yaml
dev_dependencies:
  ffigen: ^9.0.0

ffigen:
  output: 'lib/src/native_bindings.dart'
  headers:
    entry-points:
      - 'native/include/image_processor.h'
      - 'native/include/encryption.h'
  functions:
    include:
      - 'apply_greyscale'
      - 'aes_encrypt'
      - 'aes_decrypt'

Run dart run ffigen and it produces a complete Dart bindings file — all the typedefs, all the struct definitions, all the lookupFunction calls. For a library with dozens of functions, this saves hours and eliminates a class of copy-paste errors.

Building for Android and iOS

The C++ code needs to be compiled as part of the Flutter build. Each platform has its own mechanism.

Android uses CMake. In android/CMakeLists.txt:

cmake
cmake_minimum_required(VERSION 3.18.1)
project(native_lib)

add_library(
    image_processor      # Library name
    SHARED               # .so file
    ../native/image_processor.cpp
    ../native/encryption.cpp
)

target_include_directories(image_processor PRIVATE ../native/include)

# Link against standard C++ library
target_link_libraries(image_processor android log)

Tell Gradle about it in android/app/build.gradle:

groovy
android {
    defaultConfig {
        externalNativeBuild {
            cmake {
                cppFlags "-std=c++17"
            }
        }
    }
    externalNativeBuild {
        cmake {
            path "CMakeLists.txt"
        }
    }
}
Pro Tip
iOS has two distinct linking scenarios, and using the wrong one gives you a "Symbol not found" error at runtime.

If your C++ code is compiled directly into the app binary — meaning you've added the .cpp files to the Xcode target directly — the symbols end up in the main executable. DynamicLibrary.process() searches the main process symbol table and finds them.

If your native code is packaged as a Flutter plugin (compiled into a .framework bundle), the symbols are not in the global symbol table. DynamicLibrary.process() will fail. You must open the framework explicitly:

dart
// Code compiled into the app binary directly
final lib = DynamicLibrary.process();
dart
// Code inside a plugin .framework
final lib = DynamicLibrary.open('MyLibrary.framework/MyLibrary');

The practical rule: if this native code is only for your own app, compile it directly into the app binary and use process(). If it's going into a reusable plugin or framework, use open() with the framework path.

FFI vs Platform Channels: When to Use Which

FFI and platform channels both let Flutter talk to native code. They solve different problems.

Platform channels are the right choice when you need to call existing platform APIs — accessing the camera, reading contacts, using Bluetooth. The platform does the work and sends the result back over a message channel. You're not writing the implementation, you're calling something that already exists.

FFI is the right choice when you have the implementation — a C or C++ library — and you need Flutter to call it directly. No platform API is involved. You're bringing your own native code.

The practical rule: if a Flutter package already exists for what you need, use it. If you have C++ code you need to run, use FFI. Platform channels sit in between — when you need native platform capabilities that no package covers yet.

FFI vs Platform Channels: When to Use Which

FFI and platform channels both let Flutter talk to native code. They solve different problems.

  1. Platform channels are the right choice when you need to call existing platform APIs — accessing the camera, reading contacts, using Bluetooth. The platform does the work and sends the result back over a message channel. You're not writing the implementation, you're calling something that already exists.

Everything else is almost certainly solvable in Dart, with platform channels if you need OS APIs, or with packages from pub.dev. FFI adds native compilation to your build pipeline, two sets of code to maintain, and manual memory management to your mental model. Those costs are worth paying when the alternative is genuinely not workable. They're not worth paying to squeeze 5ms out of a list sort.

When to Actually Use This

FFI is the right tool in a narrow set of situations:

  1. Existing C/C++ library you need to reuse without rewriting
  2. Performance-critical computation where Dart profiling confirms it's the bottleneck (check this first — premature C++ is not the answer to slow code)
  3. Algorithms unavailable in Dart — specific codec implementations, hardware-adjacent math, proprietary signal processing
  4. Real-time media processing — camera filters, audio DSP, video decoding

Everything else is almost certainly solvable in Dart, with platform channels if you need OS APIs, or with packages from pub.dev. FFI adds native compilation to your build pipeline, two sets of code to maintain, and manual memory management to your mental model. Those costs are worth paying when the alternative is genuinely not workable. They're not worth paying to squeeze 5ms out of a list sort.

Related Topics

dart ffi flutterflutter c++ integrationdart ffi tutorialflutter native codeffigen dartflutter platform channels vs ffidart ffi memory managementflutter native library

Ready to build your app?

Turn your ideas into reality with our expert mobile app development services.