Dominando FFI: Integración de código nativo en Dart

Cuando el rendimiento de un algoritmo de criptografía o el procesamiento de señales de audio es el cuello de botella, no puedes permitirte el overhead de la comunicación entre procesos. Aquí es donde entra FFI (Foreign Function Interface). FFI te permite invocar código C o C++ directamente desde la Dart VM, ejecutando instrucciones a velocidad nativa sin pasar por capas de serialización o procesos externos.

Para lograr esto, dart:ffi actúa como el puente. Utilizas DynamicLibrary.open para cargar una librería compartida (.so, .dll o .dylib) o DynamicLibrary.executable() si las funciones están en el mismo binario que tu aplicación. La comunicación se realiza mediante Pointer<T>, que no es un objeto de Dart, sino una dirección de memoria cruda. Para que esto funcione, la estructura de los datos en Dart debe coincidir exactamente con la disposición de bytes en la memoria de C.

Para usar esto de forma segura, debes gestionar la memoria manualmente. Mientras que el recolector de basura (GC) de Dart gestiona tus objetos, la memoria asignada con malloc o calloc [disponible desde Dart 2.12] vive fuera del heap de Dart. Si no liberas esa memoria con free, tendrás fugas de memoria (memory leaks). En aplicaciones de larga duración, esto es fatal. Finalmente, para evitar olvidos, puedes usar NativeFinalizer, que permite registrar una función de limpieza (como free) que se ejecutará automáticamente cuando el objeto de Dart que contiene el puntero sea recolectado por el GC.

Si te equivocas en el mapeo de tipos (por ejemplo, intentas leer un Int64 cuando la función C espera un Int32), no obtendrás una excepción de Dart; obtendrás un error de segmentación (segmentation fault) que cerrará la VM por completo o, peor aún, leerás datos corruptos de la memoria adyacente.

// Para ejecutar esto, necesitarías compilar el código C primero.
/*
// math_lib.c
#include <stdint.h>

typedef struct {
    int32_t width;
    int32_t height;
} Rectangle;

float calculate_area(Rectangle* rect) {
    return (float)(rect->width * rect->height);
}
*/

import 'dart:ffi';
import 'package:ffi/ffi.dart';

// Definición de la estructura de C para que el compilador conozca el layout de memoria.
// El uso de @Int32() asegura que el offset de cada campo sea exacto.
final class Rectangle extends Struct {
  @Int32()
  external int width;

  @Int32()
  external int height;
}

// Definición de los tipos de función para el lookup.
// NativeType: La firma en C.
// DartType: La firma que usaremos en Dart.
typedef CalculateAreaNative = Float Function(Pointer<Rectangle>);
typedef CalculateAreaDart = double Function(Pointer<Rectangle>);

void main() {
  // 1. Cargar la librería dinámica.
  // En un entorno real: DynamicLibrary.open('libmath.so');
  // Para este ejemplo, asumimos que la librería está disponible en el path.
  final DynamicLibrary mathLib = DynamicLibrary.open('libmath.so');

  // 2. Mapear la función C a una función de Dart.
  final calculateArea = mathLib.lookupFunction<CalculateAreaNative, CalculateAreaDart>(
    'calculate_area',
  );

  // 3. Asignación de memoria nativa en el heap.
  // Usamos malloc de la extensión 'ffi' para reservar espacio para el struct.
  final Pointer<Rectangle> rectPtr = malloc<Rectangle>();

  try {
    // 4. Inicializar los datos en la memoria nativa.
    rectPtr.ref.width = 50;
    rectPtr.ref.height = 20;

    // 5. Llamada a la función nativa.
    // Pasamos el puntero directamente; no hay copia de datos, solo la dirección.
    final double area = calculateArea(rectPtr);

    print('Área calculada nativamente: $area');
  } finally {
    // 6. Gestión manual de memoria.
    // Siempre debemos liberar lo que mallocamos.
    malloc.free(rectPtr);
  }
}

Desglose técnico

En el ejemplo, la clave es la correspondencia exacta entre el struct de C y la clase Rectangle en Dart. Al extender de Struct y usar anotaciones como @Int32(), le estamos diciendo al compilador AOT cómo calcular los offsets de memoria. Si width mide 4 bytes y height 4 bytes, Dart sabe que para acceder a height debe saltar exactamente 4 bytes desde la dirección base de rectPtr.

La función lookupFunction<CalculateAreaNative, CalculateAreaDart> es vital. El primer parámetro (CalculateAreaNative) le dice a la VM cómo interpretar los bytes que devuelve la CPU (en este caso, un float de 32 bits) para convertirlo al tipo de retorno de Dart (double, que es de 64 bits). Esta conversión es gestionada internamente por la VM para asegurar que la precisión se mantenga.

Cuando ejecutas malloc<Rectangle>(), no estás creando un objeto en el heap de Dart. Estás reservando un bloque de memoria en el heap nativo. Por eso, si la función main terminara sin el malloc.free(rectPtr), ese bloque de memoria quedaría bloqueado hasta que el proceso termine, lo que en un servidor Dart Frog o un servicio de backend de larga ejecución causaría un consumo de RAM ascendente e imparable.

El error frecuente

Uno de los errores más peligrosos en FFI es el uso de punteros después de haber liberado su memoria (use-after-free).

// CÓDIGO PROVOCADOR DE CRASH
final Pointer<Rectangle> rectPtr = malloc<Rectangle>();
rectPtr.ref.width = 10;
malloc.free(rectPtr); // La memoria se libera aquí.

// ERROR: Acceder a la memoria liberada.
// Esto no lanzará una Exception de Dart. 
// Puede que funcione por azar, puede que devuelva basura, 
// o lo más probable: el proceso muere instantáneamente con un Segfault.
print(rectPtr.ref.width); 

Si estás manejando la vida media de objetos nativos dentro de clases de Dart, evita el free manual en métodos comunes. En su lugar, usa NativeFinalizer. A diferencia del Finalizer estándar, NativeFinalizer es mucho más eficiente porque la función de limpieza se ejecuta directamente desde el código nativo sin necesidad de volver a entrar en el entorno de ejecución de Dart, evitando así bloqueos innecesarios del event loop durante la recolección de basura.

105

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio