“Buena IU. ¿Pero cómo aborda Flutter las API específicas de la plataforma?”.
Flutter te invita a crear tu app para dispositivos móviles en el lenguaje de programación Dart y a realizar compilaciones para Android e iOS. Pero Dart no realiza compilaciones para el código de bytes Dalvik de Android, ni estás bendecido con vinculaciones de Dart y Objective-C en iOS. Esto significa que tu código Dart se escribe sin acceso directo a las API específicas de la plataforma Cocoa Touch de iOS y al SDK de Android.
Esto no representa un gran problema siempre que solo escribas en Dart para pintar píxeles en la pantalla. El marco de trabajo de Flutter y su motor de gráficos subyacente son capaces de hacer esto por sí solos. Tampoco es un problema si lo que haces además de pintar píxeles es archivar o conectar E/S y lógica de negocios asociada. El lenguaje, el tiempo de ejecución y las bibliotecas de Dart se ocupan de esto.
Sin embargo, las apps más complejas requieren una mayor integración con la plataforma host:
  • notificaciones, ciclo de vida de las apps, vínculos directos,
  • sensores, cámara, batería, ubicación geográfica, sonido, conectividad,
  • uso compartido de información con otras apps, lanzamiento de otras apps,
  • preferencias persistentes, carpetas especiales, información de dispositivos...
La lista es larga y parece ampliarse con cada actualización de la plataforma.
El acceso a todas estas API de la plataforma podría integrarse al marco de trabajo de Flutter. Sin embargo, eso expandiría mucho Flutter y le daría muchas más razones para cambiar. En la práctica, esto haría que Flutter se retrasara con respecto a la última versión de la plataforma. O expondría a los autores a agrupaciones de “mínimo denominador común” poco satisfactorias de las API de la plataforma. O confundiría a los recién llegados con abstracciones difíciles de manejar para ocultar diferencias de la plataforma. También podría existir fragmentación de versiones. O errores.
Pensándolo bien, probablemente podría producirse todo lo anterior.
El equipo de Flutter eligió un enfoque diferente. No hace todo eso, pero es sencillo y versátil, y está totalmente bajo tu control.
En primer lugar, Flutter se aloja en una app ambiente de Android o iOS. Las partes de la app que corresponden a Flutter se agrupan en componentes estándares específicos de la plataforma, como View en Android y UIViewController en iOS. Por lo tanto, cuando Flutter te invite a escribir tu app en Dart, puedes hacer todo lo que quieras en Java y Kotlin, o en Objective-C y Swift en la app host, y trabajar directamente sobre las API específicas de la plataforma.
En segundo lugar, los canales de la plataforma proporcionan un mecanismo simple de comunicación entre tu código Dart y el código específico de la plataforma de tu app host. Esto significa que puedes exponer un servicio de la plataforma en el código de tu app host e invocarlo desde Dart. O viceversa.
Y en tercer lugar, los complementos permiten crear una Dart API respaldada por una implementación en Android escrita en Java o Kotlin y una implementación en iOS escrita en Objective-C o Swift, y empaquetarla como un conjunto triple de Flutter, Android e iOS usando canales de plataforma. Esto significa que puedes volver a usar, compartir y distribuir tu opinión sobre cómo Flutter debería usar una API de plataforma específica.
En este artículo se ofrece una introducción detallada a los canales de plataformas. Comenzando por los aspectos fundamentales de la mensajería de Flutter, presentaré los conceptos de canal de mensajes, métodos y eventos, y analizaré algunas consideraciones de diseño de la API. No proporcionaré listas de API, aunque sí ejemplos de códigos cortos que podrás reutilizar mediante copia y pegado. Se proporciona una lista breve de pautas de uso en función de mi experiencia como contribuyente al repositorio flutter/plugins de GitHub, como miembro del equipo de Flutter. El artículo finaliza con una lista de recursos adicionales, que incluyen vínculos a las API de referencia DartDoc/JavaDoc/ObjcDoc.

Índice

Platform channels API
Aspectos básicos: mensajería binaria asíncrona
Canales de mensajes: nombre + códec
Canales de métodos: sobres estandarizados
Canales de eventos: transmisión
Pautas de uso
Prefijar nombres de canales por dominio para exclusividad
Considerar el tratamiento de canales de plataforma como vías de comunicación dentro de módulos
No simular canales de la plataforma
Considerar las pruebas automatizadas para tu interacción con plataformas
Mantener la plataforma lista para llamadas síncronas entrantes
Recursos

Platform channels API

Para la mayoría de los casos de uso, probablemente usarías canales de métodos para la comunicación de la plataforma. Sin embargo, debido a que muchas de sus propiedades derivan de canales de mensajes más simples y de aspectos fundamentales de la mensajería binaria subyacente, empezaré por allí.

Aspectos básicos: mensajería binaria asíncrona



En el nivel más básico, Flutter se comunica con el código de la plataforma usando transmisión de mensajes asíncronos con mensajes binarios, lo cual significa que la carga útil de mensajes es un búfer de bytes. Para distinguir mensajes que se usan para diferentes propósitos, cada mensaje se envía en un “canal” lógico, que es simplemente una string de nombre. En los siguientes ejemplos se usa el nombre de canal foo.
// Send a binary message from Dart to the platform.
final WriteBuffer buffer = WriteBuffer()
  ..putFloat64(3.1415)
  ..putInt32(12345678);
