Si tu app Flutter tiene que hablar con código nativo (la cámara, un lector BLE, el SDK de un banco), tienes tres caminos: escribir platform channels a mano, generar el puente con Pigeon o saltarte los canales con FFI. Pigeon es la opción por defecto cuando necesitas el paradigma de canal y prefieres tipos comprobados en compilación en lugar de mapas de strings. Aquí va qué hace, cuándo encaja y, sobre todo, cuándo no.

Ya escribimos sobre FFIgen y JNIgen para llamar a librerías nativas directamente. Pigeon resuelve un problema distinto, y confundirlos cuesta tiempo. Conviene no mezclarlos.

El problema del MethodChannel a mano

Un MethodChannel clásico se apoya en strings y en dynamic. Llamas a un método por su nombre en texto, pasas un mapa de argumentos y, del otro lado, en Swift o Kotlin, casteas a mano lo que llega. El compilador no te avisa de nada: da igual que cambie el nombre del método, que el tipo del argumento no cuadre o que te falte un campo. El fallo aparece en runtime, en el dispositivo, y suele elegir el momento de la demo.

La serialización también la escribes tú, en los dos lados, y la mantienes sincronizada tú. Para una llamada suelta, aguanta. Para una superficie de quince métodos con objetos anidados, es deuda desde el primer commit.

Qué hace Pigeon

Pigeon es un generador de código, no una dependencia de runtime. Defines el contrato una sola vez, en Dart, con clases abstractas anotadas:

  • @HostApi(): métodos que Dart invoca en el nativo (por ejemplo, "arranca el escáner").
  • @FlutterApi(): métodos que el nativo invoca en Dart (por ejemplo, "ha llegado una lectura").
// pigeons/scanner.dart
import 'package:pigeon/pigeon.dart';

class ScanResult {
  ScanResult(this.code, this.rssi);
  final String code;
  final int rssi;
}

@HostApi()
abstract class ScannerApi {
  void startScan(int timeoutMs);
  void stopScan();
}

@FlutterApi()
abstract class ScannerEvents {
  void onResult(ScanResult result);
}

Corres el generador y obtienes las interfaces tipadas en Dart y el esqueleto nativo. Implementas ese esqueleto cumpliendo la interfaz, y el compilador nativo te obliga a respetarla. El mapa de strings desaparece.

Genera para Android (Kotlin y Java), iOS y macOS (Swift y Objective-C), Windows (C++) y Linux (GObject). Admite los tipos que admiten los platform channels: primitivos, listas, mapas, enums y clases con campos anidados. La serialización la hace con StandardMessageCodec, así que el codec tampoco lo escribes tú.

No es código de juguete. Pigeon sostiene la capa de canal de plugins de primera parte como camera, webview_flutter, in_app_purchase, google_maps_flutter y video_player. La versión actual, la 29, la publica el editor verificado de Flutter.

Ponerlo en marcha

Pigeon entra como dev_dependency, no como dependencia de la app: solo lo necesitas al generar. Defines las opciones de salida (rutas y lenguajes) con @ConfigurePigeon en el propio archivo de definición o por línea de comandos, y lanzas la generación:

dart run pigeon --input pigeons/scanner.dart

La salida es código. Lo commiteas al repo y lo regeneras cuando cambia el contrato. Conviene atarlo a un check de CI que falle si el código generado no coincide con la definición, para que nadie edite a mano lo generado y se olvide.

Métodos asíncronos y streams de eventos

En el lado nativo los métodos pueden ser síncronos, pero en móvil lo habitual es lo asíncrono. Pigeon lo cubre con dos anotaciones: @async, que genera firmas de concurrencia moderna (suspend en Kotlin, async en Swift), y @asyncCallback para el patrón de callback de finalización.

Cuando los datos fluyen de forma continua del nativo hacia Flutter (lecturas de un sensor, posición GPS, un periférico BLE que emite medidas) entra @EventChannelApi, disponible desde Pigeon 22.7.0. Anotas una clase cuyos métodos declaran el tipo que se emite y Pigeon te devuelve un Stream de Dart que consumes con await for. Declaras el tipo del dato, no el Stream que lo envuelve. Pigeon solo genera esta parte para Swift, Kotlin y Dart.

Es justo el flujo que quieres al integrar hardware por Bluetooth: el dispositivo emite, la app escucha y el tipo de cada lectura queda garantizado en compilación en lugar de resolverse a ciegas en runtime.

Pigeon, FFI o canal a mano

La respuesta depende de qué estés integrando. No compiten por el mismo hueco.

Canal a manoPigeonFFI (FFIgen/JNIgen)
Qué integrasun puente puntualun SDK nativo, un plugin, eventosuna librería C o API nativa directa
Tiposstrings y dynamicgenerados y comprobados en compilacióngenerados
Serializaciónmanual, en los dos ladosautomáticaninguna: llamada directa
Asíncronoa mano@async y EventChannelsegún la API

La regla que usamos:

  • Si envuelves un SDK nativo, escribes un plugin o necesitas que el nativo te llame con eventos, Pigeon.
  • Si llamas a una librería C o a una API nativa que puedes invocar directa y síncrona, y quieres tree-shaking sin pasar por el codec del canal, FFI.
  • Si es una única llamada trivial que no va a crecer, un MethodChannel a mano aguanta. En cuanto aparece el segundo método o un objeto con campos, Pigeon sale gratis y te quita el bug de runtime de encima.

Cuándo no usar Pigeon

  • Si lo tuyo es una librería C pura con llamadas síncronas, FFI es más directo y se ahorra el salto por el canal. Pigeon no te aporta nada ahí.
  • Si el SDK nativo ya tiene un plugin de Flutter mantenido que hace el trabajo, úsalo. Pigeon es para cuando el puente lo escribes tú. Reimplementar un plugin que ya funciona es trabajo tirado.
  • Si necesitas EventChannels y tu objetivo es Objective-C o Java, la generación de eventos no llega: para esa parte te toca canal manual.
  • Pigeon añade un paso de generación al build. Es barato, pero es un paso: alguien tiene que correr el generador y commitear la salida. En un equipo que no lo tiene interiorizado, molesta hasta que entra en el pipeline de CI.

Cómo lo planteamos en Dribba

Cuando montamos la comunicación con un SDK nativo o un periférico, empezamos por el contrato en Dart y dejamos que el generador imponga los tipos en ambos lados. El coste es un archivo de definición y un paso de build. A cambio, un cambio de contrato rompe la compilación en el ordenador de quien lo tocó, no la app en manos del usuario. Esa es la línea que nos importa: el error caro es el de producción, y tipar el puente lo empuja al sitio barato.

Si estás integrando hardware o un SDK nativo en una app Flutter y no quieres que el puente sea el eslabón débil, así trabajamos Flutter en Dribba.