Dart FFI in Flutter: integrare librerie C native con ffigen, memoria e isolate

Foto di Nathana Rebouças su Unsplash

GuideAvanzato55 min Flutter 3.x

Dart FFI in Flutter: integrare librerie C native con ffigen, memoria e isolate

Quando una funzionalità richiede prestazioni estreme o riusa una libreria C/C++ già esistente (codec, crittografia, computer vision, parser binari), i Platform Channel diventano un collo di bottiglia: ogni chiamata attraversa il ponte asincrono verso la piattaforma e serializza i dati.

dart:ffi (Foreign Function Interface) permette invece di chiamare direttamente il codice nativo dal thread Dart, senza serializzazione e con overhead nell'ordine dei nanosecondi. In questo tutorial avanzato costruiremo un plugin FFI completo:

  • creazione del progetto con il template plugin_ffi;
  • generazione automatica dei binding con ffigen;
  • caricamento della DynamicLibrary su Android, iOS, macOS, Windows e Linux;
  • gestione corretta della memoria nativa con package:ffi, calloc e NativeFinalizer;
  • spostamento delle chiamate bloccanti fuori dal thread UI con Isolate.run;
  • callback asincrone dal C verso Dart con NativeCallable.listener.

Prerequisiti: Flutter 3.19+ (Dart 3.3+), toolchain nativa installata (NDK/CMake per Android, Xcode per iOS/macOS), e confidenza con i puntatori in C. Il codice di esempio implementa una libreria di hashing/elaborazione buffer, ma lo schema vale per qualsiasi libreria C.

  1. 1

    Creare il plugin FFI e scrivere la libreria C

    Il template plugin_ffi genera già la struttura corretta: sorgenti C in src/, build Android via CMake, podspec per iOS/macOS e CMakeLists.txt per Windows/Linux.

    flutter create --template=plugin_ffi --platforms=android,ios,macos,windows,linux native_core
    cd native_core
    

    Struttura rilevante:

    native_core/
    ├── src/                 # sorgenti C/C++ condivisi
    │   ├── native_core.c
    │   └── native_core.h
    ├── android/CMakeLists.txt
    ├── ios/native_core.podspec
    ├── lib/                 # API Dart pubblica
    └── ffigen.yaml
    

    Nell'header è fondamentale marcare le funzioni esportate con FFI_PLUGIN_EXPORT: su iOS/macOS il linker in release elimina i simboli non referenziati da codice nativo (tree-shaking), e senza l'attributo visibility("default") + __attribute__((used)) la lookupFunction fallirebbe a runtime.

    Scriviamo un header con tre tipologie di funzioni: una veloce (sincrona), una bloccante (da eseguire in isolate) e una che restituisce memoria allocata dal nativo (che dovremo liberare).

    // src/native_core.h
    #ifdef __cplusplus
    extern "C" {
    #endif
    
    #if _WIN32
    #define FFI_PLUGIN_EXPORT __declspec(dllexport)
    #else
    #define FFI_PLUGIN_EXPORT __attribute__((visibility("default"))) __attribute__((used))
    #endif
    
    // Funzione veloce: si può chiamare direttamente dal thread UI.
    FFI_PLUGIN_EXPORT int32_t nc_checksum(const uint8_t *data, int32_t length);
    
    // Funzione bloccante (simula lavoro pesante): va chiamata in un isolate.
    FFI_PLUGIN_EXPORT int64_t nc_heavy_transform(const uint8_t *data, int32_t length, int32_t rounds);
    
    // Restituisce una stringa allocata con malloc: il chiamante deve liberarla.
    FFI_PLUGIN_EXPORT char *nc_version_string(void);
    FFI_PLUGIN_EXPORT void nc_free(void *ptr);
    
    #ifdef __cplusplus
    }
    #endif

    Risultato atteso

    Il progetto plugin `native_core` compila con `flutter build apk --debug` (o `flutter run` sull'example) e la libreria nativa viene inclusa nell'APK/framework.

  2. 2

    Generare i binding Dart con ffigen

    Scrivere a mano typedef nativi e Dart è la principale fonte di bug (ABI mismatch → crash silenziosi). ffigen legge gli header C tramite libclang e genera una classe Dart type-safe.

    Installa le dipendenze:

    # pubspec.yaml
    dependencies:
      ffi: ^2.1.0
    dev_dependencies:
      ffigen: ^13.0.0
    

    Su macOS serve brew install llvm; su Linux sudo apt-get install libclang-dev.

    Configura ffigen.yaml limitando rigorosamente cosa includere: senza include finisci per generare migliaia di simboli di stdio.h, stdint.h ecc.

    Poi esegui:

    dart run ffigen --config ffigen.yaml
    

    Il file generato non va modificato a mano e conviene rigenerarlo in CI per verificare che sia allineato agli header (git diff --exit-code).

    # ffigen.yaml
    name: NativeCoreBindings
    description: Binding FFI generati per la libreria native_core.
    output: 'lib/src/native_core_bindings.dart'
    headers:
      entry-points:
        - 'src/native_core.h'
      include-directives:
        - '**native_core.h'   # solo i nostri simboli
    functions:
      include:
        - 'nc_.*'
    comments:
      style: any
      length: full
    preamble: |
      // ignore_for_file: always_specify_types, camel_case_types, non_constant_identifier_names
      // GENERATO AUTOMATICAMENTE — non modificare a mano.

    Risultato atteso

    In `lib/src/native_core_bindings.dart` trovi la classe `NativeCoreBindings` con i metodi `nc_checksum`, `nc_heavy_transform`, `nc_version_string` e `nc_free` tipizzati.

  3. 3

    Caricare la DynamicLibrary in modo cross-platform

    Il caricamento della libreria cambia per piattaforma:

    Piattaforma Come caricare
    Android DynamicLibrary.open('libnative_core.so')
    iOS / macOS (static o framework) DynamicLibrary.process() oppure DynamicLibrary.executable()
    Windows DynamicLibrary.open('native_core.dll')
    Linux DynamicLibrary.open('libnative_core.so')

    Due accortezze da progetto reale:

    1. Carica una sola volta e riusa l'istanza dei binding: DynamicLibrary.open mantiene un handle nel processo, ma aprire ripetutamente su isolate diversi è inutile e confonde il profiling.
    2. Nei test Dart puri (dart test) la libreria non è nel bundle: prevedi un percorso di fallback alla build locale, così puoi testare il codice FFI senza emulatore.

    Creiamo un piccolo loader con lazy initialization.

    // lib/src/library_loader.dart
    import 'dart:ffi';
    import 'dart:io' show Platform, File;
    
    import 'native_core_bindings.dart';
    
    const _libName = 'native_core';
    
    DynamicLibrary _open() {
      if (Platform.isAndroid || Platform.isLinux) {
        return DynamicLibrary.open('lib$_libName.so');
      }
      if (Platform.isIOS || Platform.isMacOS) {
        // Il plugin è linkato staticamente nell'app: i simboli sono nel processo.
        return DynamicLibrary.process();
      }
      if (Platform.isWindows) {
        return DynamicLibrary.open('$_libName.dll');
      }
      throw UnsupportedError('Piattaforma non supportata: ${Platform.operatingSystem}');
    }
    
    /// Caricata una sola volta per isolate, alla prima lettura.
    final NativeCoreBindings bindings = NativeCoreBindings(_resolve());
    
    DynamicLibrary _resolve() {
      // Fallback per i test da riga di comando su desktop.
      const devPath = 'build/native/libnative_core.so';
      if (!Platform.isAndroid && File(devPath).existsSync()) {
        return DynamicLibrary.open(devPath);
      }
      return _open();
    }

    Risultato atteso

    Chiamando `bindings.nc_checksum(...)` da un'app di esempio non ottieni più `Invalid argument(s): Failed to lookup symbol`.

  4. 4

    Gestire la memoria nativa: puntatori, calloc e NativeFinalizer

    Il garbage collector di Dart non conosce la memoria allocata dal C. Ogni calloc.allocate o malloc lato nativo deve avere un calloc.free / nc_free corrispondente, altrimenti hai un memory leak invisibile ai DevTools.

    Regole operative:

    • usa sempre try/finally attorno alle allocazioni temporanee;
    • per passare un Uint8List al C, alloca un buffer nativo e copia i byte con asTypedList (un Uint8List Dart può essere spostato dal GC, quindi non puoi prenderne l'indirizzo in modo stabile);
    • per risorse a vita lunga (handle, context, sessioni) usa NativeFinalizer: il runtime chiamerà la funzione di rilascio quando l'oggetto Dart wrapper diventa irraggiungibile. È una rete di sicurezza, non un sostituto di un metodo dispose() esplicito.

    Attenzione: la funzione passata a NativeFinalizer deve essere un Pointer<NativeFinalizerFunction> con firma void (*)(void*), esattamente come la nostra nc_free.

    // lib/src/native_buffer.dart
    import 'dart:ffi';
    import 'dart:typed_data';
    import 'package:ffi/ffi.dart';
    
    import 'library_loader.dart';
    import 'native_core_bindings.dart';
    
    /// Chiamata veloce: copia i byte in memoria nativa, calcola e libera.
    int checksum(Uint8List data) {
      final ptr = calloc<Uint8>(data.length);
      try {
        ptr.asTypedList(data.length).setAll(0, data);
        return bindings.nc_checksum(ptr, data.length);
      } finally {
        calloc.free(ptr); // obbligatorio anche in caso di eccezione
      }
    }
    
    /// Stringa allocata dal C: va liberata con nc_free, non con calloc.free.
    String nativeVersion() {
      final ptr = bindings.nc_version_string();
      if (ptr == nullptr) throw StateError('nc_version_string ha restituito NULL');
      try {
        return ptr.cast<Utf8>().toDartString();
      } finally {
        bindings.nc_free(ptr.cast<Void>());
      }
    }
    
    /// Risorsa a vita lunga con doppia protezione: dispose() + NativeFinalizer.
    final _finalizer = NativeFinalizer(
      nativeLibrary.lookup<NativeFunction<Void Function(Pointer<Void>)>>('nc_free'),
    );
    
    class NativeScratchBuffer implements Finalizable {
      NativeScratchBuffer(this.size) : _ptr = calloc<Uint8>(size) {
        _finalizer.attach(this, _ptr.cast<Void>(), detach: this, externalSize: size);
      }
    
      final int size;
      Pointer<Uint8> _ptr;
      bool _disposed = false;
    
      Pointer<Uint8> get pointer {
        if (_disposed) throw StateError('Buffer già liberato');
        return _ptr;
      }
    
      void dispose() {
        if (_disposed) return;
        _disposed = true;
        _finalizer.detach(this);
        calloc.free(_ptr);
        _ptr = nullptr;
      }
    }

    Risultato atteso

    Eseguendo migliaia di chiamate in loop, la memoria RSS del processo resta stabile: nessuna crescita lineare dovuta a leak.

  5. 5

    Chiamate bloccanti fuori dal thread UI con gli isolate

    Una chiamata FFI è sincrona: blocca il thread Dart finché il C non ritorna. Se la funzione nativa impiega 300 ms, l'UI perde ~18 frame. La soluzione è eseguirla in un isolate.

    Due approcci:

    • Isolate.run (Dart 3): perfetto per operazioni one-shot. Attenzione: i Pointer non si passano tra isolate come oggetti vivi — trasferisci Uint8List (copiati o via TransferableTypedData) e alloca la memoria nativa dentro l'isolate.
    • Isolate long-running con ReceivePort: evita il costo di spawn (~1-2 ms) quando le chiamate sono frequenti, ed è indispensabile se il nativo mantiene uno stato/sessione, perché quello stato deve essere usato sempre dallo stesso thread.

    Ricorda anche che DynamicLibrary va caricata in ogni isolate (il nostro loader è lazy per isolate, quindi funziona già).

    Per grossi buffer usa TransferableTypedData: sposta i byte senza copia tra isolate.

    // lib/src/heavy_api.dart
    import 'dart:isolate';
    import 'dart:typed_data';
    import 'dart:ffi';
    import 'package:ffi/ffi.dart';
    
    import 'library_loader.dart';
    
    /// One-shot: la memoria nativa nasce e muore dentro l'isolate.
    Future<int> heavyTransform(Uint8List data, {int rounds = 50}) {
      final transfer = TransferableTypedData.fromList([data]);
      return Isolate.run(() {
        final bytes = transfer.materialize().asUint8List();
        final ptr = calloc<Uint8>(bytes.length);
        try {
          ptr.asTypedList(bytes.length).setAll(0, bytes);
          return bindings.nc_heavy_transform(ptr, bytes.length, rounds);
        } finally {
          calloc.free(ptr);
        }
      }, debugName: 'native_core.heavy');
    }
    
    /// Worker persistente: nessun costo di spawn per chiamata.
    class NativeWorker {
      NativeWorker._(this._toIsolate, this._responses, this._isolate);
    
      final SendPort _toIsolate;
      final Stream<Object?> _responses;
      final Isolate _isolate;
    
      static Future<NativeWorker> spawn() async {
        final init = ReceivePort();
        final isolate = await Isolate.spawn(_entryPoint, init.sendPort);
        final broadcast = init.asBroadcastStream();
        final sendPort = await broadcast.first as SendPort;
        return NativeWorker._(sendPort, broadcast, isolate);
      }
    
      Future<int> transform(Uint8List data, int rounds) async {
        final reply = ReceivePort();
        _toIsolate.send([TransferableTypedData.fromList([data]), rounds, reply.sendPort]);
        final result = await reply.first;
        reply.close();
        if (result is Object && result is! int) throw StateError('Errore nativo: $result');
        return result as int;
      }
    
      void dispose() => _isolate.kill(priority: Isolate.immediate);
    
      static void _entryPoint(SendPort initial) {
        final commands = ReceivePort();
        initial.send(commands.sendPort);
        commands.listen((message) {
          final args = message as List;
          final bytes = (args[0] as TransferableTypedData).materialize().asUint8List();
          final rounds = args[1] as int;
          final reply = args[2] as SendPort;
          final ptr = calloc<Uint8>(bytes.length);
          try {
            ptr.asTypedList(bytes.length).setAll(0, bytes);
            reply.send(bindings.nc_heavy_transform(ptr, bytes.length, rounds));
          } catch (e) {
            reply.send(e.toString());
          } finally {
            calloc.free(ptr);
          }
        });
      }
    }

    Risultato atteso

    Con il worker attivo, l'animazione di un `CircularProgressIndicator` resta fluida a 60/120 fps anche durante trasformazioni da centinaia di millisecondi.

  6. 6

    Ricevere callback dal C in Dart con NativeCallable.listener

    Molte librerie C notificano il progresso o gli eventi tramite puntatori a funzione. Con NativeCallable puoi passare una closure Dart al nativo:

    • NativeCallable.isolateLocal: la callback viene invocata in modo sincrono e deve arrivare dallo stesso thread che ha chiamato Dart. Usala solo per callback sincrone (es. comparatori).
    • NativeCallable.listener: la callback può essere invocata da un thread nativo qualsiasi; il messaggio viene accodato sull'event loop dell'isolate creatore ed eseguito in modo asincrono. È la scelta giusta per progressi, worker thread C e callback di completamento.

    Regole critiche:

    1. chiama sempre close() sul NativeCallable, altrimenti l'isolate resta vivo per sempre;
    2. nella callback non fare assunzioni sulla validità dei puntatori: se il nativo passa un buffer temporaneo, copialo subito (asTypedList(...).sublist(0)), perché con listener l'esecuzione avviene dopo il ritorno della funzione C;
    3. non lanciare eccezioni dalla callback: il nativo non sa gestirle.
    // lib/src/progress_api.dart
    import 'dart:async';
    import 'dart:ffi';
    
    import 'library_loader.dart';
    
    typedef _ProgressNative = Void Function(Int32 percent, Pointer<Void> userData);
    
    /// Esegue un job nativo che riporta il progresso da un thread C.
    /// Firma C: void nc_run_job(int rounds, void (*cb)(int, void*), void* userData);
    Stream<int> runJobWithProgress(int rounds) {
      final controller = StreamController<int>();
      late final NativeCallable<_ProgressNative> callback;
    
      void onProgress(int percent, Pointer<Void> _) {
        if (controller.isClosed) return;
        controller.add(percent);
        if (percent >= 100) {
          controller.close();
          callback.close(); // fondamentale: libera il thread di ascolto
        }
      }
    
      callback = NativeCallable<_ProgressNative>.listener(onProgress);
    
      controller.onCancel = () {
        callback.close();
      };
    
      // La chiamata nativa fa partire un thread C e ritorna subito.
      bindings.nc_run_job(rounds, callback.nativeFunction, nullptr);
      return controller.stream;
    }

    Risultato atteso

    Un `StreamBuilder` collegato a `runJobWithProgress(200)` mostra la percentuale aggiornarsi in tempo reale mentre il codice C lavora su un thread separato.

  7. 7

    Build nativa, test e troubleshooting in produzione

    Ultimo miglio: assicurarsi che la libreria venga compilata e inclusa su tutte le piattaforme, e che l'integrazione sia testata.

    Android — android/CMakeLists.txt deve elencare i sorgenti e, se usi Isolate.spawn + NativeCallable, verifica di non avere problemi di 16 KB page size su Android 15: aggiungi i flag di link -Wl,-z,max-page-size=16384.

    iOS/macOS — nel .podspec mantieni s.source_files = 'Classes/**/*', '../src/**/*' e assicurati che DEFINES_MODULE = YES. Se ottieni Failed to lookup symbol solo in release, quasi sempre manca __attribute__((used)) nell'header.

    Test — i binding si testano con dart test su desktop compilando la libreria con gcc/clang -shared, il che rende la suite velocissima e indipendente dagli emulatori.

    Diagnostica dei crash: un SIGSEGV senza stack trace Dart indica quasi sempre un ABI mismatch (es. Int32 invece di Int64, o struct passate per valore non allineate). Rigenera i binding con ffigen e confronta le firme.

    Checklist finale:

    • [ ] Ogni allocate ha il suo free in un finally.
    • [ ] Nessuna chiamata FFI > 8 ms sul thread UI.
    • [ ] Tutti i NativeCallable vengono chiusi.
    • [ ] I binding sono rigenerati e verificati in CI.
    • [ ] Testato su ABI arm64-v8a, armeabi-v7a (32 bit! attenzione a IntPtr) e x86_64.
    // test/native_core_test.dart
    import 'dart:typed_data';
    import 'package:test/test.dart';
    import 'package:native_core/native_core.dart';
    
    void main() {
      test('checksum è deterministico e non perde memoria', () {
        final data = Uint8List.fromList(List.generate(1024, (i) => i % 256));
        final first = checksum(data);
        for (var i = 0; i < 10000; i++) {
          expect(checksum(data), first);
        }
      });
    
      test('la stringa nativa viene decodificata e liberata', () {
        expect(nativeVersion(), matches(RegExp(r'^\d+\.\d+\.\d+$')));
      });
    
      test('heavyTransform non blocca il chiamante', () async {
        final data = Uint8List(1 << 20);
        var ticks = 0;
        final timer = Stream.periodic(const Duration(milliseconds: 4)).listen((_) => ticks++);
        await heavyTransform(data, rounds: 200);
        await timer.cancel();
        expect(ticks, greaterThan(5), reason: 'event loop rimasto responsivo');
      });
    }

    Risultato atteso

    `dart test` passa su desktop, l'app di esempio gira su Android e iOS in release e il profiling DevTools non mostra jank durante le chiamate native pesanti.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!