final ByteData message = buffer.done();
await BinaryMessages.send('foo', message);
print('Message sent, reply ignored');
En Android, un mensaje de este tipo, como java.nio.ByteBuffer, se puede recibir usando el siguiente código de Kotlin:
// Receive binary messages from Dart on Android.
// This code can be added to a FlutterActivity subclass, typically
// in onCreate.
flutterView.setMessageHandler("foo") { message, reply ->
  message.order(ByteOrder.nativeOrder())
  val x = message.double
  val n = message.int
  Log.i("MSG", "Received: $x and $n")
  reply.reply(null)
}
La ByteBuffer API admite la lectura de valores primitivos y, al mismo tiempo, anticipa automáticamente la posición de lectura actual. Con iOS ocurre algo similar; es muy bienvenida cualquier sugerencia para mejorar mi frágil Swift fu:
// Receive binary messages from Dart on iOS.
// This code can be added to a FlutterAppDelegate subclass,
// typically in application:didFinishLaunchingWithOptions:.
let flutterView =
  window?.rootViewController as! FlutterViewController;
flutterView.setMessageHandlerOnChannel("foo") {
  (message: Data!, reply: FlutterBinaryReply) -> Void in
  let x : Float64 = message.subdata(in: 0..<8)
    .withUnsafeBytes { $0.pointee }
  let n : Int32 = message.subdata(in: 8..<12)
    .withUnsafeBytes { $0.pointee }
  os_log("Received %f and %d", x, n)
  reply(nil)
}
La comunicación es bidireccional, de modo que también puedes enviar mensajes en la dirección opuesta, de Java/Kotlin u Objective-C/Swift a Dart. La inversión de la dirección de la configuración anterior se ve de la siguiente manera:
// Send a binary message from Android.
val message = ByteBuffer.allocateDirect(12)
message.putDouble(3.1415)
message.putInt(123456789)
flutterView.send("foo", message) { _ ->
  Log.i("MSG", "Message sent, reply ignored")
}

// Send a binary message from iOS.
var message = Data(capacity: 12)
var x : Float64 = 3.1415
var n : Int32 = 12345678
message.append(UnsafeBufferPointer(start: &x, count: 1))
message.append(UnsafeBufferPointer(start: &n, count: 1))
flutterView.send(onChannel: "foo", message: message) {(_) -> Void in
  os_log("Message sent, reply ignored")
}

// Receive binary messages from the platform.
BinaryMessages.setMessageHandler('foo', (ByteData message) async {
  final ReadBuffer readBuffer = ReadBuffer(message);
  final double x = readBuffer.getFloat64();
  final int n = readBuffer.getInt32();
  print('Received $x and $n');
  return null;
});
La letra chica: respuestas obligatorias. Cada envío de mensaje incluye una respuesta asíncrona del receptor. En los ejemplos anteriores, no hay valores interesantes para comunicar, pero la respuesta nula es necesaria para que se complete el futuro de Dart y para que se ejecuten las dos callbacks de la plataforma.
Subprocesos: los mensajes y las respuestas se reciben, y se deben enviar, en el subproceso principal de la IU de la plataforma. En Dart, hay un solo subproceso por cada componente aislado de Dart; es decir, por cada vista de Flutter, de modo que no existe confusión con respecto a los subprocesos que deben usarse aquí.
Excepciones: las excepciones no detectadas que se produzcan en un controlador de mensajes de Dart o Android son captadas por el marco de trabajo, se registran y se envía una respuesta nula al emisor. Las excepciones no detectadas que se producen en los controladores de respuestas se registran.
Vida útil del controlador: los controladores de mensajes registrados se conservan y mantienen activos juntos con la vista de Flutter (es decir, el elemento aislado de Dart, la instancia de FlutterView de Android y FlutterViewController de iOS). Puedes reducir la vida del controlador al anular su registro: simplemente envía un controlador nulo (o diferente) usando el mismo nombre de canal.
Exclusividad de controladores: los controladores se conservan en un mapa hash codificado por nombre de canal, de modo que pueda haber, como máximo, un controlador por canal. Un mensaje enviado en un canal para el que no se registran controladores de mensajes en el extremo de recepción se responde automáticamente usando una respuesta nula.
Comunicación síncrona: la comunicación de la plataforma está disponible únicamente en el modo asíncrono. Esto evita que se realicen llamadas de bloqueo entre subprocesos y también los problemas de nivel de sistema que podrían producirse (bajo rendimiento, riesgo de interbloqueo). Al momento de este documento, no queda completamente claro si la comunicación síncrona es realmente necesaria en Flutter y, si lo fuera, la forma en que se produciría.
Al trabajar en el nivel de mensajes binarios, debes preocuparte por detalles delicados como el formato endian y por cómo representar mensajes de nivel superior, como strings o mapas, usando bytes. También debes especificar el nombre de canal correcto cada vez que quieras enviar un mensaje o registrar un controlador. Facilitar esto nos conduce a canales de plataforma:
Un canal de plataforma es un objeto que reúne un nombre de canal y un códec para serializar o deserializar mensajes al formato binario y viceversa.

Canales de mensajes: nombre + códec



Supongamos que quieres enviar y recibir mensajes de strings en lugar de búferes de bytes. Esto se puede hacer usando un canal de mensajes, un tipo de canal de plataforma simple construido con un códec de strings. En el siguiente código se muestra la manera de usar canales de mensajes en ambas direcciones entre Dart, Android e iOS:
// String messages
// Dart side
const channel = BasicMessageChannel<String>('foo', StringCodec());
// Send message to platform and receive reply.final String reply = await channel.send('Hello, world');
print(reply);
// Receive messages from platform and send replies.
channel.setMessageHandler((String message) async {
  print('Received: $message');
  return 'Hi from Dart';
});
// Android side
val channel = BasicMessageChannel<String>(
  flutterView, "foo", StringCodec.INSTANCE)
