Saltar al contenido principal

Interoperabilidad con Objective-C y Swift usando package:ffigen

Para usar código de Objective-C y Swift en tu programa Dart, usa package:ffigen.

Las aplicaciones Dart que se ejecutan en la plataforma nativa de Dart (Dart Native platform) (macOS o iOS) pueden usar dart:ffi y package:ffigen para llamar a las APIs de Objective-C y Swift.

dart:ffi permite que el código Dart interactúe con las APIs nativas de C. Objective-C se basa en C y es compatible con él, por lo que es posible interactuar con las APIs de Objective-C usando únicamente dart:ffi. Sin embargo, hacerlo requiere mucho código repetitivo (boilerplate), por lo que puedes usar package:ffigen para generar automáticamente los enlaces (bindings) de Dart FFI para una API de Objective-C determinada. Para obtener más información sobre FFI y la interoperabilidad con código C directamente, consulta la guía de interoperabilidad con C.

Puedes generar cabeceras de Objective-C para las APIs de Swift, permitiendo que dart:ffi y package:ffigen interactúen con Swift.

Para obtener más información sobre el uso de FFIgen, consulta el README de FFIgen y la documentación adicional.

Ejemplo de Objective-C

#

Esta guía te acompaña a través de un ejemplo que utiliza package:ffigen para generar enlaces para AVAudioPlayer. Esta API requiere al menos el SDK de macOS 10.7, así que verifica tu versión y actualiza Xcode si es necesario:

xcodebuild -showsdks

Generar enlaces para envolver una API de Objective-C es similar a envolver una API de C. Dirige package:ffigen al archivo de cabecera que describe la API, y luego carga la librería con dart:ffi.

package:ffigen analiza los archivos de cabecera de Objective-C utilizando LLVM, por lo que necesitarás instalarlo primero. Consulta Cómo instalar LLVM en el README de FFIgen para obtener más detalles.

Configurar FFIgen para Objective-C

#

Primero, agrega package:ffigen como dev dependency (dependencia de desarrollo) y los ayudantes (helpers) package:objective_c y package:ffi como dependencias normales:

dart pub add dev:ffigen objective_c ffi

Luego, configura FFIgen para generar enlaces para la cabecera de Objective-C que contiene la API. Configura FFIgen utilizando código YAML o Dart; recomendamos Dart para proyectos nuevos. La configuración de YAML quedará obsoleta en futuras versiones de FFIgen. Comienza creando un script generate_code.dart en algún lugar de tu paquete. Recomendamos colocar este archivo en my_package/tool.

El script generate_code.dart debe crear un objeto FfiGenerator, el cual contendrá todas tus opciones de configuración, y luego llamar a su método .generate().

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

final config = FfiGenerator(
);

void main() => config.generate();

Primero, le dirás a FFIgen dónde encontrar la API para la cual estás intentando generar enlaces. Para hacer esto, establece la opción headers.entryPoints.

Para este ejemplo, cargarás AVAudioPlayer.h. Este forma parte del framework AVFAudio, el cual se encuentra en tu instalación de Xcode. FFIgen incluye algunas funciones auxiliares para localizar este tipo de APIs, tales como macSdkPath. El uso de estas funciones auxiliares hace que tu script de generación de código sea más confiable en diferentes máquinas, las cuales podrían tener diferentes ubicaciones de instalación del SDK.

La utilidad macSdkPath encuentra el SDK de macOS ejecutando xcrun --show-sdk-path --sdk macosx. Puedes ejecutar este comando en una terminal para localizar tus SDKs de macOS, o con --sdk iphoneos para encontrar tus SDKs de iOS. Al generar enlaces para una API de Apple, explorar estos directorios es una excelente manera de encontrar las cabeceras correctas para pasar a FFIgen.

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

final config = FfiGenerator(
  headers: Headers(
    entryPoints: [
      Uri.file(
        '$macSdkPath/System/Library/Frameworks/AVFAudio.framework/Headers/AVAudioPlayer.h',
      ),
    ],
  ),
);

void main() => config.generate();

