La mayoría de las apps Flutter nunca necesitan código nativo. Dart resuelve red, gestión de estado, renderizado de interfaz e incluso criptografía sin bajar a C. Pero a veces te topas con un muro: la API del sistema que necesitas no tiene binding en Dart, la biblioteca que necesitas está escrita en C, o tu presupuesto de rendimiento se mide en microsegundos.
Nos topamos con ese muro construyendo una herramienta de supervisión de exámenes para Karel de Grote Hogeschool. La app tenía que capturar paquetes de red en tiempo real, detectar máquinas virtuales y enumerar adaptadores de hardware, y todo ello en Windows, macOS y Linux. Nada de eso es posible en Dart puro.
Así usamos la Foreign Function Interface (FFI) de Flutter para salvar esa distancia.
Qué es realmente Flutter FFI
FFI significa Foreign Function Interface. Permite que Dart llame directamente a funciones de bibliotecas nativas compiladas (.so en Linux, .dylib en macOS, .dll en Windows), sin platform channels ni serialización de mensajes.
La diferencia clave con los platform channels: la FFI es síncrona y se ejecuta en el mismo espacio de memoria. Sin codificación de mensajes, sin sobrecoste de puente asíncrono, sin esperar al hilo de la plataforma. Llamas a una función de C desde Dart igual que C llama a C.
// Cargar la biblioteca compilada
final dylib = DynamicLibrary.open('capture_wrapper.so');
// Buscar una función por su nombre en C
final findAllDevs = dylib.lookupFunction<
Pointer<Utf8> Function(), // firma en C
Pointer<Utf8> Function() // firma en Dart
>('find_all_devices');
// Llamarla
final result = findAllDevs();
Eso es todo. Sin boilerplate de platform channel, sin MethodChannel, sin registrar handlers.
Por qué necesitábamos código nativo
La herramienta de supervisión de KDG vigila los equipos de los estudiantes durante los exámenes. El profesorado necesita saber si un estudiante se conecta a una red inesperada, activa el Bluetooth, abre una VM o inicia una aplicación prohibida. Toda la actividad se cifra y se registra en local para revisarla después.
Tres capacidades exigían código nativo:
1. Captura de paquetes en tiempo real. Dart no tiene API de captura de paquetes. Leer el tráfico de una interfaz exige acceso a nivel de kernel que la capa de red de Dart no expone. Envolvimos una biblioteca de captura en C ya madura tras una frontera FFI.
2. Detección de máquinas virtuales. Saber si la app se ejecuta dentro de VMware, VirtualBox o Hyper-V depende de señales que el runtime de Dart no puede ver. Envolvimos una biblioteca de detección en C++ y expusimos una única llamada tipada.
3. Enumeración de adaptadores de red. Listar los adaptadores de red físicos y virtuales con sus detalles de hardware exige API del sistema que Dart no ofrece.
La arquitectura
Cada capacidad nativa vive en su propio paquete Dart, con una frontera limpia:
exam_monitor (app Flutter)
├── capture_wrapper (paquete Dart FFI)
│ ├── src/capture_wrapper.c ← envuelve la biblioteca de captura en C
│ ├── src/capture_wrapper.h ← cabecera para ffigen
│ └── lib/capture_wrapper.dart ← bindings generados automáticamente
│
└── vm_wrapper (paquete Dart FFI)
├── src/vm_wrapper.cpp ← envuelve la biblioteca de detección en C++
├── src/vm_wrapper.h
└── lib/vm_wrapper.dart
La app Flutter nunca toca punteros crudos. Cada paquete wrapper expone API de Dart tipadas. El código nativo se compila por plataforma con CMake (Linux/Windows) y CocoaPods (macOS).
Generar bindings automáticamente con ffigen
Escribir bindings de FFI a mano es tedioso y propenso a errores. Usamos package:ffigen para generarlos a partir de las cabeceras de C.
Defines tus funciones de C en una cabecera:
// 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);
Añade una configuración de ffigen a tu paquete:
# ffigen.yaml
name: NetworkActivityBindings
output: lib/src/capture_wrapper_bindings_generated.dart
headers:
entry-points:
- src/capture_wrapper.h
Ejecuta dart run ffigen y obtienes bindings de Dart con tipos seguros, con los punteros, las estructuras y las firmas correctas. Cuando cambia la API de C, vuelves a generar.
El problema de los isolates
La captura de paquetes es una operación bloqueante. El bucle de lectura de la biblioteca de captura se mantiene en un ciclo apretado tomando datos del búfer del kernel y llamando a tu handler por cada paquete. Si lo llamas desde el isolate principal de Dart, la interfaz se congela.
La solución: ejecutar la captura en un isolate de Dart aparte y usar la Dart Native API para devolver resultados.
// En C: devolver cada paquete capturado a 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;
// Abrir el dispositivo y hacer el bucle, llamando a packet_handler por paquete
}
static void packet_handler(unsigned char *args,
const struct capture_pkthdr *header,
const unsigned char *packet) {
// Analizar el paquete (extraer IP, puertos, protocolo)
// ...
// Enviar a Dart por el puerto nativo
Dart_CObject obj;
obj.type = Dart_CObject_kString;
obj.value.as_string = packet_info;
Dart_PostCObject_DL(g_send_port, &obj);
}
// Inicializar la Dart native API una sola vez, antes del spawn
initDartApi(NativeApi.initializeApiDLData);
final receivePort = ReceivePort();
// Pasar al lado nativo el id int64 del puerto, no el objeto SendPort
final nativePort = receivePort.sendPort.nativePort;
await Isolate.spawn((int port) {
// Captura bloqueante, corre hasta que se detenga
runCapture(port, 'eth0');
}, nativePort);
// Los paquetes llegan como mensajes al receive port
receivePort.listen((packet) {
// Actualizar la interfaz, escribir en el registro cifrado
});
Este patrón, una llamada nativa bloqueante en un isolate con paso de mensajes de vuelta al isolate principal, se reutiliza para cualquier operación nativa de larga duración.
¿Necesitas ayuda con FFI?
Configuración de build por plataforma
Cada plataforma compila el código nativo de forma distinta.
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 del proveedor):
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")
El sistema de plugins de Flutter se encarga de empaquetar las bibliotecas compiladas dentro de la app. Declaras el soporte de FFI en pubspec.yaml:
flutter:
plugin:
platforms:
linux:
ffiPlugin: true
macos:
ffiPlugin: true
windows:
ffiPlugin: true
Build hooks: el enfoque más reciente
Desde Dart 3.10 y Flutter 3.38, el sistema de package:hooks y package:code_assets (build hooks y code assets) puede automatizar la compilación de bibliotecas nativas. En lugar de configurar CMake a mano por plataforma y declarar ffiPlugin: true, escribes un archivo hook/build.dart que describe cómo compilar tu código nativo. El sistema de build de Dart se encarga entonces de compilar, enlazar y empaquetar en todas las plataformas.
Para el proyecto de KDG usamos el enfoque manual de CMake/CocoaPods porque los build hooks seguían siendo experimentales cuando empezamos. Para proyectos nuevos son el camino recomendado si tus requisitos de build son sencillos.
¿Y Rust?
Rust es una alternativa sólida a C para trabajar con FFI. El paquete flutter_rust_bridge genera los bindings automáticamente, gestiona la memoria y te da las garantías de seguridad de Rust.
Elegimos C para el proyecto de KDG porque las bibliotecas que envolvimos ya están en C y C++. Añadir una capa de Rust entre Dart y ellas habría sido un rodeo innecesario.
Usa Rust cuando escribas el código nativo desde cero. La seguridad de memoria, el manejo de errores y las herramientas compensan la complejidad extra de build. Usa C cuando envuelvas una biblioteca de C que ya existe.
En cualquier caso, ffigen (para C) y flutter_rust_bridge (para Rust) hacen que casi nunca escribas código de binding a mano.
Cuándo usar FFI y cuándo platform channels
| FFI | Platform channels | |
|---|---|---|
| Velocidad | Síncrona, mismo proceso | Asíncrona, paso de mensajes |
| Uso | Bibliotecas C/C++/Rust, crítico en rendimiento | API de los SDK de plataforma (Swift, Kotlin) |
| Memoria | Manual (o gestionada por Rust) | Automática |
| Escritorio | Soporte de primer nivel | Funciona, pero es menos habitual |
| Complejidad | Mayor (punteros, memoria) | Menor (mensajes codificados) |
Para el proyecto de KDG, los platform channels habrían sido impracticables. Serializar datos de paquetes en crudo con el códec binario estándar, enviarlos por un message channel y decodificarlos al otro lado habría añadido una latencia que no podíamos permitirnos y una complejidad que no necesitábamos.
Lo que conviene recordar
No recurras al código nativo por defecto. Dart es lo bastante rápido para casi todo. La FFI añade complejidad de build, código específico de cada plataforma y preocupaciones de gestión de memoria.
Cuando lo necesites, aíslalo. Envuelve cada capacidad nativa en su propio paquete Dart. Mantén la frontera FFI pequeña y tipada. Deja que ffigen o flutter_rust_bridge generen el boilerplate.
Usa isolates para las llamadas bloqueantes. Nunca llames a una función nativa bloqueante desde el isolate principal. El patrón de isolate más Dart Native API mantiene la interfaz receptiva.
Prueba pronto en todas las plataformas objetivo. Los sistemas de build difieren entre Linux, macOS y Windows. Cuanto antes verifiques que tu configuración de CMake/CocoaPods compila en las tres, menos sorpresas tendrás al publicar.
Hemos entregado apps Flutter en producción con integraciones nativas para captura de paquetes, sistemas de subastas en tiempo real y protocolos de hardware BLE. Si tu proyecto tiene que ir más allá de lo que Dart ofrece de serie, ya hemos estado ahí.
¿Ir más allá de lo que Dart ofrece de serie?