// Send message to Dart and receive reply.channel.send("Hello, world") { reply ->
  Log.i("MSG", reply)
}
// Receive messages from Dart and send replies.channel.setMessageHandler { message, reply ->
  Log.i("MSG", "Received: $message")
  reply.reply("Hi from Android")
}
// iOS side
let channel = FlutterBasicMessageChannel(
    name: "foo",
    binaryMessenger: controller,
    codec: FlutterStringCodec.sharedInstance())
// Send message to Dart and receive reply.channel.sendMessage("Hello, world") {(reply: Any?) -> Void in
  os_log("%@", type: .info, reply as! String)
}
// Receive messages from Dart and send replies.
channel.setMessageHandler {
  (message: Any?, reply: FlutterReply) -> Void in
  os_log("Received: %@", type: .info, message as! String)
  reply("Hi from iOS")
}
El nombre del canal se especifica únicamente en la construcción del canal. Luego de eso, las llamadas para enviar un mensaje o configurar un controlador de mensajes se pueden realizar sin repetir el nombre del canal. Lo que es más importante, dejamos que la clase de códec de string se ocupe de interpretar los búferes de bytes como strings y viceversa.
Estas son ventajas nobles, pero probablemente estés de acuerdo en que BasicMessageChannel no hace todo eso. Esto es intencional. El código Dart anterior equivale a la siguiente aplicación de los aspectos básicos de mensajería binaria:
const codec = StringCodec();
// Send message to platform and receive reply.final String reply = codec.decodeMessage(
  await BinaryMessages.send(
    'foo',
    codec.encodeMessage('Hello, world'),
  ),
);
print(reply);
// Receive messages from platform and send replies.
BinaryMessages.setMessageHandler('foo', (ByteData message) async {
  print('Received: ${codec.decodeMessage(message)}');
  return codec.encodeMessage('Hi from Dart');
});
Esta observación también se aplica a las implementaciones de canales de mensajes en Android e iOS. Aquí no hay magia:
  • Los canales de mensajes delegan toda la comunicación a la capa de mensajería binaria.
  • Los canales de mensajes no realizan un seguimiento de los controladores registrados.
  • Los canales de mensajes son ligeros y no poseen estado.
  • Dos instancias de canal de mensajes creadas con el mismo nombre de canal y el mismo códec son equivalentes (y una interfiere con la comunicación de la otra).
Por varios motivos históricos, el marco de trabajo de Flutter define cuatro códecs de mensajes diferentes:
  • StringCodec: codifica strings usando UTF-8. Como acabamos de ver, los canales de mensajes con este códec tienen el tipo BasicMessageChannel<String> en Dart.
  • BinaryCodec: al implementar la asignación de identidad en los búferes de bytes, este códec te permite disfrutar de la practicidad de los objetos de canal en casos en los que no necesitas codificación ni decodificación. Los canales de mensajes Dart con este códec tienen el tipo BasicMessageChannel<ByteData>.
  • JSONMessageCodec Se maneja con valores de tipo JSON (strings, números, valores booleanos, valores nulos, listas de esos valores y mapas de esos valores codificados en strings). Las listas y los mapas son heterogéneos y se pueden anidar. Durante la codificación, los valores se convierten en strings JSON y luego en bytes usando UTF-8. Los canales de mensajes Dart tienen el tipo BasicMessageChannel<dynamic> con este códec.
  • StandardMessageCodec: se maneja con valores ligeramente más generalizados que el códec JSON, y también admite mapas y búferes de datos homogéneos (UInt8List, Int32List, Int64List y Float64List) con claves que no son strings. El manejo de números difiere de JSON con valores enteros de Dart que llegan como valores enteros firmados de 32 o 64 bits en la plataforma, según la magnitud, nunca como números de punto flotante. Los valores se codifican en un formato binario personalizado, razonablemente compacto y extensible. El códec estándar está diseñado para ser la opción predeterminada para la comunicación de canales en Flutter. En el caso de JSON, los canales de mensajes de Dart construidos con el códec estándar tienen el tipo BasicMessageChannel<dynamic>.