A continuación, definirás el archivo de salida. La salida principal de FFIgen es un único archivo de Dart que contiene los enlaces para las entradas proporcionadas. La ubicación de este archivo se define mediante la opción output.dartFile.

A veces, FFIgen genera un archivo .m, que contiene el código de Objective-C requerido para la interoperabilidad con la API. FFIgen genera este archivo solo si la API lo requiere (por ejemplo, si estás usando bloques o protocolos). Por defecto, este archivo tiene el mismo nombre que los enlaces de Dart, pero con .m al final del nombre del archivo. Puedes cambiar su ubicación con la opción output.objectiveCFile. Si FFIgen produce este archivo, debes compilarlo dentro de tu paquete, de lo contrario, podrías obtener excepciones en tiempo de ejecución relacionadas con símbolos faltantes. Para este ejemplo, FFIgen no genera un archivo .m.

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

final config = FfiGenerator(
  headers: Headers(
    entryPoints: [
      Uri.file(
        '$macSdkPath/System/Library/Frameworks/AVFAudio.framework/Headers/AVAudioPlayer.h',
      ),
    ],
  ),
  output: Output(
    dartFile: Uri.file('avf_audio_bindings.dart'),
  ),
);

void main() => config.generate();

Finalmente, dile a FFIgen para qué partes de la API de entrada generar enlaces. Por defecto, FFIgen filtra todos los enlaces. En este caso, para generar enlaces para AVAudioPlayer, que es una interfaz de Objective-C, tienes que establecer el campo objectiveC.interfaces.

Establecer el campo objectiveC le indica a FFIgen que genere enlaces para el lenguaje Objective-C. Por defecto, FFIgen genera enlaces de C.

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

final config = FfiGenerator(
  headers: Headers(
    entryPoints: [
      Uri.file(
        '$macSdkPath/System/Library/Frameworks/AVFAudio.framework/Headers/AVAudioPlayer.h',
      ),
    ],
  ),
  objectiveC: ObjectiveC(
    interfaces: Interfaces.includeSet({'AVAudioPlayer'}),
  ),
  output: Output(
    dartFile: Uri.file('lib/avf_audio_bindings.dart'),
  ),
);

void main() => config.generate();

Puedes usar includeMember para filtrar métodos específicos de la clase, y rename o renameMember para renombrar las clases o métodos incluidos. Existen opciones similares para protocolos y categorías.

Para obtener una lista completa de las opciones de configuración, consulta la documentación de la API de FFIgen.

Generar los bindings de Objective-C

#

Para generar los enlaces (bindings), navega al directorio example y ejecuta el script:

dart run tool/generate_code.dart

Esto debería generar un archivo avf_audio_bindings.dart bastante grande, similar a este. La clase principal de interés es AVAudioPlayer.

Podrías notar otras clases en el archivo con un comentario que indica que son un stub. FFIgen genera enlaces stub para todas las dependencias transitivas de las APIs incluidas directamente. Para generar enlaces completos para estos stubs, agrégalos a los includes en tu configuración. Este comportamiento de creación de stubs se puede cambiar con las opciones includeTransitive.

Usar los bindings de Objective-C

#

Ahora estás listo para cargar e interactuar con la librería generada. La aplicación de ejemplo, play_audio.dart, carga y reproduce archivos de audio pasados como argumentos de línea de comandos. El primer paso es cargar la librería dinámica dylib e instanciar la librería nativa AVFAudio:

dart
import 'dart:ffi';

import 'package:objective_c/objective_c.dart';

import 'avf_audio_bindings.dart';

const _dylibPath =
    '/System/Library/Frameworks/AVFAudio.framework/Versions/Current/AVFAudio';

void main(List<String> args) async {
  DynamicLibrary.open(_dylibPath);
}

Debido a que este ejemplo carga una librería del sistema, la ruta de la dylib apunta a la dylib interna del framework. También puedes cargar tu propio archivo .dylib, o si la librería está vinculada estáticamente en tu aplicación (lo que suele ser el caso en iOS) no necesitas cargar nada.

