Flutter FFI — C/C++ Interop and Native Library Integration
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
Memory leaks from unfreed allocations: Every
calloc,malloc, andtoNativeUtf8call must have a correspondingfree. Usetry/finallyblocks.Incorrect struct alignment: C structs have alignment requirements. Dart's
Structhandles this automatically, but verify with@Packed()if needed.Calling FFI from the main isolate for long operations: FFI calls block the calling isolate. For long operations, use
Isolate.runto offload to a background isolate.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.
Platform-specific library paths: Library file names differ per platform (
lib.so,.dylib,.dll). UsePlatform.isAndroid,Platform.isIOS, etc. to load the correct file.
Practice Questions
- How does FFI differ from MethodChannel in terms of performance and use cases?
- Why must C strings be converted using
toNativeUtf8()andtoDartString()? - What is the purpose of
Pointer.fromFunction()? - How do you prevent memory leaks when using FFI?
- 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
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