Como probablemente lo hayas conjeturado, los canales de mensajes funcionan con cualquier implementación de códecs de mensajes que cumplan con un contrato simple. Esto te permite incorporar tu propio códec si es necesario. Deberás implementar codificación y decodificación compatible en Dart, Java/Kotlin y Objective-C/Swift.
La letra chica: la evolución de los códecs. Cada códec de mensajes está disponible en Dart, como parte del marco de trabajo de Flutter, y también en ambas plataformas, como parte de las bibliotecas expuestas por Flutter a tu código Java/Kotlin o Objective-C/Swift. Flutter usa los códecs solo para la comunicación dentro de la app, no como un formato persistente. Esto significa que la forma binaria de los mensajes puede cambiar de una versión de Flutter a la siguiente sin aviso. Por supuesto, las implementaciones de códecs en Dart, Android e iOS evolucionan juntas para garantizar que lo que codifica el emisor pueda ser codificado correctamente por el receptor, en ambas direcciones.
Mensajes nulos: los códecs de mensajes deben admitir y preservar mensajes nulos, ya que esto representa la respuesta predeterminada a un mensaje enviado en un canal para el que no se ha registrado ningún controlador de mensajes en el receptor.
Redacción estática de mensajes en Dart. Un canal de mensajes configurado con el códec de mensajes estándares da dinámica de tipo a los mensajes y las respuestas. Generalmente, explicitarás tus expectativas de tipo mediante la asignación a una variable tipificada:
final String reply1 = await channel.send(msg1);
final int reply2 = await channel.send(msg2);
Sin embargo, hay un inconveniente al trabajar con respuestas que incluyen parámetros de tipo genéricos:
final List<String> reply3 = await channel.send(msg3);      // Fails.final List<dynamic> reply3 = await channel.send(msg3);     // Works.
La primera línea falla en el tiempo de ejecución, a menos que la respuesta sea nula. El códec de mensajes estándares se escribe para listas y mapas heterogéneos. En Dart tienen los tipos de tiempo de ejecución List<dynamic> y Map<dynamic, dynamic>, y Dart 2 evita que dichos valores se asignen a variables con argumentos de tipos más específicos. Esta situación es similar a la deserialización Dart JSON que produce List<dynamic> y Map<String, dynamic>, como lo hace el códec de mensajes JSON.
Las características pueden exponerte a problemas similares:
Future<String> greet() => channel.send('hello, world');    // Fails.Future<String> greet() async {                             // Works.
  final String reply = await channel.send('hello, world');
  return reply;
}
El primer método falla en el tiempo de ejecución, aun cuando la respuesta recibida sea una string. La implementación del canal crea un objeto Future<dynamic> independientemente del tipo de respuesta, y un objeto de este tipo no se puede asignar a una Future<String>.
¿Por qué lo “básico” en BasicMessageChannel? Los canales de mensajes parecen usarse solo en situaciones bastante limitadas en las que se comunica alguna forma de transmisión de eventos homogénea en un contexto implícito. Como eventos de teclado, quizás. Para la mayoría de las aplicaciones de canales de plataforma, necesitarás comunicar no solo valores, sino también lo que quieras que ocurra con cada valor, o la manera en la que desees que el receptor lo interprete. Una manera de lograrlo es hacer que el mensaje represente una llamada al método con el valor como argumento. De esta manera, te convendrá una forma estándar de separar el nombre del método del argumento en el mensaje. También te convendrá una forma estándar de distinguir respuestas de éxito y error. Esto es lo que los canales de métodos hacen por ti. Originalmente, BasicMessageChannel tenía el nombre MessageChannel, pero se le cambió el nombre para evitar confundir MessageChannel con MethodChannel en el código. Al aplicarse con más frecuencia, los canales de métodos conservaron el nombre más corto.

Canales de métodos: sobres estandarizados



Los canales de métodos son canales de plataforma diseñados para invocar partes de código con nombre en Dart y Java/Kotlin o Objective-C/Swift. Los canales de métodos usan “sobres” de mensajes estandarizados para transmitir el nombre del método y los argumentos del emisor al receptor, y para distinguir resultados correctos y erróneos en la respuesta asociada. Los sobres y la carga útil admitidos se definen con clases de códecs de métodos independientes, de la misma manera que los canales de mensajes usan códecs de mensajes.
Eso es todo lo que hacen los canales de métodos: combinar un nombre de canal con un códec.
En particular, no se hacen suposiciones con respecto a qué código se ejecuta al recibir un mensaje en un canal de métodos. Incluso cuando el mensaje represente una llamada a un método, no necesitas invocar un método. Simplemente podrías cambiar el nombre del método y ejecutar unas líneas de código para cada caso.
Nota al margen: esta falta de vinculación implícita o automatizada con los métodos y sus parámetros podría decepcionarte. Y está bien; la decepción puede ser productiva. Supongo que puedes crear una solución desde cero usando procesamiento de anotaciones y generación de código, o quizás puedas reutilizar partes de un marco de trabajo RPC existente. Flutter es de código abierto, ¡no dudes en contribuir! Los canales de métodos están disponibles como destino para tu generación de código, si resultan adecuados. Mientras tanto, son útiles por sí solos en el “modo artesanal”.
Los canales de métodos fueron la respuesta del equipo de Flutter al desafío de definir una API de comunicación ejecutable para que pudiera usarla el ecosistema de complementos inexistentes de ese momento. Queríamos algo que los autores de complementos pudieran comenzar a usar de inmediato, sin necesidad de un gran volumen de código estándar o configuraciones de compilación complicadas. Creo que el concepto de canal de métodos proporciona una respuesta aceptable, pero me sorprendería que continuara siendo la única.
A continuación, se muestra la forma en que usarías un canal de métodos en el caso sencillo de invocar una pequeña parte de código de la plataforma desde Dart. El código se asocia con la barra de nombre que en este caso no es, pero podría haber sido, un nombre de método. Lo que hace es construir una string de saludo y mostrársela a al emisor, de modo que podamos codificar eso con la suposición razonable de que la invocación a la plataforma no fallará (veremos el manejo de errores en más detalle más adelante):
// Invocation of platform methods, simple case.
// Dart side.
const channel = MethodChannel('foo');
final String greeting = await channel.invokeMethod('bar', 'world');
print(greeting);

// Android side.
val channel = MethodChannel(flutterView, "foo")
channel.setMethodCallHandler { call, result ->
  when (call.method) {
    "bar" -> result.success("Hello, ${call.arguments}")
    else -> result.notImplemented()
  }
}
// iOS side.
let channel = FlutterMethodChannel(
  name: "foo", binaryMessenger: flutterView)
channel.setMethodCallHandler {
  (call: FlutterMethodCall, result: FlutterResult) -> Void in
  switch (call.method) {
  case "bar": result("Hello, \(call.arguments as! String)")
  default: result(FlutterMethodNotImplemented)
  }
}
Al agregar casos a las construcciones de interruptores, podemos extender fácilmente lo anterior para controlar varios métodos. La cláusula predeterminada controla la situación en la que se llama a un método desconocido (probablemente debido a un error de programación).
El código Dart anterior es equivalente a lo siguiente:
const codec = StandardMethodCodec();
final ByteData reply = await BinaryMessages.send(
  'foo',
  codec.encodeMethodCall(MethodCall('bar', 'world')),
);
if (reply == null)
  throw MissingPluginException();
else
  print(codec.decodeEnvelope(reply));
Las implementaciones de canales de métodos en Android e iOS son contenedores estrechos similares en torno a llamadas a los aspectos fundamentales de la mensajería binaria. Se usa una respuesta nula para representar un resultado “no implementado”. Esto convenientemente hace que el comportamiento en el extremo receptor sea indiferente respecto de que la invocación fracase en la cláusula predeterminada en el interruptor o no se registre un controlador de llamadas a métodos en el canal.
El valor del argumento del ejemplo es el ámbito de string única. Pero el códec de método predeterminado, acertadamente denominado “códec de método estándar”, usa el códec de mensaje estándar preexistente para codificar valores de carga útil. Esto significa que todos los valores “generalizados de tipo JSON” que se describieron antes se admiten como argumentos del método y como resultados (correctos). En particular, las listas heterogéneas admiten varios argumentos, mientras que los mapas heterogéneos admiten argumentos denominados. El valor predeterminado de los argumentos es nulo. Algunos ejemplos:
await channel.invokeMethod('bar');
await channel.invokeMethod('bar', <dynamic>['world', 42, pi]);
await channel.invokeMethod('bar', <String, dynamic>{
  name: 'world',
  answer: 42,
  math: pi,
}));
El SDK de Flutter incluye dos códecs de métodos:
  • StandardMethodCodec: de forma predeterminada, delega la codificación de los valores de carga útil a StandardMessageCodec. Debido a que este último es extensible, también lo es el anterior.
  • JSONMethodCodec: delega la codificación de los valores de carga útil a JSONMessageCodec.
Puedes configurar canales de métodos con cualquier códec de método, incluidos los personalizados. Para comprender plenamente lo que incluye la implementación de un códec, observemos la forma en que se abordan los errores a nivel de la API de canales de métodos ampliando el ejemplo anterior con un método baz falible:
// Method calls with error handling.
// Dart side.
const channel = MethodChannel('foo');
// Invoke a platform method.const name = 'bar'; // or 'baz', or 'unknown'
const value = 'world';
try {
  print(await channel.invokeMethod(name, value));
} on PlatformException catch(e) {
  print('$name failed: ${e.message}');
} on MissingPluginException {
  print('$name not implemented');
}
// Receive method invocations from platform and return results.
channel.setMethodCallHandler((MethodCall call) async {
  switch (call.method) {
    case 'bar':
      return 'Hello, ${call.arguments}';
    case 'baz':
      throw PlatformException(code: '400', message: 'This is bad');
    default:
      throw MissingPluginException();
  }
});

// Android side.
val channel = MethodChannel(flutterView, "foo")
// Invoke a Dart method.
val name = "bar" // or "baz", or "unknown"
val value = "world"
channel.invokeMethod(name, value, object: MethodChannel.Result {
  override fun success(result: Any?) {
    Log.i("MSG", "$result")
  }
  override fun error(code: String?, msg: String?, details: Any?) {
    Log.e("MSG", "$name failed: $msg")
  }
  override fun notImplemented() {
    Log.e("MSG", "$name not implemented")
  }
})
// Receive method invocations from Dart and return results.
channel.setMethodCallHandler { call, result ->
  when (call.method) {
    "bar" -> result.success("Hello, ${call.arguments}")
    "baz" -> result.error("400", "This is bad", null)
    else -> result.notImplemented()
  }
}
// iOS side.
let channel = FlutterMethodChannel(
  name: "foo", binaryMessenger: flutterView)
// Invoke a Dart method.let name = "bar" // or "baz", or "unknown"
let value = "world"
channel.invokeMethod(name, arguments: value) {
  (result: Any?) -> Void in
  if let error = result as? FlutterError {
    os_log("%@ failed: %@", type: .error, name, error.message!)
  } else if FlutterMethodNotImplemented.isEqual(result) {
    os_log("%@ not implemented", type: .error, name)
  } else {
    os_log("%@", type: .info, result as! NSObject)
  }
}
// Receive method invocations from Dart and return results.channel.setMethodCallHandler {
  (call: FlutterMethodCall, result: FlutterResult) -> Void in
  switch (call.method) {
  case "bar": result("Hello, \(call.arguments as! String)")
  case "baz": result(FlutterError(
    code: "400", message: "This is bad", details: nil))
  default: result(FlutterMethodNotImplemented)
}
Los errores son triplos (código, mensaje y detalles) en los que el código y el mensaje son strings. El mensaje está orientado al consumo humano y el código... bueno, al código. Los detalles del error tienen un valor predeterminado, generalmente nulo, que está limitado únicamente por los tipos de valor que el códec admite.
La letra chica: excepciones. Las excepciones no detectadas que se producen en un controlador de llamadas de métodos de Dart o Android son detectadas por la implementación del canal, se registran y se muestra un resultado de error al emisor. Las excepciones no detectadas que se producen en los controladores de resultados se registran.
Codificación de sobres: La manera en la que un códec de método codifica sus sobres es un detalle de implementación similar a la manera en la que los códecs de mensajes convierten mensajes en bytes. A modo de ejemplo, un códec de método podría usar listas: las llamadas a métodos pueden codificarse como listas de dos elementos [nombre del método y argumentos]; los resultados correctos como listas de un elemento [resultado]; los resultados de errores como listas de tres elementos [código, mensaje y detalles]. Un códec de método luego puede implementarse simplemente mediante delegación a un códec de mensaje subyacente que admita al menos listas, strings y valores null. Los argumentos de llamada, los resultados correctos y los detalles de error de un método serían valores arbitrarios admitidos por ese códec de mensaje.
Diferencias de API: en los ejemplos de código anteriores se destaca que los canales de métodos entregan resultados muy diferentes en Dart, Android e iOS:
  • En Dart, la invocación está controlada por un método que muestra un valor futuro. El valor futuro se completa con el resultado de la llamada en casos exitosos, con un PlatformException en casos de error y un MissingPluginException en casos sin implementación.
  • En Android, la invocación está controlada por un método que toma un argumento del callback. La interfaz del callback define tres métodos de los cuales se llama a uno, según el resultado. El código de cliente implementa la interfaz del callback para definir lo que debería ocurrir ante un caso exitoso, de error o sin implementación.
  • En iOS, la invocación se aborda de forma similar mediante un método que toma un argumento del callback. Sin embargo, aquí el callback es una función de un solo argumento que recibe una instancia FlutterError, la constante FlutterMethodNotImplemented o, en caso de que haya éxito, el resultado de la invocación. El código de cliente proporciona un bloque con lógica condicional para abordar los diferentes casos, según sea necesario.
