Skip to content

Flutter FFI — C/C++ Interop and Native Library Integration

DodaTech Updated 2026-06-28 9 min read

In this tutorial, you will learn about Flutter FFI. We cover key concepts, practical examples, and best practices to help you master this topic.

Flutter FFI (Foreign Function Interface) enables Dart code to call C and C++ functions directly from native libraries, providing maximum performance for CPU-intensive operations like cryptography, image processing, and audio decoding.

What Will You Learn

  • Setting up FFI in a Flutter project
  • Loading and binding native libraries
  • Defining Dart function signatures for C functions
  • Working with pointers, strings, and structs
  • Managing native memory allocation
  • Handling callbacks from C to Dart
  • Performance considerations

Why It Matters

Most Flutter apps can rely on Dart packages and plugins for platform integration. However, certain use cases demand maximum performance or integration with existing C/C++ libraries: OpenGL rendering, audio/video codecs, cryptography libraries (OpenSSL), computer vision (OpenCV), and game engines. FFI provides direct function call access without the overhead of MethodChannel. Unlike MethodChannel, FFI calls are synchronous and nearly zero-cost.

Real-World Use

The DodaTech Flutter app uses FFI to integrate a custom C library for fast audio waveform generation. The library processes audio samples 10x faster than a pure Dart implementation. FFI is also used for image EXIF data reading, where the platform-specific C library handles parsing without loading the full image into memory.

Learning Path

flowchart LR
  A[Native Channels] --> B[Flutter FFI\nYou are here]
  B --> C[Build Runner]
  style B fill:#f90,color:#fff

What is FFI?

FFI allows Dart to call C functions directly without going through platform channels:

// Native C function:
// double calculate_average(int* values, int count);

import 'dart:ffi';
import 'package:ffi/ffi.dart';

// Define Dart types
typedef CalculateAverageNative = Double Function(Pointer<Int32>, Int32);
typedef CalculateAverageDart = double Function(Pointer<Int32>, int);

The typedef pairs ensure type safety between Dart and C. The native typedef mirrors the C signature. The Dart typedef uses Dart types.

Loading a Native Library

Load a shared library and bind functions:

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

class NativeMath {
  static final NativeMath _instance = NativeMath._init();
  factory NativeMath() => _instance;
  NativeMath._init();

  late final DynamicLibrary _lib;
  late final CalculateAverageDart calculateAverage;

  void _init() {
    // Load platform-specific library
    if (Platform.isAndroid) {
      _lib = DynamicLibrary.open('libnative_math.so');
    } else if (Platform.isIOS) {
      // iOS uses static linking
      _lib = DynamicLibrary.process();
    } else if (Platform.isMacOS) {
      _lib = DynamicLibrary.open('libnative_math.dylib');
    } else if (Platform.isWindows) {
      _lib = DynamicLibrary.open('native_math.dll');
    } else {
      _lib = DynamicLibrary.open('libnative_math.so');
    }

    // Bind function
    calculateAverage = _lib
        .lookupFunction<CalculateAverageNative, CalculateAverageDart>(
          'calculate_average',
        );
  }

  double avg(List<int> values) {
    // Allocate native memory
    final ptr = calloc<Int32>(values.length);
    for (var i = 0; i < values.length; i++) {
      ptr[i] = values[i];
    }

    try {
      return calculateAverage(ptr, values.length);
    } finally {
      // Free native memory
      calloc.free(ptr);
    }
  }
}

Use DynamicLibrary.open() for dynamic linking. On iOS, use DynamicLibrary.process() for statically linked libraries. Always free native memory in finally blocks.

Working with Strings

C strings are null-terminated. Dart's ffi package provides conversion utilities:

import 'dart:ffi';
import 'package:ffi/ffi.dart';

// Native functions:
// char* greet_user(const char* name);
// void free_string(char* str);

typedef GreetUserNative = Pointer<Utf8> Function(Pointer<Utf8>);
typedef GreetUserDart = Pointer<Utf8> Function(Pointer<Utf8>);

typedef FreeStringNative = Void Function(Pointer<Utf8>);
typedef FreeStringDart = void Function(Pointer<Utf8>);

class StringNative {
  late final DynamicLibrary _lib;
  late final GreetUserDart _greetUser;
  late final FreeStringDart _freeString;

  String greet(String name) {
    // Convert Dart string to C string
    final namePtr = name.toNativeUtf8();
    final resultPtr = _greetUser(namePtr);

    try {
      // Convert C string to Dart string
      return resultPtr.toDartString();
    } finally {
      // Free both allocated strings
      calloc.free(namePtr);
      _freeString(resultPtr);
    }
  }
}

