Flutter FFI : quand Dart a besoin de code natif

Publié le 21 août 2026

9 min de lecture
Application Flutter appelant du code C natif via dart:ffi
Jonas Ockerman
Jonas OckermanCo-Founder

La plupart des applications Flutter n’ont jamais besoin de code natif. Dart gère le réseau, la gestion d’état, le rendu de l’interface et même la cryptographie sans jamais descendre en C. Mais parfois vous butez sur un mur : l’API système dont vous avez besoin n’a pas de binding Dart, la bibliothèque qu’il vous faut est écrite en C, ou votre budget de performance se compte en microsecondes.

Nous avons buté sur ce mur en construisant un outil de surveillance d’examens pour Karel de Grote Hogeschool. L’application devait capturer des paquets réseau en temps réel, détecter les machines virtuelles et énumérer les adaptateurs matériels, le tout sur Windows, macOS et Linux. Rien de cela n’est possible en Dart pur.

Voici comment nous avons utilisé la Foreign Function Interface (FFI) de Flutter pour combler l’écart.

Ce qu’est réellement Flutter FFI

FFI signifie Foreign Function Interface. Elle permet à Dart d’appeler directement des fonctions dans des bibliothèques natives compilées (.so sous Linux, .dylib sous macOS, .dll sous Windows), sans platform channels ni sérialisation de messages.

La différence essentielle avec les platform channels : la FFI est synchrone et s’exécute dans le même espace mémoire. Pas d’encodage des messages, pas de surcoût de pont asynchrone, pas d’attente du thread plateforme. Vous appelez une fonction C depuis Dart comme le C appelle le C.

// Charger la bibliothèque compilée
final dylib = DynamicLibrary.open('capture_wrapper.so');

// Rechercher une fonction par son nom C
final findAllDevs = dylib.lookupFunction<
  Pointer<Utf8> Function(),  // signature C
  Pointer<Utf8> Function()   // signature Dart
>('find_all_devices');

// L'appeler
final result = findAllDevs();

C’est tout. Pas de boilerplate de platform channel, pas de MethodChannel, aucun handler à enregistrer.

Pourquoi nous avions besoin de code natif

L’outil de surveillance de KDG observe les machines des étudiants pendant les examens. Les enseignants doivent savoir si un étudiant se connecte à un réseau inattendu, active le Bluetooth, ouvre une VM ou lance une application interdite. Toute l’activité est chiffrée et journalisée localement pour relecture ultérieure.

Trois capacités exigeaient du code natif :

1. Capture de paquets en temps réel. Dart n’a pas d’API de capture de paquets. Lire le trafic d’une interface demande un accès au niveau du noyau que la couche réseau de Dart n’expose pas. Nous avons encapsulé une bibliothèque C de capture éprouvée derrière une frontière FFI.

2. Détection de machines virtuelles. Savoir si l’application tourne dans VMware, VirtualBox ou Hyper-V repose sur des signaux que le runtime Dart ne peut pas voir. Nous avons encapsulé une bibliothèque C++ de détection et exposé un seul appel typé.

3. Énumération des adaptateurs réseau. Lister les adaptateurs réseau physiques et virtuels avec leurs détails matériels demande des API système que Dart ne remonte pas.

L’architecture

Chaque capacité native vit dans son propre package Dart, avec une frontière nette :

exam_monitor (application Flutter)
  ├── capture_wrapper (package Dart FFI)
  │     ├── src/capture_wrapper.c      ← encapsule la bibliothèque C de capture
  │     ├── src/capture_wrapper.h      ← en-tête pour ffigen
  │     └── lib/capture_wrapper.dart   ← bindings générés automatiquement

  └── vm_wrapper (package Dart FFI)
        ├── src/vm_wrapper.cpp         ← encapsule la bibliothèque C++ de détection
        ├── src/vm_wrapper.h
        └── lib/vm_wrapper.dart