Estas diferencias, reflejadas también en la forma en la que se escriben los controladores de llamadas de mensajes, surgieron como concesiones a los estilos de los lenguajes de programación (Dart, Java y Objective-C) utilizados para las implementaciones de canales de métodos del SDK de Flutter. Rehacer las implementaciones en Kotlin y Swift podría eliminar algunas de las diferencias, pero se debe tener precaución para evitar que resulte más difícil usar canales de métodos de Java y Objective-C.

Canales de eventos: transmisión



Un canal de eventos es un canal de plataforma especializado pensado para el caso de uso en el que se exponen eventos de la plataforma a Flutter como una transmisión de Dart. El SDK de Flutter actualmente no admite el caso simétrico de exposición de transmisiones de Dart a código de la plataforma, aunque esto podría crearse si surgiera la necesidad.
Aquí te mostramos la forma en que consumirías una transmisión de eventos de la plataforma en Dart:
// Consuming events on the Dart side.
const channel = EventChannel('foo');
channel.receiveBroadcastStream().listen((dynamic event) {
  print('Received event: $event');
}, onError: (dynamic error) {
  print('Received error: ${error.message}');
});
En el siguiente código se muestra la manera de producir eventos en la plataforma usando eventos de sensor en Android como ejemplo. La principal inquietud es garantizar que recibamos eventos de la fuente de la plataforma (en este caso, el administrador de sensores) y los enviemos a través del canal de eventos de forma precisa cuando 1) haya al menos un receptor de transmisiones en Dart y 2) esté en ejecución la Activity ambiente. Empaquetar la lógica necesaria en una sola clase aumenta las probabilidades de hacer esto correctamente:
// Producing sensor events on Android.
// SensorEventListener/EventChannel adapter.class SensorListener(private val sensorManager: SensorManager) :
  EventChannel.StreamHandler, SensorEventListener {
  private var eventSink: EventChannel.EventSink? = null

  // EventChannel.StreamHandler methods
  override fun onListen(
    arguments: Any?, eventSink: EventChannel.EventSink?) {
    this.eventSink = eventSink
    registerIfActive()
  }
  override fun onCancel(arguments: Any?) {
    unregisterIfActive()
    eventSink = null
  }

  // SensorEventListener methods.  override fun onSensorChanged(event: SensorEvent) {
    eventSink?.success(event.values)
  }
  override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
    if (accuracy == SensorManager.SENSOR_STATUS_ACCURACY_LOW)
      eventSink?.error("SENSOR", "Low accuracy detected", null)
  }
  // Lifecycle methods.
  fun registerIfActive() {
    if (eventSink == null) return
    sensorManager.registerListener(
      this,
      sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE),
      SensorManager.SENSOR_DELAY_NORMAL)
  }
  fun unregisterIfActive() {
    if (eventSink == null) return
    sensorManager.unregisterListener(this)
  }
}
// Use of the above class in an Activity.class MainActivity: FlutterActivity() {
  var sensorListener: SensorListener? = null

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    GeneratedPluginRegistrant.registerWith(this)
    sensorListener = SensorListener(
      getSystemService(Context.SENSOR_SERVICE) as SensorManager)
    val channel = EventChannel(flutterView, "foo")
    channel.setStreamHandler(sensorListener)
  }

  override fun onPause() {
    sensorListener?.unregisterIfActive()
    super.onPause()
  }

  override fun onResume() {
    sensorListener?.registerIfActive()
    super.onResume()
  }
}
Si usas el paquete android.arch.lifecycle en tu app, podrías hacer que SensorListener sea más independiente al convertirlo en un LifecycleObserver.
La letra chica: la vida útil de un controlador de transmisiones. El controlador de transmisiones de la plataforma tiene dos métodos, onListen y onCancel, que se invocan cada vez que la cantidad de receptores de la transmisión de Dart va de cero a uno y nuevamente a cero, respectivamente. Esto puede ocurrir varias veces. La implementación del controlador de transmisiones debe comenzar a enviar eventos al receptor de eventos cuando se llama al primero, y debe detenerse cuando se llama al segundo. Además, debe pausarse cuando el componente de la app ambiente no se encuentra en ejecución. El código anterior proporciona un ejemplo típico. Tras bambalinas, un controlador de transmisiones es, por supuesto, un controlador de mensajes binarios, registrado con la vista Flutter usando el nombre del canal de eventos.
Códec: un canal de eventos se configura con un códec de métodos, lo que nos permite distinguir entre eventos exitosos y erróneos de la misma manera que los canales de mensajes pueden distinguir entre resultados correctos y erróneos.
Argumentos y errores del controlador de transmisiones. Los métodos onListen y onCancel del controlador de transmisiones se invocan mediante invocaciones del canal de métodos. Por lo tanto, debemos controlar las llamadas a métodos de Dart a la plataforma y los mensajes de eventos en la dirección inversa, todo en el mismo canal lógico. Esta configuración permite la retransmisión de argumentos a ambos métodos de control y el informe de errores. En Dart, los argumentos (si existieran) se proporcionan en la llamada a receiveBroadcastStream. Esto significa que se especifican una sola vez, independientemente de la cantidad de invocaciones de onListen y onCancel que tengan lugar durante la duración de la transmisión. Los errores informados se registran.
Fin de la transmisión. Un receptor de eventos tiene un método endOfStream que se puede invocar para indicar que no se enviarán más eventos de éxito ni error. Para esto se usa el mensaje binario null. Cuando se recibe en Dart, la transmisión se cierra.
Vida útil de una transmisión: la transmisión de Dart está respaldada por un controlador de transmisiones alimentado con los mensajes entrantes del canal de la plataforma. Un controlador de mensajes binarios se registra usando el nombre del canal de eventos para recibir mensajes entrantes solo mientras hay receptores para la transmisión.

