A la hora de desarrollar aplicaciones multiplataforma, Flutter es una de las opciones que tenemos disponibles. Nos permite trabajar con Dart y crear aplicaciones para móvil, escritorio e incluso web.

Sin embargo, es posible que en algún punto necesitemos utilizar una librería escrita en otro lenguaje, como C, o una librería de C++ expuesta mediante una interfaz compatible con C. Incluso podríamos encontrarnos con librerías desarrolladas en Go o Rust que expongan esa misma interfaz.

Para estos casos, podemos usar FFI (Foreign Function Interface), que nos permite llamar a funciones nativas desde Dart. Y, aunque es posible realizar estas vinculaciones a mano, también existen herramientas como FFIGen, que nos pueden simplificar bastante el trabajo.

En este artículo vamos a ver las dos opciones con un ejemplo sencillo: una librería de C que contiene dos funciones, una para multiplicar números y otra para sumarlos. Primero crearemos los bindings manualmente y después veremos cómo generarlos con FFIGen.

Todo el código está disponible en GitHub.

Antes de empezar: aunque estos conceptos se pueden utilizar en Flutter para plataformas nativas, el proyecto de este artículo es una aplicación de consola de Dart, no una aplicación Flutter ni un ejemplo para Flutter web. dart:ffi se utiliza en plataformas nativas, no en el navegador.

Preparando nuestra librería de C

Imaginemos que tenemos una pequeña librería en C, con las siguientes funciones:

// math_operations.h
#ifndef MATH_OPERATIONS_H
#define MATH_OPERATIONS_H

int multiply(int first, int second);
int add(int first, int second);

#endif

Este fichero .h contiene las declaraciones de las funciones que queremos utilizar. Su implementación se encuentra en math_operations.c:

#include "math_operations.h"

int multiply(int first, int second) {
    return first * second;
}

int add(int first, int second) {
    return first + second;
}

Para acabar con la parte de C, primero necesitamos compilar el código y generar una biblioteca compartida. El proyecto está configurado con CMake, así que podemos hacerlo desde la raíz del repositorio con estos comandos:

cmake -S native_code -B native_code/build
cmake --build native_code/build

En Linux/WSL se generará el fichero native_code/build/libmathOperationsLib.so, que es la biblioteca que cargaremos después desde Dart.

Utilizando FFI manualmente

Una vez tenemos nuestra biblioteca compilada, toca conectarla con Dart.

Para esto utilizaremos dart:ffi, que ya viene incluido en el SDK de Dart. Aquí hay un pequeño detalle que conviene aclarar: dart:ffi no es lo mismo que package:ffi. El primero nos permite interactuar con funciones y tipos nativos; el segundo es un paquete adicional con utilidades, por ejemplo para gestionar memoria o cadenas de caracteres. Para nuestras operaciones con enteros no necesitamos añadirlo.

Lo primero es abrir la biblioteca mediante DynamicLibrary.open. En el repositorio he separado esta parte en lib/native_library.dart, para no tener que repetirla en los dos ejemplos. Desde nuestro código la cargamos así:

final library = await loadMathLibrary();

La función loadMathLibrary() se encarga de localizar la biblioteca dentro del proyecto y abrirla. De esta manera, no dependemos de ejecutar Dart desde un directorio concreto. Si quieres ver cómo está implementada, la tienes en native_library.dart.

Con la biblioteca cargada, ya podemos buscar las funciones de C y utilizarlas desde Dart. Esta es la clase que he creado para hacerlo manualmente:

import 'dart:ffi';

class MathOperationsWithFFI {
  MathOperationsWithFFI(DynamicLibrary library)
    : _multiply = library
          .lookupFunction<Int Function(Int, Int), int Function(int, int)>(
            'multiply',
          ),
      _add = library
          .lookupFunction<Int Function(Int, Int), int Function(int, int)>(
            'add',
          );

  final int Function(int, int) _multiply;
  final int Function(int, int) _add;

  int multiply(int first, int second) => _multiply(first, second);

  int add(int first, int second) => _add(first, second);
}

Vamos por partes. Lo más importante aquí es lookupFunction, porque es lo que nos permite encontrar una función de nuestra biblioteca nativa y convertirla en una función que podamos llamar desde Dart.

En su declaración indicamos dos tipos de función:

  • Int Function(Int, Int): representa la función nativa de C, que recibe dos int y devuelve otro int.
  • int Function(int, int): representa cómo vamos a llamar a esa función desde Dart.

Fíjate en que utilizamos Int para los tipos nativos y int para los de Dart. Aunque se parezcan, no significan exactamente lo mismo. En este caso, Int es el tipo de FFI que corresponde al int de C; no debemos asumir que siempre tenemos que utilizar Int32.

También guardamos las funciones en _multiply y _add, así no tenemos que volver a buscarlas cada vez que queramos realizar una operación.

Y ahora sí, dentro de una función main asíncrona, podemos utilizarlas así:

final library = await loadMathLibrary();
final manual = MathOperationsWithFFI(library);

print(manual.multiply(10, 2)); // 20
print(manual.add(10, 2));      // 12

¡Ya estamos llamando a nuestras funciones de C desde Dart!

Sin embargo, aunque aquí resulta bastante sencillo porque solo tenemos dos funciones, imagina que nuestra librería tuviera decenas o cientos. Escribir todos esos bindings a mano podría llevarnos bastante tiempo, y mantenerlos cuando cambie la librería tampoco sería precisamente divertido.