toNativeUtf8() allocates C memory for the string. toDartString() reads a C string into Dart. Always free allocated C strings to prevent memory leaks.

Working with Structs

Define Dart structs that mirror C structs:

// C struct
typedef struct {
    int id;
    double price;
    char name[64];
    bool in_stock;
} Product;

// C function
double calculate_discount(Product* product, double percent);
import 'dart:ffi';
import 'package:ffi/ffi.dart';

// Dart struct definition
class Product extends Struct {
  @Int32()
  external int id;

  @Double()
  external double price;

  @Array(64)
  external Array<Utf8> name;

  @Bool()
  external bool inStock;
}

typedef CalculateDiscountNative = Double Function(
  Pointer<Product>,
  Double,
);
typedef CalculateDiscountDart = double Function(
  Pointer<Product>,
  double,
);

class DiscountService {
  late final CalculateDiscountDart _calculateDiscount;

  double calculateDiscount(int id, double price, String name, bool inStock) {
    final productPtr = calloc<Product>();

    try {
      productPtr.ref.id = id;
      productPtr.ref.price = price;
      productPtr.ref.inStock = inStock;

      // Copy string to the fixed-length array
      final namePtr = name.toNativeUtf8();
      for (var i = 0; i < name.length && i < 63; i++) {
        productPtr.ref.name[i] = namePtr[i];
      }
      productPtr.ref.name[name.length < 63 ? name.length : 63] = 0; // null terminator
      calloc.free(namePtr);

      return _calculateDiscount(productPtr, 0.1);
    } finally {
      calloc.free(productPtr);
    }
  }
}

Struct subclasses map to C structs. Use @Int32(), @Double(), @Bool(), @Array() annotations for field types. Struct.ref provides typed field access.

Callbacks from C to Dart

C functions can call back into Dart:

// Native function that accepts a callback:
// void process_data(int* data, int count, void (*callback)(int));

// Define callback types
typedef ProgressCallbackNative = Void Function(Int32);
typedef ProgressCallbackDart = void Function(int);

typedef ProcessDataNative = Void Function(
  Pointer<Int32>,
  Int32,
  Pointer<NativeFunction<ProgressCallbackNative>>,
);
typedef ProcessDataDart = void Function(
  Pointer<Int32>,
  int,
  Pointer<NativeFunction<ProgressCallbackNative>>,
);

class DataProcessor {
  void process(List<int> data, void Function(int progress) onProgress) {
    final callback = Pointer.fromFunction<ProgressCallbackNative>(
      _progressCallback,
    );
    // Keep a reference to prevent garbage collection
    _keepAlive = callback;

    final dataPtr = calloc<Int32>(data.length);
    for (var i = 0; i < data.length; i++) {
      dataPtr[i] = data[i];
    }

    try {
      _processData(dataPtr, data.length, callback);
    } finally {
      calloc.free(dataPtr);
    }
  }

  // Static callback function
  static void _progressCallback(int value) {
    print('Progress: $value%');
  }

  // Prevent callback from being garbage collected
  Pointer<NativeFunction<ProgressCallbackNative>>? _keepAlive;
}

Pointer.fromFunction creates a function pointer from a Dart static function. The callback must be a top-level or static function. Keep a reference to prevent Garbage Collection.

Memory Allocation

Manage native memory with calloc and malloc:

import 'package:ffi/ffi.dart';

class MemoryManager {
  // Allocate typed memory
  Pointer<Int32> allocateIntArray(int length) {
    return calloc<Int32>(length);
  }

  // Allocate and zero memory
  Pointer<Uint8> allocateBuffer(int size) {
    return calloc<Uint8>(size);
  }

  // Free memory
  void freePointer(Pointer pointer) {
    calloc.free(pointer);
  }

  // Work with struct arrays
  Pointer<Point> allocatePoints(int count) {
    return calloc<Point>(count);
  }

  void freePoints(Pointer<Point> points, int count) {
    // Free any string fields before freeing the array
    for (var i = 0; i < count; i++) {
      // points[i].name is a field that may have been allocated separately
    }
    calloc.free(points);
  }
}

class Point extends Struct {
  @Double()
  external double x;

  @Double()
  external double y;
}

calloc allocates zero-initialized memory. malloc allocates uninitialized memory. Prefer calloc for safety. Always free memory to prevent leaks.

Performance Considerations

FFI is faster than MethodChannel but has considerations:

class PerformanceBenchmark {
  // FFI call overhead is ~50ns
  // MethodChannel overhead is ~100us (2000x slower)

  void benchmark() {
    final start = DateTime.now();
    for (var i = 0; i < 10000; i++) {
      // FFI call
      _nativeAdd(1, 2);
    }
    print('FFI: ${DateTime.now().difference(start).inMilliseconds}ms');

    // For comparison, same operation in Dart
    final start2 = DateTime.now();
    for (var i = 0; i < 10000; i++) {
      _dartAdd(1, 2);
    }
    print('Dart: ${DateTime.now().difference(start2).inMicroseconds}us');
  }

  int _dartAdd(int a, int b) => a + b;
}

FFI is ideal for: heavy computation (image filters, audio processing), data Serialization (Protocol Buffers), and cryptographic operations. For simple operations, native Dart is faster due to FFI overhead.

Error Handling

Handle C errors gracefully:

class SafeFfiCall {
  // C function returns error code, output via pointer
  // int open_file(const char* path, FileHandle* handle);

  FileHandle? openFile(String path) {
    final pathPtr = path.toNativeUtf8();
    final handlePtr = calloc<FileHandle>();

    try {
      final errorCode = _openFile(pathPtr, handlePtr);

      if (errorCode != 0) {
        print('C error code: $errorCode');
        return null;
      }

      return handlePtr.ref;
    } finally {
      calloc.free(pathPtr);
      // Don't free handlePtr on success; caller must close
      if (handlePtr.ref.id == 0) {
        calloc.free(handlePtr);
      }
    }
  }

  int _openFile(Pointer<Utf8> path, Pointer<FileHandle> handle) {
    // Simulate FFI call
    return -1; // Error
  }
}

class FileHandle extends Struct {
  @Int32()
  external int id;

  @Int64()
  external int size;
}

C functions typically return error codes. Validate return values and manage ownership carefully. Document whether the caller or callee owns allocated memory.

Common Mistakes

  1. Memory leaks from unfreed allocations: Every calloc, malloc, and toNativeUtf8 call must have a corresponding free. Use try/finally blocks.

  2. Incorrect struct alignment: C structs have alignment requirements. Dart's Struct handles this automatically, but verify with @Packed() if needed.

  3. Calling FFI from the main isolate for long operations: FFI calls block the calling isolate. For long operations, use Isolate.run to offload to a background isolate.

  4. Not handling null terminators: C strings must be null-terminated. Dart strings from FFI may include garbage after the null byte if the buffer is reused.

  5. Platform-specific library paths: Library file names differ per platform (lib.so, .dylib, .dll). Use Platform.isAndroid, Platform.isIOS, etc. to load the correct file.

Practice Questions

  1. How does FFI differ from MethodChannel in terms of performance and use cases?
  2. Why must C strings be converted using toNativeUtf8() and toDartString()?
  3. What is the purpose of Pointer.fromFunction()?
  4. How do you prevent memory leaks when using FFI?
  5. Challenge: Create a Flutter app that uses FFI to call a custom C library that calculates the SHA-256 hash of a string. The C library should be loaded dynamically, the string passed as a pointer, and the hash returned as a hex string.

Mini Project

Build an image filter app using FFI:

  • Write a C library with functions: grayscale, blur, sepia, brightness
  • Load the library with DynamicLibrary
  • Pass image pixel data as Uint8List to C functions
  • C functions process pixels and return modified data
  • Display the original and filtered images in Flutter
  • Benchmark FFI vs Dart-native implementations
  • Handle memory cleanup properly

FAQ

Can I use C++ classes with FFI?

FFI supports only C functions. To use C++ classes, create C wrapper functions that expose the class methods. Use opaque pointers to pass object references.

How do I link a static library on iOS?

Add the static library to Xcode project. Use DynamicLibrary.process() to access symbols linked into the main binary. iOS does not support dynamic library loading.

Is FFI safe to use?

FFI is memory-unsafe by nature. Incorrect pointer arithmetic, buffer overflows, or use-after-free can crash the app. Test thoroughly and validate all inputs before passing to C code.

Can I use FFI with Flutter web?

No. FFI is not available on the web platform. Use JS interop (dart:js) or WASM for web-based native code integration.

How do I debug native crashes?

Use Android Studio's LLDB debugger for Android and Xcode's debugger for iOS. Enable crash reporting with Firebase Crashlytics with native crash support.

What is Next

Now that you understand FFI, learn about Code Generation tools. Proceed to Flutter Build Runner for generating code with json_serializable, freezed, and retrofit. Then explore Flutter Packages for publishing and consuming packages.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro