Saltar al contenido principal

Vincular a código nativo usando FFI

Para usar código nativo en tu programa de Flutter, usa la librería dart:ffi con la plantilla package_ffi.

Las aplicaciones de Flutter pueden usar la librería dart:ffi para llamar a APIs nativas. FFI significa foreign function interface (interfaz de funciones extranjeras). Otros términos para una funcionalidad similar incluyen interfaz nativa y vinculaciones de lenguaje.

Desde Flutter 3.38, la forma recomendada de vincular a código nativo es usar el comando flutter create --template=package_ffi. Esta plantilla utiliza build hooks para configurar la compilación nativa en un script build.dart, y ya no requiere archivos de compilación específicos del SO. Este enfoque funciona tanto para proyectos de Flutter como para proyectos independientes de Dart.

Si necesitas usar la API de plugins de Flutter, o si necesitas configurar un entorno de ejecución de Google Play services en Android, usa la plantilla de plugin estándar (flutter create --template=plugin).

Crear un paquete FFI

#

Para crear un paquete FFI, ejecuta el siguiente comando:

flutter create --template=package_ffi native_add
cd native_add

Esto crea un paquete con el siguiente contenido especializado:

  • lib/native_add.dart: El código Dart que define la API del paquete.
  • lib/native_add_bindings_generated.dart: Las vinculaciones Dart generadas para el código nativo.
  • src/native_add.c: El código fuente nativo de C.
  • src/native_add.h: El archivo de cabecera de C para el código nativo.
  • hook/build.dart: Un script que es ejecutado por el SDK de Flutter para compilar el código nativo.
  • ffigen.yaml: El archivo de configuración para que package:ffigen genere las vinculaciones Dart.
  • pubspec.yaml: La definición del paquete, que habilita el hook build.dart.

El código nativo

#

El código nativo se encuentra en src/native_add.c y src/native_add.h. La función de C sum está definida en el archivo .c y su firma está en el archivo de cabecera. La función está marcada para ser exportada de modo que se pueda llamar desde Dart.

El hook de compilación

#

El código nativo se compila y empaqueta con tu aplicación automáticamente. Esto lo realiza el script hook/build.dart, que es un build hook.

Esto significa que ya no necesitas escribir archivos de compilación específicos del SO (como CMakeLists.txt para Linux/Windows, .podspec para iOS/macOS, o build.gradle para Android) para compilar tu código nativo.

El build hook utiliza package:native_toolchain_c para compilar el código C en una librería dinámica. Puedes personalizar este archivo para compilar otros lenguajes nativos o para descargar binarios precompilados.

El código Dart

#

El código Dart define la API pública del paquete.

Generar las vinculaciones

#

Para vincular al código nativo, la plantilla utiliza package:ffigen para generar vinculaciones a partir del archivo de cabecera (src/native_add.h). La generación se configura en ffigen.yaml.

Esto genera lib/native_add_bindings_generated.dart.

Llamar a la función nativa

#

Las vinculaciones generadas en lib/native_add_bindings_generated.dart contienen funciones @Native() external. Estas funciones se resuelven automáticamente en tiempo de ejecución en función del asset de código producido por el build hook (que se ejecuta a nivel de compilación). Esto significa que no se requiere lógica específica del SO para abrir las librerías dinámicas mediante dlopen, haciendo que el código Dart sea verdaderamente multiplataforma.

El archivo de librería principal lib/native_add.dart expone estas funciones. Tu aplicación puede entonces llamar a estas funciones importando package:native_add/native_add.dart.

Pruebas

#

El paquete generado incluye una prueba unitaria en test/native_add_test.dart que muestra cómo probar la función nativa.

Otros casos de uso

#

Librerías del sistema

#

Para vincular contra una librería del sistema, modificas el hook build.dart para especificar el modo de vinculación. En lugar de compilar código fuente, creas un CodeAsset y estableces su linkMode.

Para muchas librerías del sistema en Android, iOS, Linux y macOS, puedes usar LookupInProcess() para encontrar símbolos en el proceso principal.

Para Windows, a menudo utilizas DynamicLoadingSystem() y proporcionas el nombre de la DLL.

Aquí tienes un ejemplo de build.dart que se vincula contra librerías del sistema para obtener el nombre del host:

dart
// hook/build.dart
import 'package:hooks/hooks.dart';
import 'package:code_assets/code_assets.dart';

void main(List<String> args) async {
  await build(args, (input, output) async {
    final targetOS = input.target.os;
    switch (targetOS) {
      case OS.android || OS.iOS || OS.linux || OS.macOS:
        output.assets.code.add(
          CodeAsset(
            package: 'host_name',
            name: 'src/third_party/unix.dart',
            linkMode: LookupInProcess(),
          ),
        );
      case OS.windows:
        output.assets.code.add(
          CodeAsset(
            package: 'host_name',
            name: 'src/third_party/windows.dart',
            linkMode: DynamicLoadingSystem(Uri.file('ws2_32.dll')),
          ),
        );
      default:
        throw Exception('Unsupported target os: $targetOS');
    }
  });
}

Los archivos de Dart (unix.dart, windows.dart) contendrían entonces las funciones external que usan los símbolos de estas librerías del sistema.

Empaquetar libc++_shared.so en Android

#

Aunque libc++_shared.so se distribuye con el NDK de Android, no es una librería del sistema. Si tu aplicación o paquete utiliza la librería estándar de C++, o incluye múltiples librerías compartidas que dependen de ella, tu aplicación necesita empaquetar libc++_shared.so.