L’application Flutter ne touche jamais aux pointeurs bruts. Chaque package wrapper expose des API Dart typées. Le code natif est compilé par plateforme avec CMake (Linux/Windows) et CocoaPods (macOS).

Générer les bindings automatiquement avec ffigen

Écrire des bindings FFI à la main est fastidieux et source d’erreurs. Nous avons utilisé package:ffigen pour les générer à partir des fichiers d’en-tête C.

Vous déclarez vos fonctions C dans un en-tête :

// capture_wrapper.h
const char* find_all_devices(void);
intptr_t init_dart_api(void* data);
void run_capture(int64_t send_port, const char* device);
int get_packet_count(void);

Ajoutez une configuration ffigen à votre package :

# ffigen.yaml
name: NetworkActivityBindings
output: lib/src/capture_wrapper_bindings_generated.dart
headers:
  entry-points:
    - src/capture_wrapper.h

Lancez dart run ffigen et vous obtenez des bindings Dart typés, avec les bons types de pointeurs, les bonnes dispositions de structures et les bonnes signatures. Quand l’API C change, vous régénérez.

Le problème des isolates

La capture de paquets est une opération bloquante. La boucle de lecture de la bibliothèque de capture tourne serré, puisant dans le tampon du noyau et appelant votre handler pour chaque paquet. Si vous l’appelez depuis l’isolate principal de Dart, l’interface se fige.

La solution : exécuter la capture dans un isolate Dart distinct et utiliser la Dart Native API pour renvoyer les résultats.

// En C : renvoyer chaque paquet capturé vers Dart
static Dart_Port_DL g_send_port;

void run_capture(int64_t send_port, const char* device) {
    g_send_port = (Dart_Port_DL) send_port;
    // Ouvrir le device et boucler, en appelant packet_handler par paquet
}

static void packet_handler(unsigned char *args,
    const struct capture_pkthdr *header,
    const unsigned char *packet) {

    // Analyser le paquet (extraire IP, ports, protocole)
    // ...

    // Envoyer vers Dart via le port natif
    Dart_CObject obj;
    obj.type = Dart_CObject_kString;
    obj.value.as_string = packet_info;
    Dart_PostCObject_DL(g_send_port, &obj);
}
// Initialiser la Dart native API une seule fois, avant le spawn
initDartApi(NativeApi.initializeApiDLData);

final receivePort = ReceivePort();
// Donner au natif l'id int64 du port, pas l'objet SendPort
final nativePort = receivePort.sendPort.nativePort;

await Isolate.spawn((int port) {
  // Capture bloquante, tourne jusqu'à l'arrêt
  runCapture(port, 'eth0');
}, nativePort);

// Les paquets arrivent comme messages sur le receive port
receivePort.listen((packet) {
  // Mettre à jour l'interface, écrire dans le journal chiffré
});

Ce motif, un appel natif bloquant dans un isolate avec passage de messages vers l’isolate principal, se réutilise pour n’importe quelle opération native de longue durée.

Besoin d'aide sur la FFI ?

Configuration de build par plateforme

Chaque plateforme compile le code natif différemment.

Linux (CMake) :

add_library(capture_wrapper SHARED "src/capture_wrapper.c")
find_library(CAPTURE_LIB NAMES capture)
target_link_libraries(capture_wrapper PRIVATE ${CAPTURE_LIB})

macOS (CocoaPods) :

Pod::Spec.new do |s|
  s.source_files = 'src/**/*.{c,h}'
  s.frameworks = 'SystemConfiguration'
  s.libraries = 'capture'
end

Windows (CMake + SDK du fournisseur) :

add_library(capture_wrapper SHARED "src/capture_wrapper.c")
target_include_directories(capture_wrapper PRIVATE "${CAPTURE_SDK}/Include")
target_link_libraries(capture_wrapper PRIVATE "${CAPTURE_SDK}/Lib/x64/capture.lib")