Para evitarlo, podemos utilizar FFIGen.

Generando los bindings con FFIGen

FFIGen es una herramienta que analiza las declaraciones de nuestros ficheros de cabecera (.h) y genera los bindings de Dart por nosotros.

Eso sí, FFIGen no sustituye a FFI. El código que genera sigue utilizando dart:ffi. Tampoco se encarga de compilar nuestra librería de C: únicamente nos ahorra escribir manualmente las vinculaciones.

En la versión actualizada del proyecto, utilizo FFIGen 23 y configuro la generación mediante un fichero Dart, en lugar de hacerlo con el antiguo bloque YAML de pubspec.yaml.

La configuración está en tool/ffigen.dart:

import 'dart:io';

import 'package:ffigen/ffigen.dart';

Future<void> main() async {
  final root = Platform.script.resolve('../');
  await FfiGenerator(
    input: Input(
      entryPoints: [
        root.resolve('native_code/math_operations/math_operations.h'),
      ],
    ),
    output: Output(
      dart: DartOutput(path: root.resolve('lib/generated_bindings.dart')),
      style: const DynamicLibraryBindings(wrapperName: 'MathOperationsFFIGen'),
    ),
    visitors: [
      Visitor(
        func: (node) => node.isIncluded =
            node.originalName == 'multiply' || node.originalName == 'add',
      ),
    ],
  ).generate();
}

Aquí le estamos indicando varias cosas:

  • entryPoints: el fichero .h donde están declaradas nuestras funciones.
  • DartOutput: dónde queremos guardar el código generado.
  • DynamicLibraryBindings: el estilo de bindings que vamos a generar, para poder utilizar la misma biblioteca que cargamos en el ejemplo manual.
  • Visitor: qué funciones queremos incluir. En nuestro caso, multiply y add.

Con Dart y las dependencias del proyecto instaladas, además de LLVM/libclang para analizar el fichero de C, ejecutamos:

dart run tool/ffigen.dart

Esto genera lib/generated_bindings.dart, donde encontraremos la clase MathOperationsFFIGen con las funciones necesarias. El fichero está incluido en el repositorio por si quieres echarle un vistazo, pero no hace falta editarlo a mano: si cambiamos las declaraciones de C, volvemos a generar los bindings.

Ya con esto, utilizar nuestras funciones resulta bastante más sencillo:

final library = await loadMathLibrary();
final generated = MathOperationsFFIGen(library);

print(generated.multiply(10, 2)); // 20
print(generated.add(10, 2));      // 12

Como ves, una vez generados los bindings, podemos llamar a multiply y add sin tener que escribir sus firmas nativas nosotros mismos.

¿Y qué diferencia hay entre los dos enfoques?

En nuestro ejemplo la diferencia puede parecer pequeña, porque solo tenemos dos operaciones.

Con FFI manual somos nosotros quienes indicamos las firmas de C, buscamos los símbolos y definimos las funciones que utilizaremos desde Dart. Es una buena manera de entender qué está ocurriendo por debajo.

Con FFIGen, en cambio, esa parte se genera a partir de los ficheros de cabecera. Si necesitamos utilizar una librería con muchas funciones, esto puede ahorrarnos bastante trabajo y reducir los errores al escribir las declaraciones.

Lo que no cambia es que, en ambos casos, seguimos utilizando la misma biblioteca de C. También tenemos que compilarla y asegurarnos de que los tipos de datos coincidan correctamente.

Probando el proyecto

Si quieres probarlo, puedes clonar el repositorio y seguir las instrucciones del README.

Desde la raíz del proyecto, estos son los comandos:

dart pub get
dart run tool/ffigen.dart
cmake -S native_code -B native_code/build
cmake --build native_code/build
dart run bin/ffi_example.dart

El resultado será el siguiente:

Multiply with ffigen: 20
Add with ffigen: 12
Multiply with ffi: 20
Add with ffi: 12

También he incluido un Taskfile.yaml para automatizar estos pasos. Si tienes Task instalado, puedes ejecutar task run para preparar y lanzar el ejemplo, o task test para ejecutar las pruebas.

Actualmente el proyecto cuenta con 34 tests, que comprueban ambos enfoques con distintos valores, además de algunos errores de carga de la biblioteca y resolución de símbolos. Estas comprobaciones también forman parte de la CI de GitHub para Linux.

Un último detalle: las operaciones reciben y devuelven un int de C. Por tanto, los números y resultados tienen que caber en ese tipo; no podemos asumir que admite cualquier valor entero que podamos representar en Dart.

Conclusiones

Con este pequeño ejemplo hemos visto cómo utilizar una biblioteca de C desde Dart de dos maneras diferentes.

Escribir los bindings manualmente nos viene bien para entender cómo funciona FFI. Pero, cuando tenemos una librería más grande, FFIGen nos puede quitar bastante trabajo al generar las vinculaciones automáticamente.

En este caso hemos utilizado funciones muy sencillas, con parámetros de tipo entero. Si quisiéramos trabajar con estructuras, punteros o memoria nativa, tendríamos que tener en cuenta otras cosas que aquí no hemos tratado. Y, si queremos llevar esta idea a una aplicación Flutter, también habrá que preparar la biblioteca para la plataforma donde se vaya a ejecutar.

Si te apetece probarlo o curiosear cómo está organizado, tienes todo el código en GitHub.

¡Hasta la próxima! 🫡

Referencias: