Dart FFI tutorial
last modified May 30, 2026
In this tutorial we show how to use the Dart Foreign Function Interface (FFI) to call native C code from Dart programs.
Introduction
The Foreign Function Interface (FFI) lets a Dart program call functions
written in C and access C data structures directly. The dart:ffi
library, which ships with the Dart SDK, provides the types and functions needed
to describe C signatures and load native libraries at runtime.
Dart already runs on a managed runtime, so FFI bridges the gap to the native world without requiring a separate plugin layer. You describe the C types in Dart, load the shared library, and call functions as if they were normal Dart functions.
Use FFI when you need to call an existing C library, access low-level OS APIs, or run performance-critical code that is already written in C. FFI does not require Flutter or platform channels; it works in pure Dart on desktop, server, Android, and iOS.
Platform channels are a Flutter concept and require the host application (iOS, Android) to act as a bridge. FFI bypasses that entirely. For a standalone Dart program or a server application, FFI is the natural choice for native interop.
Dart FFI is supported on Linux, macOS, Windows, Android, and iOS. The same Dart code compiles and runs on all platforms; only the path to the shared library differs per platform.
Setting Up FFI
The core FFI types live in dart:ffi, which is part of the standard
SDK. Higher-level utilities — memory allocators and string helpers — are in the
ffi package on pub.dev.
name: ffi_demo environment: sdk: '>=3.0.0 <4.0.0' dependencies: ffi: ^2.1.0
After adding the dependency, run dart pub get.
Import both libraries at the top of every file that uses FFI.
import 'dart:ffi'; import 'package:ffi/ffi.dart';
Load a shared library with DynamicLibrary.open. The filename
extension differs per platform.
import 'dart:ffi';
import 'dart:io';
DynamicLibrary openLib(String name) {
if (Platform.isLinux || Platform.isAndroid) {
return DynamicLibrary.open('lib$name.so');
}
if (Platform.isMacOS || Platform.isIOS) {
return DynamicLibrary.open('lib$name.dylib');
}
if (Platform.isWindows) {
return DynamicLibrary.open('$name.dll');
}
throw UnsupportedError('unsupported platform');
}
void main() {
final lib = openLib('mylib');
// use lib...
}
DynamicLibrary.process searches the symbols already loaded by the
process. Use it to reach functions from the C runtime or from a statically linked
library.
import 'dart:ffi';
void main() {
// look up a symbol already loaded into the current process
final lib = DynamicLibrary.process();
// or: DynamicLibrary.executable() for the Dart VM executable itself
}
Basic Types
Every C type has a corresponding Dart FFI type. These native types are used only
in typedef declarations — actual Dart variables always use Dart's native
int, double, or bool.
C integer types map to Int8, Int16, Int32,
Int64, and unsigned variants Uint8 through
Uint64. C float maps to Float, and
double maps to Double. C void maps to
Void. A C pointer int* maps to
Pointer<Int32>.
The sizeOf<T> function returns the number of bytes occupied by
a native type — the same value that sizeof would return in C.
import 'dart:ffi';
void main() {
print(sizeOf<Int8>()); // 1
print(sizeOf<Int16>()); // 2
print(sizeOf<Int32>()); // 4
print(sizeOf<Int64>()); // 8
print(sizeOf<Float>()); // 4
print(sizeOf<Double>()); // 8
print(sizeOf<Pointer>()); // 8 on 64-bit platforms
}
$ dart main.dart 1 2 4 8 4 8 8
Calling Simple C Functions
To call a C function, you declare two Dart typedef aliases: one using native
FFI types (for the C side) and one using Dart types (for the Dart side). Then
you call lookupFunction on the loaded library to bind the symbol.
Start with a minimal C library. Save the file as add.c and compile
it.
#include <stdint.h>
int32_t add(int32_t a, int32_t b) {
return a + b;
}
$ gcc -shared -fPIC -o libadd.so add.c
import 'dart:ffi';
// C signature: int32_t add(int32_t, int32_t)
typedef AddNative = Int32 Function(Int32, Int32);
// Dart signature: int add(int, int)
typedef AddDart = int Function(int, int);
void main() {
final lib = DynamicLibrary.open('libadd.so');
final add = lib.lookupFunction<AddNative, AddDart>('add');
print(add(3, 7)); // 10
print(add(100, -1)); // 99
}
$ dart main.dart 10 99
lookupFunction takes two type arguments: the native C signature
first, the Dart signature second. The method looks up the symbol by name,
verifies that the signatures are compatible, and returns a callable Dart
function.
Working With Pointers
A Pointer<T> holds the memory address of a value of type
T. The ffi package provides two allocators:
calloc (zeroes memory) and malloc (leaves memory
uninitialised). Always free allocations when they are no longer needed.
import 'dart:ffi';
import 'package:ffi/ffi.dart';
void main() {
// Allocate a single Int32
final ptr = calloc<Int32>();
// Write a value
ptr.value = 42;
// Read it back
print(ptr.value); // 42
// Free when done
calloc.free(ptr);
}
$ dart main.dart 42
Pass a count to allocate a contiguous block. Index the pointer like an array.
import 'dart:ffi';
import 'package:ffi/ffi.dart';
void main() {
const n = 5;
final buf = calloc<Int32>(n);
for (int i = 0; i < n; i++) {
buf[i] = i * 10;
}
for (int i = 0; i < n; i++) {
print(buf[i]);
}
calloc.free(buf);
}
$ dart main.dart 0 10 20 30 40
ptr.elementAt(n) returns a new pointer advanced by n
elements — the equivalent of C pointer arithmetic. The original pointer address
is unchanged.
import 'dart:ffi';
import 'package:ffi/ffi.dart';
void main() {
final buf = calloc<Int32>(4);
buf[0] = 10;
buf[1] = 20;
buf[2] = 30;
buf[3] = 40;
// Pointer advanced by 2 elements
final mid = buf.elementAt(2);
print(mid.value); // 30
print(mid[1]); // 40
calloc.free(buf);
}
$ dart main.dart 30 40
Structs
A C struct is mapped to a Dart class that extends Struct. Each
field is declared external and annotated with its native type.
Access struct fields through .ref on a Pointer<T>.
#include <stdint.h>
typedef struct {
int32_t x;
int32_t y;
} Point;
int32_t distance_squared(const Point *p) {
return p->x * p->x + p->y * p->y;
}
$ gcc -shared -fPIC -o libpoint.so point.c
import 'dart:ffi';
import 'package:ffi/ffi.dart';
// Mirror of the C struct
final class Point extends Struct {
@Int32()
external int x;
@Int32()
external int y;
}
typedef DistSqNative = Int32 Function(Pointer<Point>);
typedef DistSqDart = int Function(Pointer<Point>);
void main() {
final lib = DynamicLibrary.open('libpoint.so');
final distSq = lib.lookupFunction<DistSqNative, DistSqDart>('distance_squared');
final p = calloc<Point>();
p.ref.x = 3;
p.ref.y = 4;
print(distSq(p)); // 25
calloc.free(p);
}
$ dart main.dart 25
The Dart struct class is used only as a type parameter — you never instantiate
it directly. Memory is always managed through a Pointer, and fields
are accessed via .ref.
The following example shows a struct with multiple field types.
#include <stdint.h>
typedef struct {
int32_t id;
double value;
uint8_t flags;
} Reading;
void fill_reading(Reading *r, int32_t id, double value, uint8_t flags) {
r->id = id;
r->value = value;
r->flags = flags;
}
$ gcc -shared -fPIC -o libreading.so reading.c
import 'dart:ffi';
import 'package:ffi/ffi.dart';
final class Reading extends Struct {
@Int32()
external int id;
@Double()
external double value;
@Uint8()
external int flags;
}
typedef FillNative = Void Function(Pointer<Reading>, Int32, Double, Uint8);
typedef FillDart = void Function(Pointer<Reading>, int, double, int);
void main() {
final lib = DynamicLibrary.open('libreading.so');
final fill = lib.lookupFunction<FillNative, FillDart>('fill_reading');
final r = calloc<Reading>();
fill(r, 42, 3.14, 0xFF);
print(r.ref.id); // 42
print(r.ref.value); // 3.14
print(r.ref.flags); // 255
calloc.free(r);
}
$ dart main.dart 42 3.14 255
Arrays and Buffers
C structs often contain fixed-size inline arrays. In Dart FFI, model these with
the Array<T> type and the @Array(n) annotation.
import 'dart:ffi';
import 'package:ffi/ffi.dart';
final class RGB extends Struct {
@Array(3)
external Array<Uint8> channels;
}
void main() {
final color = calloc<RGB>();
color.ref.channels[0] = 255; // R
color.ref.channels[1] = 128; // G
color.ref.channels[2] = 0; // B
final r = color.ref.channels[0];
final g = color.ref.channels[1];
final b = color.ref.channels[2];
print('R=$r, G=$g, B=$b'); // R=255, G=128, B=0
calloc.free(color);
}
$ dart main.dart R=255, G=128, B=0
For large buffers, asTypedList provides a zero-copy
TypedData view over native memory. This avoids per-element copies
when exchanging data with C code that fills or reads a buffer.
import 'dart:ffi';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
void main() {
const n = 6;
final ptr = calloc<Double>(n);
// Zero-copy view as a Dart Float64List
final view = ptr.asTypedList(n);
for (int i = 0; i < n; i++) {
view[i] = i * 0.5;
}
// Reads through the pointer confirm the same memory
for (int i = 0; i < n; i++) {
print(ptr[i]);
}
calloc.free(ptr);
}
$ dart main.dart 0.0 0.5 1.0 1.5 2.0 2.5
Converting a Dart list to a native array requires copying the values manually unless you allocate the memory first and fill it in place.
import 'dart:ffi';
import 'package:ffi/ffi.dart';
Pointer<Int32> listToNative(List<int> values) {
final ptr = calloc<Int32>(values.length);
for (int i = 0; i < values.length; i++) {
ptr[i] = values[i];
}
return ptr;
}
List<int> nativeToList(Pointer<Int32> ptr, int length) {
return List.generate(length, (i) => ptr[i]);
}
void main() {
final values = [10, 20, 30, 40, 50];
final ptr = listToNative(values);
final back = nativeToList(ptr, values.length);
print(back); // [10, 20, 30, 40, 50]
calloc.free(ptr);
}
$ dart main.dart [10, 20, 30, 40, 50]
Strings
C uses null-terminated UTF-8 strings. The ffi package provides
a toNativeUtf8 extension on String that allocates
a C string from a Dart string, and toDartString on
Pointer<Utf8> that converts back. Free the pointer when done.
import 'dart:ffi';
import 'package:ffi/ffi.dart';
void main() {
final dart = 'Hello, FFI!';
// Dart string → native UTF-8 string
final native = dart.toNativeUtf8();
print(native.runtimeType); // Pointer<Utf8>
// Native UTF-8 string → Dart string
final back = native.toDartString();
print(back); // Hello, FFI!
calloc.free(native);
}
$ dart main.dart Pointer<Utf8> Hello, FFI!
The next example passes a Dart string to a C function and reads back a C string that was filled by C into an output buffer.
#include <stdio.h>
void greet(const char *name, char *out, int out_size) {
snprintf(out, out_size, "Hello, %s!", name);
}
$ gcc -shared -fPIC -o libgreet.so greet.c
import 'dart:ffi';
import 'package:ffi/ffi.dart';
typedef GreetNative = Void Function(Pointer<Utf8>, Pointer<Utf8>, Int32);
typedef GreetDart = void Function(Pointer<Utf8>, Pointer<Utf8>, int);
void main() {
final lib = DynamicLibrary.open('libgreet.so');
final greet = lib.lookupFunction<GreetNative, GreetDart>('greet');
final name = 'Dart'.toNativeUtf8();
final outBuf = calloc<Uint8>(256);
greet(name, outBuf.cast<Utf8>(), 256);
print(outBuf.cast<Utf8>().toDartString()); // Hello, Dart!
calloc.free(name);
calloc.free(outBuf);
}
$ dart main.dart Hello, Dart!
When a C function returns a const char* that points to static
memory (i.e. memory the C library owns), do not free the pointer — just call
toDartString on it.
const char *lib_version(void) {
return "1.0.0";
}
$ gcc -shared -fPIC -o libversion.so version.c
import 'dart:ffi';
import 'package:ffi/ffi.dart';
typedef VersionNative = Pointer<Utf8> Function();
typedef VersionDart = Pointer<Utf8> Function();
void main() {
final lib = DynamicLibrary.open('libversion.so');
final version = lib.lookupFunction<VersionNative, VersionDart>('lib_version');
final ptr = version();
// do not free: the pointer is owned by the C library
print(ptr.toDartString()); // 1.0.0
}
$ dart main.dart 1.0.0
Callbacks
A C function that accepts a function pointer can be given a Dart function.
Wrap the Dart function with NativeCallable.isolateLocal to produce
a Pointer<NativeFunction<T>> that C can call. Close the
callable when it is no longer needed.
#include <stdint.h>
typedef void (*IntCallback)(int32_t);
void apply_to_range(int32_t start, int32_t end, IntCallback cb) {
for (int32_t i = start; i < end; i++) {
cb(i);
}
}
$ gcc -shared -fPIC -o libforeach.so foreach.c
import 'dart:ffi';
typedef CallbackNative = Void Function(Int32);
typedef ApplyRangeNative = Void Function(Int32, Int32, Pointer<NativeFunction<CallbackNative>>);
typedef ApplyRangeDart = void Function(int, int, Pointer<NativeFunction<CallbackNative>>);
void onValue(int v) {
print('received: $v');
}
void main() {
final lib = DynamicLibrary.open('libforeach.so');
final applyRange = lib.lookupFunction<ApplyRangeNative, ApplyRangeDart>('apply_to_range');
final callable = NativeCallable<CallbackNative>.isolateLocal(onValue);
applyRange(1, 6, callable.nativeFunction);
callable.close();
}
$ dart main.dart received: 1 received: 2 received: 3 received: 4 received: 5
NativeCallable.isolateLocal creates a callback that must be called
from the same Dart isolate that created it. If the C library calls the callback
from another thread, use NativeCallable.listener instead, which
posts the call to the isolate's event loop.
The older Pointer.fromFunction is still available and works well
for callbacks that do not need to be closed explicitly. The function must be a
top-level or static function.
import 'dart:ffi';
typedef CallbackNative = Void Function(Int32);
typedef ApplyRangeNative = Void Function(Int32, Int32, Pointer<NativeFunction<CallbackNative>>);
typedef ApplyRangeDart = void Function(int, int, Pointer<NativeFunction<CallbackNative>>);
void onValue(int v) {
print('value: $v');
}
void main() {
final lib = DynamicLibrary.open('libforeach.so');
final applyRange = lib.lookupFunction<ApplyRangeNative, ApplyRangeDart>('apply_to_range');
final cbPtr = Pointer.fromFunction<CallbackNative>(onValue);
applyRange(10, 13, cbPtr);
}
$ dart main.dart value: 10 value: 11 value: 12
Error Handling
C functions signal errors with return codes. A common convention is returning 0 on success and a negative value on failure, sometimes alongside an output parameter. Check the return value before using any output.
#include <stdint.h>
int32_t safe_divide(int32_t a, int32_t b, int32_t *result) {
if (b == 0) {
return -1; // error: division by zero
}
*result = a / b;
return 0; // success
}
$ gcc -shared -fPIC -o libdivide.so divide.c
import 'dart:ffi';
import 'package:ffi/ffi.dart';
typedef SafeDivideNative = Int32 Function(Int32, Int32, Pointer<Int32>);
typedef SafeDivideDart = int Function(int, int, Pointer<Int32>);
void main() {
final lib = DynamicLibrary.open('libdivide.so');
final div = lib.lookupFunction<SafeDivideNative, SafeDivideDart>('safe_divide');
final result = calloc<Int32>();
var status = div(10, 2, result);
if (status == 0) {
print('10 / 2 = \${result.value}'); // 5
}
status = div(10, 0, result);
if (status != 0) {
print('error: division by zero');
}
calloc.free(result);
}
$ dart main.dart 10 / 2 = 5 error: division by zero
Some C APIs use errno. Retrieve it by calling strerror
or by reading the thread-local errno variable through a small C
helper.
#include <errno.h>
#include <string.h>
#include <stdint.h>
#include <stdio.h>
int32_t open_port(int32_t port) {
if (port < 1 || port > 65535) {
errno = 22; // EINVAL
return -1;
}
return 0;
}
const char *last_error(void) {
return strerror(errno);
}
$ gcc -shared -fPIC -o liberrdemo.so errdemo.c
import 'dart:ffi';
import 'package:ffi/ffi.dart';
typedef OpenPortNative = Int32 Function(Int32);
typedef OpenPortDart = int Function(int);
typedef LastErrNative = Pointer<Utf8> Function();
typedef LastErrDart = Pointer<Utf8> Function();
void main() {
final lib = DynamicLibrary.open('liberrdemo.so');
final openPort = lib.lookupFunction<OpenPortNative, OpenPortDart>('open_port');
final lastError = lib.lookupFunction<LastErrNative, LastErrDart>('last_error');
var status = openPort(8080);
print(status == 0 ? 'port 8080 ok' : lastError().toDartString());
status = openPort(99999);
print(status == 0 ? 'port 99999 ok' : lastError().toDartString());
}
$ dart main.dart port 8080 ok Invalid argument
Real-World Examples
Calling a Math Library
The system math library is already loaded in any C process. On Linux the library
file is libm.so.6. The functions sin, cos,
and sqrt are standard and available everywhere.
import 'dart:ffi';
import 'dart:math' as math;
typedef MathFnNative = Double Function(Double);
typedef MathFnDart = double Function(double);
void main() {
// On macOS use 'libm.dylib', on Windows 'msvcrt.dll'
final libm = DynamicLibrary.open('libm.so.6');
final sin = libm.lookupFunction<MathFnNative, MathFnDart>('sin');
final cos = libm.lookupFunction<MathFnNative, MathFnDart>('cos');
final sqrt = libm.lookupFunction<MathFnNative, MathFnDart>('sqrt');
print(sin(math.pi / 6)); // ~0.5
print(cos(0.0)); // 1.0
print(sqrt(144.0)); // 12.0
}
$ dart main.dart 0.49999999999999994 1.0 12.0
Wrapping a C API
A clean Dart wrapper hides the FFI details behind a plain Dart class. The example wraps a simple C counter API.
#include <stdlib.h>
#include <stdint.h>
typedef struct {
int32_t value;
int32_t step;
} Counter;
Counter *counter_new(int32_t initial, int32_t step) {
Counter *c = malloc(sizeof(Counter));
c->value = initial;
c->step = step;
return c;
}
int32_t counter_next(Counter *c) {
c->value += c->step;
return c->value;
}
void counter_reset(Counter *c) {
c->value = 0;
}
void counter_free(Counter *c) {
free(c);
}
$ gcc -shared -fPIC -o libcounter.so counter.c
import 'dart:ffi';
import 'package:ffi/ffi.dart';
// ── Native struct ────────────────────────────────────────────────────────────
final class NativeCounter extends Struct {
@Int32()
external int value;
@Int32()
external int step;
}
// ── Native typedefs ──────────────────────────────────────────────────────────
typedef _NewN = Pointer<NativeCounter> Function(Int32, Int32);
typedef _NextN = Int32 Function(Pointer<NativeCounter>);
typedef _ResetN = Void Function(Pointer<NativeCounter>);
typedef _FreeN = Void Function(Pointer<NativeCounter>);
typedef _NewD = Pointer<NativeCounter> Function(int, int);
typedef _NextD = int Function(Pointer<NativeCounter>);
typedef _ResetD = void Function(Pointer<NativeCounter>);
typedef _FreeD = void Function(Pointer<NativeCounter>);
// ── Dart wrapper ─────────────────────────────────────────────────────────────
class Counter {
static late final _NewD _new;
static late final _NextD _next;
static late final _ResetD _reset;
static late final _FreeD _free;
static void _init() {
final lib = DynamicLibrary.open('libcounter.so');
_new = lib.lookupFunction<_NewN, _NewD>('counter_new');
_next = lib.lookupFunction<_NextN, _NextD>('counter_next');
_reset = lib.lookupFunction<_ResetN, _ResetD>('counter_reset');
_free = lib.lookupFunction<_FreeN, _FreeD>('counter_free');
}
static bool _initialized = false;
final Pointer<NativeCounter> _ptr;
Counter(int initial, int step) : _ptr = (() {
if (!_initialized) { _init(); _initialized = true; }
return _new(initial, step);
})();
int next() => _next(_ptr);
void reset() => _reset(_ptr);
void dispose() => _free(_ptr);
}
// ── Usage ────────────────────────────────────────────────────────────────────
void main() {
final c = Counter(0, 5);
for (int i = 0; i < 5; i++) {
print(c.next());
}
c.reset();
print(c.next()); // 5
c.dispose();
}
$ dart main.dart 5 10 15 20 25 5
Reading System Information
System calls accessible via libc can be reached directly with FFI. The
following example calls getpid and getuid without
writing any C glue code.
import 'dart:ffi';
typedef PidFnNative = Int32 Function();
typedef PidFnDart = int Function();
void main() {
// libc is always loaded in the process; DynamicLibrary.process finds it
final libc = DynamicLibrary.process();
final getpid = libc.lookupFunction<PidFnNative, PidFnDart>('getpid');
final getuid = libc.lookupFunction<PidFnNative, PidFnDart>('getuid');
print('PID: \${getpid()}');
print('UID: \${getuid()}');
}
$ dart main.dart PID: 18423 UID: 1000
Struct-Based C API
Many C libraries pass configuration or state as structs. The example below shows reading a C struct that is filled by the library, then extracting individual fields in Dart.
#include <stdint.h>
#include <string.h>
typedef struct {
int32_t id;
double temperature;
double humidity;
uint8_t status; // 0 = ok, 1 = warning, 2 = error
} SensorData;
void sensor_read(int32_t sensor_id, SensorData *out) {
out->id = sensor_id;
out->temperature = 21.5 + sensor_id * 0.3;
out->humidity = 55.0 - sensor_id * 1.2;
out->status = 0;
}
$ gcc -shared -fPIC -o libsensor.so sensor.c
import 'dart:ffi';
import 'package:ffi/ffi.dart';
final class SensorData extends Struct {
@Int32()
external int id;
@Double()
external double temperature;
@Double()
external double humidity;
@Uint8()
external int status;
}
typedef SensorReadNative = Void Function(Int32, Pointer<SensorData>);
typedef SensorReadDart = void Function(int, Pointer<SensorData>);
void main() {
final lib = DynamicLibrary.open('libsensor.so');
final read = lib.lookupFunction<SensorReadNative, SensorReadDart>('sensor_read');
final data = calloc<SensorData>();
for (int id = 0; id < 3; id++) {
read(id, data);
final d = data.ref;
print('Sensor \${d.id}: temp=\${d.temperature.toStringAsFixed(1)} ' 'hum=\${d.humidity.toStringAsFixed(1)} status=\${d.status}');
}
calloc.free(data);
}
$ dart main.dart Sensor 0: temp=21.5 hum=55.0 status=0 Sensor 1: temp=21.8 hum=53.8 status=0 Sensor 2: temp=22.1 hum=52.6 status=0
Performance Notes
A direct FFI call into compiled C code is fast. The overhead is small compared to platform channels or subprocess calls. For tight numerical loops, calling a C function that processes a large buffer in one shot is much faster than calling a Dart function for each element.
Memory allocation adds latency. Each calloc or malloc
call crosses into native memory and may trigger a system allocation. Allocate
once, reuse the buffer, and free when the work is done.
asTypedList gives a direct view into native memory with no copy.
Use it when passing large arrays between Dart and C instead of looping over
individual elements.
import 'dart:ffi';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
// Preferred: copy the whole list into native memory at once
Pointer<Double> fromFloatList(Float64List src) {
final ptr = calloc<Double>(src.length);
final view = ptr.asTypedList(src.length);
view.setAll(0, src); // single memcpy-like operation
return ptr;
}
void main() {
final data = Float64List.fromList([1.0, 2.0, 3.0, 4.0, 5.0]);
final ptr = fromFloatList(data);
for (int i = 0; i < data.length; i++) {
print(ptr[i]);
}
calloc.free(ptr);
}
$ dart main.dart 1.0 2.0 3.0 4.0 5.0
Avoid allocating inside a loop. Allocating and freeing a buffer on every iteration dominates the total cost.
Type conversion between Dart and C is cheap for scalars. Strings require a heap allocation for the null-terminated copy — convert once and reuse the pointer if the same string is passed repeatedly.
Summary
In this tutorial we covered the Dart FFI. We loaded shared libraries, declared C types in Dart, called C functions, worked with pointers, mapped C structs, exchanged strings, and passed Dart functions to C as callbacks.
Use FFI when you need to call an existing C or system library, when you are writing a Dart package that wraps a native SDK, or when a performance-critical algorithm is already available as compiled C code.
Avoid FFI when a pure Dart solution is sufficient, when the complexity of managing native memory outweighs the benefit, or when your application only targets Flutter and a well-maintained plugin already exists for the same purpose.
The key rules are: match C types exactly, free every allocation, and keep FFI boundaries thin by wrapping them in plain Dart classes.
Author
List all Dart tutorials.