El ejemplo reproduce uno por uno cada uno de los archivos de audio especificados como argumentos de la línea de comandos. Para cada argumento, primero tienes que convertir el String de Dart a un NSString de Objective-C. El wrapper de NSString generado tiene un constructor conveniente que maneja esta conversión, y un método toDartString() que lo convierte de vuelta a un String de Dart.

dart
void main(List<String> args) async {
  DynamicLibrary.open(_dylibPath);

  for (final file in args) {
    final fileStr = NSString(file);
    print('Loading $file');
  }
}

El reproductor de audio espera un NSURL, por lo que a continuación, usa el método fileURLWithPath: para convertir el NSString en un NSURL.

dart
void main(List<String> args) async {
  DynamicLibrary.open(_dylibPath);

  for (final file in args) {
    final fileStr = NSString(file);
    print('Loading $file');

    final fileUrl = NSURL.fileURLWithPath(fileStr);
  }
}

Ahora, puedes construir el AVAudioPlayer. Construir un objeto Objective-C tiene dos etapas. alloc asigna la memoria para el objeto, pero no lo inicializa. Los métodos con nombres que comienzan con init* realizan la inicialización. Algunas interfaces también proporcionan métodos new* que realizan ambos pasos.

Para inicializar el AVAudioPlayer, usa el método initWithContentsOfURL:error::

dart
void main(List<String> args) async {
  DynamicLibrary.open(_dylibPath);

  for (final file in args) {
    final fileStr = NSString(file);
    print('Loading $file');

    final fileUrl = NSURL.fileURLWithPath(fileStr);
    final player = AVAudioPlayer.alloc().initWithContentsOfURL(fileUrl);
    if (player == null) {
      print('Failed to load audio.');
      continue;
    }
  }
}

Este objeto AVAudioPlayer de Dart es un wrapper alrededor de un puntero de objeto Objective-C AVAudioPlayer* subyacente.

Objective-C utiliza el conteo de referencias para la gestión de memoria (a través de retain, release y otras funciones), pero en el lado de Dart la gestión de memoria se maneja automáticamente. El objeto wrapper de Dart conserva una referencia al objeto Objective-C, y cuando el objeto Dart es recolectado por el recolector de basura (garbage collector), la referencia se libera automáticamente.

A continuación, busca la duración del archivo de audio, que necesitarás más tarde para esperar a que finalice el audio. La duración (duration) es una @property(readonly). Las propiedades de Objective-C se traducen en getters y setters en el objeto wrapper de Dart generado. Dado que duration es readonly, solo se genera el getter.

NSTimeInterval es un alias de tipo para double, por lo que puedes usar inmediatamente el método Dart .ceil() para redondear al siguiente segundo:

dart
void main(List<String> args) async {
  DynamicLibrary.open(_dylibPath);

  for (final file in args) {
    final fileStr = NSString(file);
    print('Loading $file');

    final fileUrl = NSURL.fileURLWithPath(fileStr);
    final player = AVAudioPlayer.alloc().initWithContentsOfURL(fileUrl);
    if (player == null) {
      print('Failed to load audio.');
      continue;
    }

    final durationSeconds = player.duration.ceil();
    print('$durationSeconds sec');
  }
}

Finalmente, puedes usar el método play para reproducir el audio, luego verificar el estado y esperar la duración del archivo de audio:

dart
void main(List<String> args) async {
  DynamicLibrary.open(_dylibPath);

  for (final file in args) {
    final fileStr = NSString(file);
    print('Loading $file');

    final fileUrl = NSURL.fileURLWithPath(fileStr);
    final player = AVAudioPlayer.alloc().initWithContentsOfURL(fileUrl);
    if (player == null) {
      print('Failed to load audio.');
      continue;
    }

    final durationSeconds = player.duration.ceil();
    print('$durationSeconds sec');

    final status = player.play();
    if (status) {
      print('Playing...');
      await Future<void>.delayed(Duration(seconds: durationSeconds));
    } else {
      print('Failed to play audio.');
    }
  }
}

Limitaciones de callbacks y multihilo

#