Para empaquetar la librería en tu aplicación, añade una dependencia a package:android_libcpp_shared, que utiliza su propio build hook para empaquetar libc++_shared.so desde el NDK instalado localmente para cada arquitectura de destino.

Librerías de código cerrado

#

También puedes usar build hooks para vincular contra librerías de código cerrado precompiladas. El enfoque recomendado es descargar los binarios precompilados en tiempo de compilación y verificar su integridad con un hash de archivo.

En tu hook build.dart, harías lo siguiente:

  1. Descarga la librería desde una URL.
  2. Verifica el hash del archivo descargado.
  3. Coloca la librería en el directorio de salida de la compilación.
  4. Crea un CodeAsset con DynamicLoading apuntando a la librería.

Aquí tienes un ejemplo simplificado de la creación de CodeAsset:

dart
// hook/build.dart
import 'package:hooks/hooks.dart';
import 'package:code_assets/code_assets.dart';

void main(List<String> args) async {
  await build(args, (input, output) async {
    // 1. Download the library from a URL.
    // 2. Verify the hash of the downloaded file.
    // 3. Place the library in the build output directory.

    output.assets.code.add(
      CodeAsset(
        package: input.packageName,
        name: 'src/my_lib.dart', // Dart file with bindings
        linkMode: DynamicLoadingBundled(),
        file: input.outputDirectory.resolve('my_lib.so'),
      ),
    );
  });
}

Necesitarías manejar diferentes arquitecturas y plataformas teniendo diferentes versiones de tu librería precompilada.

Para ver más ejemplos, consulta los ejemplos del paquete code_assets.

Pautas de nomenclatura de librerías dinámicas

#

Al implementar hooks build.dart para paquetes que empaquetan assets de código, es fundamental asegurar una nomenclatura consistente de tus librerías dinámicas en todas las arquitecturas y SDKs de destino.

En las plataformas de Apple (iOS y macOS), las librerías dinámicas se empaquetan en frameworks. El sistema de compilación de Flutter se basa en estos nombres para generar metadatos y empaquetar formatos distribuibles como XCFrameworks.

Consistencia entre arquitecturas

#

Para un ID de asset dado, tu hook se invocará varias veces, una por arquitectura. Tu hook debe producir el mismo nombre de archivo independientemente de la arquitectura de destino (por ejemplo, arm64 vs. x64).

  • ¿Por qué? Dentro de una sola compilación de SDK, Flutter combina binarios específicos de arquitectura en un único binario universal (fat) usando lipo. Si las arquitecturas tienen nombres de archivo diferentes, la herramienta elegirá uno de manera no determinante y emitirá una advertencia. Además, los mensajes de error en tiempo de ejecución serán confusos para tus usuarios si las librerías dinámicas cambian de nombre.
  • Acción recomendada: Evita añadir sufijos de arquitectura a tus nombres de archivo (por ejemplo, usa libsqlite3.dylib en lugar de libsqlite3_arm64.dylib). En su lugar, escribe el archivo en input.outputDirectory (que es único por arquitectura) o en un subdirectorio específico de la arquitectura de input.outputDirectoryShared (por ejemplo, input.outputDirectoryShared.resolve('$architecture/')).

Consistencia entre SDKs (iOS)

#

Al compilar para iOS, tu hook se invocará múltiples veces con diferentes valores para el SDK y la arquitectura. Tanto las invocaciones para dispositivo físico (iphoneos) como para simulador (iphonesimulator) deben producir el mismo nombre de framework para el mismo ID de asset.

  • ¿Por qué? Flutter utiliza xcodebuild -create-xcframework para combinar estas salidas. Xcode requiere que todos los fragmentos (slices) de la plataforma dentro de un XCFramework compartan el mismo nombre de framework para permitir una vinculación fluida. Si los nombres de archivo difieren, la herramienta Flutter no puede crear un XCFramework correcto y los comandos como flutter build ios-framework fallarán.
  • Acción recomendada: No uses sufijos como _sim o _simulator para la compilación del simulador. La estructura de XCFramework ya maneja la separación de plataformas internamente (por ejemplo, MyLib.xcframework/ios-arm64_x86_64-simulator/MyLib.framework). En su lugar, escribe el archivo en input.outputDirectory (que es único por SDK) o en un subdirectorio específico del SDK de input.outputDirectoryShared.

Consistencia en el conjunto de assets

#

Tu hook debe producir el mismo conjunto de IDs de assets en todos los SDKs para una plataforma de destino determinada.

  • ¿Por qué? El sistema de compilación de Apple y la validación de la App Store requieren que todos los frameworks incluidos en una aplicación sean compatibles con el dispositivo de destino. Si produces un asset para el simulador (iphonesimulator) pero no para el dispositivo físico (iphoneos), el XCFramework resultante contendrá un fragmento (slice) que no tiene contraparte para el dispositivo. Esto puede provocar fallos en la compilación o que Apple rechace la aplicación por incluir binarios exclusivos del simulador en una compilación para dispositivos.
  • Acción recomendada: Asegúrate de que la lógica de tu hook build.dart maneje todos los SDKs soportados de manera consistente. Si produces un asset para un SDK, debes producir el correspondiente asset para todos los demás SDKs para esa plataforma. Para código específico de un SDK, puedes usar implementaciones de stubs para otros SDKs.