Perché FFI e non un Platform Channel
I Platform Channels sono lo strumento standard per parlare con il codice nativo, ma hanno un costo: ogni messaggio viene serializzato, attraversa il confine tra isolate Dart e platform thread, e torna indietro in modo asincrono. Per operazioni come "apri la fotocamera" va benissimo; per chiamare mille volte al secondo una funzione di elaborazione immagini scritta in C è un disastro.
dart:ffi (Foreign Function Interface) risolve esattamente questo problema: permette di caricare una libreria nativa e invocarne le funzioni direttamente, in modo sincrono, senza serializzazione e senza scrivere una riga di Kotlin, Swift o Objective-C.
I casi d'uso tipici:
- riutilizzare librerie C/C++ mature (SQLite, libwebp, OpenCV, crittografia, codec audio);
- algoritmi CPU-intensive dove il costo del canale sarebbe dominante;
- SDK di terze parti distribuiti solo come binari nativi;
- condividere lo stesso codice C su tutte e sei le piattaforme supportate da Flutter.
Quando invece serve accedere ad API di sistema di alto livello (permessi, intent Android, UIViewController), i Platform Channels restano la scelta giusta.
Come funziona: i tre pezzi del puzzle
- Caricare la libreria con
DynamicLibrary. - Dichiarare la firma della funzione due volte: una in tipi nativi (
Int32,Pointer<Utf8>) e una in tipi Dart (int,Pointer<Utf8>). - Fare il lookup del simbolo e chiamarlo.
Partiamo da una funzione C banale:
// src/math_utils.h
#include <stdint.h>
int32_t sum_range(int32_t from, int32_t to);
Il binding manuale in Dart:
import 'dart:ffi';
import 'dart:io';
// Firma lato C
typedef SumRangeNative = Int32 Function(Int32 from, Int32 to);
// Firma lato Dart
typedef SumRangeDart = int Function(int from, int to);
final DynamicLibrary _lib = _openLibrary();
DynamicLibrary _openLibrary() {
if (Platform.isAndroid) return DynamicLibrary.open('libmath_utils.so');
if (Platform.isWindows) return DynamicLibrary.open('math_utils.dll');
if (Platform.isLinux) return DynamicLibrary.open('libmath_utils.so');
// Su iOS e macOS le librerie del plugin sono linkate staticamente
// dentro il binario dell'app: i simboli sono già nel processo.
return DynamicLibrary.process();
}
final sumRange = _lib.lookupFunction<SumRangeNative, SumRangeDart>(
'sum_range',
isLeaf: true, // funzione che non richiama Dart e non fa GC: chiamata più veloce
);
void main() {
print(sumRange(1, 100)); // 5050
}
Il flag isLeaf
isLeaf: true dice al compilatore che la funzione nativa non chiamerà mai codice Dart e non allocherà memoria gestita. In cambio il runtime salta parte del setup della transizione, riducendo l'overhead della chiamata a poche decine di nanosecondi. Usalo solo per funzioni brevi e "pure": se la funzione blocca a lungo, lasciando isLeaf: true impedisci al GC di girare.
Tabella di conversione dei tipi
| Tipo C | Tipo nativo Dart | Tipo Dart |
|---|---|---|
int8_t, char |
Int8 |
int |
int32_t, int |
Int32 |
int |
int64_t |
Int64 |
int |
float |
Float |
double |
double |
Double |
double |
void |
Void |
void |
bool |
Bool |
bool |
char* |
Pointer<Utf8> |
Pointer<Utf8> |
struct* |
Pointer<MyStruct> |
Pointer<MyStruct> |
Attenzione: non esiste conversione automatica per String. Le stringhe vanno tradotte esplicitamente, come vedremo tra poco.
Generare i binding automaticamente con ffigen
Scrivere a mano i binding per un header con 200 funzioni è impraticabile. package:ffigen legge gli header C con libclang e genera il file Dart corrispondente.
# pubspec.yaml
dev_dependencies:
ffigen: ^13.0.0
dependencies:
ffi: ^2.1.3
Configurazione in un file ffigen.yaml alla radice del progetto:
name: NativeImageBindings
description: Binding generati per libimage
output: 'lib/src/native_bindings.g.dart'
headers:
entry-points:
- 'src/image_utils.h'
include-directives:
- '**image_utils.h' # evita di importare mezza libc
functions:
include:
- 'image_.*'
comments:
style: any
length: full
preamble: |
// ignore_for_file: type=lint, camel_case_types
Generazione:
dart run ffigen --config ffigen.yaml
ffigen richiede LLVM installato (
brew install llvmsu macOS,apt install libclang-devsu Linux, LLVM release ufficiale su Windows). Se non lo trova, specificallvm-pathnel file di configurazione.
Il risultato è una classe che incapsula il lookup dei simboli:
final bindings = NativeImageBindings(DynamicLibrary.open('libimage.so'));
final width = bindings.image_get_width(handle);
Ricorda di committare il file generato: non tutti gli ambienti di CI hanno LLVM.
Gestione della memoria: il punto delicato
Dart ha il garbage collector, C no. Tutto ciò che allochi con malloc/calloc va liberato a mano, altrimenti hai un leak invisibile ai DevTools.
Stringhe
import 'dart:ffi';
import 'package:ffi/ffi.dart';
typedef GreetNative = Pointer<Utf8> Function(Pointer<Utf8>);
typedef GreetDart = Pointer<Utf8> Function(Pointer<Utf8>);
String greet(String name) {
final namePtr = name.toNativeUtf8(); // alloca con malloc
try {
final resultPtr = _greet(namePtr);
return resultPtr.toDartString(); // copia in una String Dart
} finally {
malloc.free(namePtr);
}
}
Domanda cruciale: chi possiede resultPtr? Dipende dal contratto della libreria C. Se la funzione restituisce un buffer allocato con malloc, devi liberarlo tu (spesso tramite una image_free_string() esposta dalla libreria stessa). Se restituisce un puntatore a memoria statica, liberarlo causa un crash. Leggi sempre la documentazione dell'header.
Arena: free automatico
Per blocchi con molte allocazioni, package:ffi offre l'Arena, che libera tutto all'uscita dello scope:
import 'package:ffi/ffi.dart';
List<int> processPixels(List<int> input) {
return using((Arena arena) {
final buffer = arena<Uint8>(input.length); // liberato automaticamente
final view = buffer.asTypedList(input.length);
view.setAll(0, input);
_processInPlace(buffer, input.length);
return view.toList(growable: false);
});
}
asTypedList è particolarmente efficiente: restituisce una Uint8List che punta direttamente alla memoria nativa, senza copie. Attenzione però: la lista diventa dangling nel momento in cui liberi il puntatore.
NativeFinalizer per gli handle
Se esponi un oggetto nativo con ciclo di vita lungo (es. un image_handle_t*), collega la deallocazione al GC di Dart:
final _finalizer = NativeFinalizer(_lib.lookup<NativeFunction<Void Function(Pointer<Void>)>>('image_free'));
class NativeImage implements Finalizable {
NativeImage._(this._handle) {
_finalizer.attach(this, _handle.cast(), detach: this);
}
final Pointer<Void> _handle;
factory NativeImage.load(String path) {
final p = path.toNativeUtf8();
try {
final handle = _imageLoad(p);
if (handle == nullptr) throw StateError('Impossibile caricare $path');
return NativeImage._(handle);
} finally {
malloc.free(p);
}
}
void dispose() {
_finalizer.detach(this);
_imageFree(_handle);
}
}
Il NativeFinalizer è una rete di sicurezza, non un sostituto di dispose(): il GC non ha alcuna garanzia temporale.
Struct e union
Le struct si dichiarano estendendo Struct con annotazioni sui campi (ffigen le genera per te):
final class ImageInfo extends Struct {
@Int32()
external int width;
@Int32()
external int height;
external Pointer<Utf8> format;
}
void readInfo() {
final ptr = calloc<ImageInfo>();
try {
_imageGetInfo(handle, ptr);
final info = ptr.ref; // accesso ai campi
print('${info.width}x${info.height} ${info.format.toDartString()}');
} finally {
calloc.free(ptr);
}
}
Non bloccare la UI: FFI è sincrono
Una chiamata FFI gira sull'isolate chiamante. Se dura 300 ms e la fai sull'isolate principale, hai perso 18 frame. La soluzione è spostarla su un isolate secondario:
Future<Uint8List> compressImage(Uint8List bytes, int quality) {
return Isolate.run(() {
// Dentro l'isolate: la DynamicLibrary va aperta di nuovo,
// i puntatori NON possono essere passati fra isolate come tali
// (si passa l'indirizzo come int, oppure si rifà il lavoro qui).
return _compressSync(bytes, quality);
});
}
Punti da ricordare:
- ogni isolate deve fare il proprio
DynamicLibrary.open(è economico, la libreria è già in memoria); Pointernon è sendable direttamente in modo sicuro: se proprio devi, trasferiscipointer.address(unint) e ricostruisci conPointer.fromAddress, assumendoti la responsabilità del ciclo di vita;- i dati "grandi" conviene passarli come
TypedData, che Dart trasferisce in modo efficiente.
Callback dal C verso Dart
Da Dart 3.1 la classe NativeCallable gestisce anche le callback invocate da thread nativi diversi da quello dell'isolate:
// void register_progress_cb(void (*cb)(int32_t));
void _onProgress(int percent) {
debugPrint('Avanzamento: $percent%');
}
late final NativeCallable<Void Function(Int32)> _callback;
void startJob() {
// .listener: la callback viene accodata sull'event loop dell'isolate.
// Funziona anche se il C chiama da un thread suo.
_callback = NativeCallable<Void Function(Int32)>.listener(_onProgress);
_registerProgressCb(_callback.nativeFunction);
}
void stopJob() {
_unregisterProgressCb();
_callback.close(); // obbligatorio, altrimenti l'isolate resta vivo
}
Esiste anche NativeCallable.isolateLocal, sincrona ma utilizzabile solo se il C richiama dallo stesso thread dell'isolate. Sbagliare variante è una fonte classica di crash difficili da diagnosticare.
Impacchettare tutto in un plugin FFI
Flutter fornisce un template dedicato che configura CMake (Android/Linux/Windows) e CocoaPods (iOS/macOS):
flutter create --template=plugin_ffi --platforms=android,ios,macos,linux,windows my_native_lib
La struttura generata:
my_native_lib/
├── src/ # sorgenti C/C++ condivisi
│ ├── my_native_lib.c
│ └── my_native_lib.h
├── android/build.gradle # punta a src/CMakeLists.txt
├── ios/my_native_lib.podspec
├── lib/
│ ├── my_native_lib.dart
│ └── my_native_lib_bindings_generated.dart
└── ffigen.yaml
Dettagli pratici da tenere a mente:
- su iOS e macOS il template usa il linking statico: devi forzare il linker a non scartare i simboli non referenziati da codice nativo. Il podspec generato include già
OTHER_LDFLAGScon-force_load, oppure puoi marcare le funzioni con__attribute__((visibility("default"))) __attribute__((used)); - su Android il file
CMakeLists.txtproduce la.soper ogni ABI (arm64-v8a,armeabi-v7a,x86_64), che finisce dentro l'APK/AAB; - su Windows non dimenticare
__declspec(dllexport); - il codice C nel plugin viene ricompilato dalla toolchain nativa del progetto: chi usa il tuo package non deve installare nulla.
Native Assets: il futuro (sperimentale)
Il team Dart sta lavorando ai native assets (build hooks tramite hook/build.dart), che permettono di dichiarare la dipendenza nativa direttamente in Dart e usare le funzioni con l'annotazione @Native, eliminando lookup e boilerplate:
@Native<Int32 Function(Int32, Int32)>(symbol: 'sum_range')
external int sumRange(int from, int to);
È ancora dietro flag sperimentale (--enable-experiment=native-assets) e l'API cambia tra le release: ottimo da sperimentare, prematuro per la produzione.
Best practice e trappole comuni
- Incapsula sempre i binding. Il file generato da ffigen non deve uscire dal package: esponi un'API Dart idiomatica (
Future<Uint8List> compress(...)), nonPointer<Void>. - Controlla i puntatori nulli.
if (ptr == nullptr) throw ...: dereferenziarenullptrnon lancia un'eccezione Dart, fa crashare il processo. - Usa
try/finallyper ogni allocazione o, meglio,using(Arena). - Attenzione ai tipi a larghezza variabile.
longè 32 bit su Windows e 64 bit su Linux/macOS: usa i tipi di<stdint.h>negli header eIntPtr/Sizequando serve. - Verifica gli allineamenti delle struct. Se il C usa
#pragma pack, ffigen potrebbe generare layout non corrispondenti: aggiungi@Packed(n). - Testa su tutte le architetture. Un bug di allineamento può manifestarsi solo su
armeabi-v7aa 32 bit. - Misura prima di ottimizzare. Se una chiamata avviene dieci volte al minuto, un Platform Channel è più semplice da mantenere.
- Ricorda che un crash nativo è un crash dell'app: nessun
try/catchDart intercetta un segmentation fault. Valida gli input prima di attraversare il confine.
Conclusioni
Dart FFI trasforma Flutter in una piattaforma capace di riutilizzare decenni di codice C e C++ senza mediazione. Con ffigen il boilerplate praticamente sparisce, con Arena e NativeFinalizer la gestione della memoria diventa ragionevolmente sicura, e con Isolate.run puoi eseguire lavoro pesante senza far scendere il frame rate.
Il prezzo da pagare è la disciplina: nel mondo nativo non c'è rete di sicurezza. Incapsula, valida gli input, libera ciò che allochi e limita la superficie di contatto tra Dart e C al minimo indispensabile.