El multihilo (multithreading) introduce complejidad a la interoperabilidad entre Objective-C y Dart. Esto se debe a las diferencias entre los isolates de Dart y los hilos del SO, y a cómo las APIs de Apple manejan la concurrencia:

  1. Los isolates de Dart no son lo mismo que los hilos. Los isolates se ejecutan en hilos pero no se garantiza que se ejecuten en ningún hilo en particular, y la VM podría cambiar en qué hilo se está ejecutando un isolate sin previo aviso. Existe una solicitud de característica abierta (open feature request) para permitir que los isolates se vinculen a hilos específicos.
  2. Aunque FFIgen admite la conversión de funciones de Dart a bloques de Objective-C, la mayoría de las APIs de Apple no ofrecen ninguna garantía sobre en qué hilo se ejecutará un callback.
  3. La mayoría de las APIs que involucran interacción con la IU solo se pueden llamar en el hilo principal (main thread), también llamado hilo de la plataforma (platform thread) en Flutter.
  4. Muchas APIs de Apple no son seguras para hilos (thread safe).

Los dos primeros puntos significan que un bloque creado en un isolate podría ser invocado en un hilo que ejecuta un isolate diferente, o en ningún isolate en absoluto. Dependiendo del tipo de bloque que estés usando, esto podría hacer que tu aplicación falle (crash). Cuando se crea un bloque, el isolate en el que se creó es su propietario. Los bloques creados usando FooBlock.fromFunction deben ser invocados en el hilo del isolate propietario, de lo contrario, fallarán. Los bloques creados usando FooBlock.listener o FooBlock.blocking se pueden invocar de forma segura desde cualquier hilo, y la función que envuelven se invocará (eventualmente) dentro del isolate propietario, aunque estos constructores solo se admiten para bloques que devuelven void. Si hay demanda por parte de los usuarios, FooBlock.blocking podría agregar soporte para valores de retorno que no sean void en el futuro.

El tercer punto significa que llamar directamente a algunas APIs de Apple usando los enlaces de Dart generados podría no ser seguro para hilos. Esto podría bloquear tu aplicación o causar otros comportamientos impredecibles. En versiones recientes de Flutter, el isolate principal se ejecuta en el hilo de la plataforma, por lo que esto no es un problema al invocar estas APIs bloqueadas por hilos desde el isolate principal. Si necesitas invocar estas APIs desde otros isolates, o si necesitas dar soporte a versiones anteriores de Flutter, puedes usar la función runOnPlatformThread. Para obtener más información, consulta la documentación de despacho de Objective-C.

En cuanto al último punto, aunque los isolates de Dart pueden cambiar de hilo, solo se ejecutan en un hilo a la vez. La API con la que interactúas no necesita ser segura para hilos, siempre y cuando no tenga restricciones sobre desde qué hilo se le llama.

Puedes interactuar de forma segura con el código de Objective-C siempre y cuando tengas en cuenta estas limitaciones.

Ejemplo de Swift

#

Este ejemplo demuestra cómo hacer que una clase de Swift sea compatible con Objective-C, generar una cabecera de wrapper e invocarla desde el código Dart.

El proceso que se muestra a continuación es manual. Existe un proyecto experimental para automatizar estos pasos llamado Swiftgen.

Generar el encabezado de wrapper de Objective-C

#

Las APIs de Swift se pueden hacer compatibles con Objective-C mediante el uso de la anotación @objc. Asegúrate de hacer que cualquier clase o método que desees usar sea public, y que tus clases extiendan NSObject.

swift
import Foundation

@objc public class SwiftClass: NSObject {
  @objc public func sayHello() -> String {
    return "Hello from Swift!";
  }

  @objc public var someField = 123;
}

Para interactuar con una librería de terceros que no puedes modificar, es posible que necesites escribir una clase wrapper compatible con Objective-C que exponga los métodos que deseas usar.

Para obtener más información sobre la interoperabilidad entre Objective-C y Swift, consulta la documentación de Swift.

Una vez que hayas hecho compatible tu clase, puedes generar una cabecera de wrapper de Objective-C. Puedes hacer esto usando Xcode, o usando el compilador de línea de comandos de Swift, swiftc. Este ejemplo utiliza la línea de comandos:

swiftc -c swift_api.swift             \
    -module-name swift_module           \
    -emit-objc-header-path swift_api.h  \
    -emit-library -o libswiftapi.dylib

