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

  1. Caricare la libreria con DynamicLibrary.
  2. Dichiarare la firma della funzione due volte: una in tipi nativi (Int32, Pointer<Utf8>) e una in tipi Dart (int, Pointer<Utf8>).
  3. 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 llvm su macOS, apt install libclang-dev su Linux, LLVM release ufficiale su Windows). Se non lo trova, specifica llvm-path nel 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);
  • Pointer non è sendable direttamente in modo sicuro: se proprio devi, trasferisci pointer.address (un int) e ricostruisci con Pointer.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_LDFLAGS con -force_load, oppure puoi marcare le funzioni con __attribute__((visibility("default"))) __attribute__((used));
  • su Android il file CMakeLists.txt produce la .so per 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

  1. Incapsula sempre i binding. Il file generato da ffigen non deve uscire dal package: esponi un'API Dart idiomatica (Future<Uint8List> compress(...)), non Pointer<Void>.
  2. Controlla i puntatori nulli. if (ptr == nullptr) throw ...: dereferenziare nullptr non lancia un'eccezione Dart, fa crashare il processo.
  3. Usa try/finally per ogni allocazione o, meglio, using(Arena).
  4. 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 e IntPtr/Size quando serve.
  5. Verifica gli allineamenti delle struct. Se il C usa #pragma pack, ffigen potrebbe generare layout non corrispondenti: aggiungi @Packed(n).
  6. Testa su tutte le architetture. Un bug di allineamento può manifestarsi solo su armeabi-v7a a 32 bit.
  7. Misura prima di ottimizzare. Se una chiamata avviene dieci volte al minuto, un Platform Channel è più semplice da mantenere.
  8. Ricorda che un crash nativo è un crash dell'app: nessun try/catch Dart 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.