Le système de plugins de Flutter se charge d’embarquer les bibliothèques compilées dans l’application. Vous déclarez la prise en charge FFI dans pubspec.yaml :

flutter:
  plugin:
    platforms:
      linux:
        ffiPlugin: true
      macos:
        ffiPlugin: true
      windows:
        ffiPlugin: true

Build hooks : l’approche plus récente

Depuis Dart 3.10 et Flutter 3.38, le système package:hooks et package:code_assets (build hooks et code assets) peut automatiser la compilation des bibliothèques natives. Plutôt que de configurer CMake à la main par plateforme et de déclarer ffiPlugin: true, vous écrivez un fichier hook/build.dart qui décrit comment compiler votre code natif. Le système de build de Dart prend ensuite en charge la compilation, l’édition de liens et l’empaquetage sur toutes les plateformes.

Pour le projet KDG, nous avons retenu l’approche manuelle CMake/CocoaPods parce que les build hooks étaient encore expérimentaux à nos débuts. Pour un nouveau projet, c’est la voie recommandée si vos exigences de build restent simples.

Et Rust ?

Rust est une solide alternative au C pour le travail en FFI. Le package flutter_rust_bridge génère les bindings automatiquement, gère la mémoire et vous apporte les garanties de sûreté de Rust.

Nous avons choisi le C pour le projet KDG parce que les bibliothèques encapsulées sont déjà en C et en C++. Ajouter une couche Rust entre Dart et elles aurait été un détour inutile.

Choisissez Rust quand vous écrivez le code natif de zéro. La sûreté mémoire, la gestion d’erreurs et l’outillage valent la complexité de build supplémentaire. Choisissez le C quand vous encapsulez une bibliothèque C existante.

Dans les deux cas, ffigen (pour le C) et flutter_rust_bridge (pour Rust) font que vous n’écrivez presque plus de code de binding à la main.

FFI ou platform channels

FFIPlatform channels
VitesseSynchrone, même processusAsynchrone, passage de messages
UsageBibliothèques C/C++/Rust, critique en performanceAPI des SDK plateforme (Swift, Kotlin)
MémoireManuelle (ou gérée par Rust)Automatique
DesktopPrise en charge de premier ordreFonctionne, mais moins courant
ComplexitéPlus élevée (pointeurs, mémoire)Plus faible (messages encodés)

Pour le projet KDG, les platform channels auraient été impraticables. Sérialiser des données de paquets brutes via le codec binaire standard, les envoyer sur un message channel puis les décoder de l’autre côté aurait ajouté une latence que nous ne pouvions pas nous permettre, et une complexité dont nous n’avions pas besoin.

À retenir

Ne vous tournez pas vers le natif par défaut. Dart est assez rapide pour la plupart des besoins. La FFI ajoute de la complexité de build, du code spécifique à chaque plateforme et des préoccupations de gestion mémoire.

Quand vous en avez besoin, isolez-le. Encapsulez chaque capacité native dans son propre package Dart. Gardez la frontière FFI petite et typée. Laissez ffigen ou flutter_rust_bridge produire le boilerplate.

Utilisez des isolates pour les appels bloquants. N’appelez jamais une fonction native bloquante depuis l’isolate principal. Le motif isolate plus Dart Native API garde l’interface réactive.

Testez tôt sur toutes les plateformes cibles. Les systèmes de build diffèrent entre Linux, macOS et Windows. Plus tôt vous vérifiez que votre configuration CMake/CocoaPods compile sur les trois, moins vous aurez de surprises à la sortie.

Nous avons livré des applications Flutter en production avec des intégrations natives pour la capture de paquets, les systèmes d’enchères en temps réel et les protocoles matériels BLE. Si votre projet doit aller au-delà de ce que Dart offre d’origine, nous y sommes déjà passés.

Aller au-delà de ce que Dart offre d'origine ?