Este comando compila el archivo Swift, swift_api.swift, y genera una cabecera de wrapper, swift_api.h. También genera la dylib que vas a cargar más tarde, libswiftapi.dylib.

Puedes verificar que la cabecera se haya generado correctamente abriéndola y comprobando que las interfaces sean las que esperas. Hacia la parte inferior del archivo, deberías ver algo como lo siguiente:

objc
SWIFT_CLASS("_TtC12swift_module10SwiftClass")
@interface SwiftClass : NSObject
- (NSString * _Nonnull)sayHello SWIFT_WARN_UNUSED_RESULT;
@property (nonatomic) NSInteger someField;
- (nonnull instancetype)init OBJC_DESIGNATED_INITIALIZER;
@end

Si la interfaz no se encuentra o no tiene todos sus métodos, asegúrate de que estén anotados con @objc y public.

Configurar FFIgen para Swift

#

FFIgen solo ve la cabecera del wrapper de Objective-C, swift_api.h. Por lo tanto, la mayor parte de esta configuración se parece al ejemplo de Objective-C, incluyendo la configuración del idioma a objc.

yaml
ffigen:
  name: SwiftLibrary
  description: Bindings for swift_api.
  language: objc
  output: 'swift_api_bindings.dart'
  exclude-all-by-default: true
  objc-interfaces:
    include:
      - 'SwiftClass'
    module:
      'SwiftClass': 'swift_module'
  headers:
    entry-points:
      - 'swift_api.h'

Como antes, establece el idioma a objc, y el punto de entrada a la cabecera; excluye todo de forma predeterminada, e incluye explícitamente la interfaz que estás vinculando.

Una diferencia clave en la configuración para las APIs de Swift envueltas es la opción objc-interfaces -> module. Cuando swiftc compila la librería, le da a la interfaz de Objective-C un prefijo de módulo. Internamente, SwiftClass se registra como swift_module.SwiftClass. Necesitas indicarle a ffigen sobre este prefijo, para que cargue la clase correcta desde la dylib.

No todas las clases reciben este prefijo. Por ejemplo, NSString y NSObject no recibirán un prefijo de módulo, porque son clases internas. Es por esto que la opción module mapea del nombre de la clase al prefijo del módulo. También puedes usar expresiones regulares para hacer coincidir múltiples nombres de clase a la vez.

El prefijo del módulo es lo que sea que hayas pasado a swiftc en el parámetro (flag) -module-name. En este ejemplo, es swift_module. Si no estableces explícitamente este parámetro, se toma por defecto el nombre del archivo Swift.

Si no estás seguro de cuál es el nombre del módulo, también puedes verificar la cabecera de Objective-C generada. Arriba del @interface, encontrarás una macro SWIFT_CLASS:

objc
SWIFT_CLASS("_TtC12swift_module10SwiftClass")
@interface SwiftClass : NSObject

La cadena dentro de la macro contiene el nombre del módulo y el nombre de la clase: "_TtC12swift_module10SwiftClass".

Swift incluso puede decodificar (demangle) este nombre por nosotros:

echo "_TtC12swift_module10SwiftClass" | swift demangle

Esto genera como salida swift_module.SwiftClass.

Generar los bindings de Swift

#

Como antes, para generar los enlaces, navega al directorio de ejemplos y ejecuta FFIgen:

dart run ffigen

Esto genera swift_api_bindings.dart.

Usar los bindings de Swift

#

Interactuar con estos enlaces es exactamente igual que para una librería normal de Objective-C:

dart
import 'dart:ffi';
import 'swift_api_bindings.dart';

void main() {
  final lib = SwiftLibrary(DynamicLibrary.open('libswiftapi.dylib'));
  final object = SwiftClass.new1(lib);
  print(object.sayHello());
  print('field = ${object.someField}');
  object.someField = 456;
  print('field = ${object.someField}');
}

Ten en cuenta que el nombre del módulo no se menciona en la API de Dart generada. Solo se utiliza internamente para cargar la clase desde la dylib.

Ahora puedes ejecutar el ejemplo usando:

dart run example.dart