Pautas de uso

Prefijar nombres de canales por dominio para exclusividad

Los nombres de los canales son solo strings, pero deben ser únicos en todos los objetos de canal que se usen para diferentes fines en tu app. Puedes lograr esto usando cualquier esquema de denominación adecuado. Sin embargo, el enfoque recomendado para canales usados en complementos consiste en emplear un prefijo de nombre de dominio y nombre de complemento, como some.body.example.com/sensors/foo, para el canal foo empleado por el complemento de sensores desarrollado por some.body en example.com. Esto permite a los consumidores de complementos combinar cualquier cantidad de complementos en sus apps sin correr el riesgo de que ocurran colisiones entre nombres de canales.

Considerar el tratamiento de canales de plataforma como vías de comunicación dentro de módulos

El código usado para invocar llamadas de procedimientos remotos en sistemas distribuidos tiene una apariencia superficialmente similar al código que se usa en los canales de métodos: se invoca un método proporcionado por una string y se serializan los argumentos y resultados. Debido a que los componentes de los sistemas distribuidos generalmente se desarrollan e implementan de forma independiente, es fundamental controlar estrictamente las solicitudes y las respuestas, lo que normalmente se realiza siguiendo el estilo de comprobación y registro en ambos lados de la red.
Los canales de plataforma, por otro lado, integran tres partes de código que se desarrollan e implementan juntas, en un solo componente.
Java/Kotlin ↔ Dart ↔ Objective-C/Swift
De hecho, normalmente tiene sentido empaquetar un trío como este en un solo módulo de código, como un complemento de Flutter. Esto significa que la necesidad de comprobación de argumentos y resultados en invocaciones de canales de métodos debe ser comparable a la necesidad de tales comprobaciones en llamadas de métodos normales dentro del mismo módulo.
Dentro de los módulos, nuestra principal inquietud es brindar protección contra errores de programación que estén fuera del control de las comprobaciones estáticas del compilador y pasen inadvertidas en el tiempo de ejecución hasta echar a perder algo a nivel externo en el tiempo o el espacio. Un estilo de codificación razonable consiste en realizar suposiciones explícitas usando tipos o aserciones, lo cual permite que las fallas sean rápidas y limpias; p. ej. con una excepción. Los detalles varían, por supuesto, según el lenguaje de programación. Ejemplos:
  • Si se prevé que un valor recibido a través de un canal de plataforma tenga un tipo determinado, asígnalo de inmediato a una variable de ese tipo.
  • Si se prevé que un valor recibido a través de un canal de la plataforma sea no nulo, puedes configurar todo para que se eliminen las referencias de inmediato o afirmar que es no nulo antes de almacenarlo para más adelante. Según tu lenguaje de programación, podrías asignarlo a una variable de un tipo que no admita nulidad.
Dos ejemplos simples:
// Dart: we expect to receive a non-null List of integers.
for (final int n in await channel.invokeMethod('getFib', 100)) {
  print(n * n);
}
// Android: we expect non-null name and age arguments for
// asynchronous processing, delivered in a string-keyed map.
channel.setMethodCallHandler { call, result ->
  when (call.method) {
    "bar" -> {
      val name : String = call.argument("name")
      val age : Int = call.argument("age")
      process(name, age, result)
    }
    else -> result.notImplemented()
  }
}
:
fun process(name: String, age: Int, result: Result) { ... }
El código Android explota el método de argumento T (clave String), escrito genéricamente como <T>, de MethodCall que busca la clave en los argumentos, que se prevén como un mapa, y transmite el valor encontrado al tipo de destino (sitio de llamada). Si esto falla por cualquier motivo, se produce una excepción adecuada. Al producirse en un controlador de llamadas a métodos, se registraría y se enviaría un resultado de error a Dart.

No remedar canales de plataforma

(Juego de palabras intencionado). Al escribir pruebas de unidad para código Dart que usa canales de plataforma, una reacción automática puede ser simular el objeto de canal, lo que se haría con una conexión de red.
Puedes hacerlo, pero no es necesario simular los objetos de canal para que funcionen correctamente en las pruebas de unidad. Como alternativa, puedes registrar controladores de mensajes o métodos simulados para que cumplan la función de la plataforma durante una prueba determinada. Aquí te mostramos una prueba de unidad de una función hello que se supone que invoca el método de barra en el canal foo:
test('gets greeting from platform', () async {
  const channel = MethodChannel('foo');
  channel.setMockMethodCallHandler((MethodCall call) async {
    if (call.method == 'bar')
      return 'Hello, ${call.arguments}';
    throw MissingPluginException();
  });
  expect(await hello('world'), 'Platform says: Hello, world');
});
Para probar código que configure controladores de mensajes o métodos, puedes sintetizar mensajes entrantes usando BinaryMessages.handlePlatformMessage. Actualmente, este método no se duplica en los canales de plataforma, aunque se podría hacer fácilmente como se indica en el código siguiente. El código define una prueba de unidad de una clase Hello que se supone que recopila argumentos entrantes de llamadas a la barra de método en el canal foo, mientras muestra saludos:
test('collects incoming arguments', () async {
  const channel = MethodChannel('foo');
  final hello = Hello();
  final String result = await handleMockCall(
    channel,
    MethodCall('bar', 'world'),
  );
  expect(result, contains('Hello, world'));
  expect(hello.collectedArguments, contains('world'));
});
// Could be made an instance method on class MethodChannel.
Future<dynamic> handleMockCall(
  MethodChannel channel,
  MethodCall call,
) async {
  dynamic result;
  await BinaryMessages.handlePlatformMessage(
    channel.name,
    channel.codec.encodeMethodCall(call),
    (ByteData reply) {
      if (reply == null)
        throw MissingPluginException();
      result = channel.codec.decodeEnvelope(reply);
    },
  );
  return result;
}
En los dos ejemplos anteriores se declara el objeto del canal en la prueba de unidad. Esto funciona bien, a menos que te preocupes por la duplicación del nombre del canal y el códec, ya que todos los objetos del canal con el mismo nombre y códec son equivalentes. Puedes evitar la duplicación al declarar el canal como const en algún lugar visible para tu código de producción y la prueba.
Lo que no necesitas es proporcionar una manera de inyectar un canal simulado en tu código de producción.

Considerar las pruebas automatizadas para tu interacción con plataformas

Los canales de plataforma son bastante simples, pero para hacer que todo funcione desde tu IU de Flutter a través de una Dart API personalizada respaldada por una implementación Java/Kotlin y Objective-C/Swift independiente se requiere atención. A su vez, en la práctica, para mantener la configuración funcionando mientras realizas cambios en tu app se necesitarán pruebas automatizadas a fin de brindar protección contra regresiones. Esto no se puede lograr solo con la prueba de unidades, ya que debes ejecutar una app real para los canales de plataforma a fin de poder comunicarte con ella.
Flutter incluye el marco de trabajo para pruebas de integración de flutter_driver, el cual te permite probar aplicaciones de Flutter que se ejecutan en dispositivos reales y emuladores. Pero flutter_driver no está integrado actualmente con otros marcos de trabajo para permitir pruebas en Flutter y en los componentes de plataforma. Confío en que esta es un área que Flutter mejorará en el futuro.
En algunas situaciones, puedes usar flutter_driver tal como está para probar tu uso de canales de plataforma. Esto requiere que tu interfaz de usuario de Flutter se pueda usar para activar cualquier interacción con la plataforma y que luego se actualice con suficiente detalle para permitir que tu prueba constate el resultado de la interacción.
Si no estás en esa situación, o si empaquetas tu uso de canales de plataforma como un complemento de Flutter para el que quieres una prueba de módulo, puedes escribir una app de Flutter sencilla para pruebas. Esa app debe tener las características anteriores y debe poder ejecutarse usando flutter_driver. Encontrarás un ejemplo en el repositorio de GitHub de Flutter.

Mantener la plataforma lista para llamadas síncronas entrantes

Los canales de la plataforma son únicamente asíncronos. Sin embargo, hay algunas API de plataforma que realizan llamadas síncronas a los componentes de tu app host y les solicitan información o ayuda, u ofrecen diferentes oportunidades. Un ejemplo es Activity.onSaveInstanceState, en Android. La cualidad “síncrono” implica que todo debe realizarse antes de que se muestre la llamada entrante. Ahora quizá quieras incluir información de Dart en ese procesamiento, pero es muy tarde para comenzar a enviar mensajes asíncronos una vez que la llamada síncrona está activa en el subproceso principal de la IU.
El enfoque que usa Flutter, de forma más notoria para información sobre accesibilidad y semántica, consiste en enviar de forma proactiva información actualizada (o actualizaciones) a la plataforma siempre que la información cambie en Dart. Luego, cuando llega la llamada síncrona, la información de Dart ya está presente y disponible para el código de la plataforma.

Recursos

Documentación sobre API de Flutter:
Guías:
  • En el sitio web flutter.io se ofrece documentación sobre cómo usar canales de métodos y las conversiones de valores de Dart, Android e iOS que intervienen en el uso del códec de métodos estándar.
  • The Boring Flutter Development Show, Episodio 6: Packages and plugins es un video de YouTube en el que se muestra, en tiempo real, la implementación de un complemento de Flutter con canales de plataforma.
Ejemplos de código: