Перевод оригинальной статьи, пера клавиатуры Mikkel Ravn.

«Приятный UI. Но как Flutter работает с API для конкретных платформ?»

Flutter предлагает вам написать мобильное приложение на языке программирования Dart и собрать его как для Android, так и для iOS. Но Dart не компилируется в байт-код Dalvik для Android, и вы не были благословлены привязками Dart/Objective-C на iOS. Это означает, что ваш код Dart написан без прямого доступа к платформенным API iOS Cocoa Touch и Android SDK.

Это не то что бы существенная проблема, если вы просто пишете Dart для отрисовки пикселей на экране. Фреймворк Flutter и лежащий в его основе графический движок сами по себе отлично с этим справляются. Также не проблема, если все, что вы делаете, кроме рисования пикселей, представляет собой файловый или сетевой ввод-вывод и связанную с ним бизнес-логику. Язык Dart, среда выполнения и библиотеки помогут вам в этом.

Но нетривиальные приложения требуют более глубокой интеграции с хост-платформой:

  • уведомления, жизненный цикл приложения, глубокие ссылки, …

  • датчики, камера, аккумулятор, геолокация, звук, подключение, …

  • обмен информацией с другими приложениями, запуск других приложений, ...

  • сохраненные настройки, специальные папки, информация об устройстве, …

Список длинный и широкий, и кажется, что он растет с каждым выпуском платформы.

Доступ ко всем этим API-интерфейсам платформы может быть встроен в саму структуру Flutter. Но это сделало бы Flutter более громоздким, и дало бы ему больше причин для изменений. На практике это, скорее всего, приведет к отставанию Flutter от последней версии платформы. Или приземление авторов приложений с неудовлетворительной оберткой «наименьшего общего знаменателя» API платформы. Или озадачивать новичков громоздкими абстракциями, чтобы скрыть различия между платформами. Или фрагментация версий. Или баги.

Если подумать, наверное, все вышеперечисленное.

Команда Flutter выбрала другой подход. Он не так уж много делает, но он прост, универсален и полностью в ваших руках.

Во-первых, Flutter содержится окружающим его приложением Android или iOS. Части приложения Flutter обернуты в стандартные компоненты для конкретных платформ, такие как View на Android и UIViewController на iOS. Таким образом, хотя Flutter предлагает вам написать свое приложение на Dart, вы можете делать столько же или меньше на Java/Kotlin или Objective-C/Swift, сколько вам угодно, в хост-приложении, работая непосредственно поверх API-интерфейсов для конкретной платформы.

Во-вторых, каналы платформы предоставляют простой механизм для связи между вашим кодом Dart и кодом платформы вашего хост-приложения. Это значит, что вы можете предоставить сервис платформы в коде своего хост-приложения и вызвать его со стороны Dart. Или наоборот.

И, в-третьих, плагины позволяют создать Dart API, поддерживаемый реализацией Android, написанной на Java или Kotlin, и реализацией iOS, написанной на Objective-C или Swift, и упаковать это как тройку Flutter/Android/iOS, склеенную вместе, используя каналы платформы. Это означает, что вы можете повторно использовать, делиться и распространять ваш подход в том, как Flutter должен использовать API конкретной платформы.

Эта статья представляет собой подробное введение в каналы платформы. Начиная с основ обмена сообщениями Flutter, я представлю концепции канала сообщений/методов/событий и обсужу некоторые соображения по проектированию API. Здесь не будет списков API, вместо них будут короткие образцы кода для повторного использования путем ctrl+c, ctrl+v. Приведен краткий список рекомендаций по использованию, основанный на моем опыте работы с репозиторием flutter/plugins на GitHub в качестве члена команды Flutter. Статья завершается списком дополнительных ресурсов, включая ссылки на справочные API DartDoc/JavaDoc/ObjcDoc. (прим. пер.: не завершается. Все ссылки, указанные в источниках оригинала устарели и никуда не ведут)

API каналов платформы

В большинстве случаев вы, вероятно, будете использовать каналы методов для общения с платформой. Но поскольку многие из их свойств получены из более простых каналов сообщений и лежащих в их основе бинарных основ обмена сообщениями, я начну с них.

Основы: асинхронность, бинарные сообщения.

На самом базовом уровне Flutter взаимодействует с кодом платформы, используя асинхронную передачу сообщений с двоичными содержанием, что означает, что полезная нагрузка сообщения представляет собой байтовый буфер. Чтобы различать сообщения, используемые для разных целей, каждое сообщение отправляется по логическому «каналу», который представляет собой просто строку имени. В приведенных ниже примерах используется имя канала foo.

// Отправить бинарное сообщение из Dart платформе

final WriteBuffer buffer = WriteBuffer()
  ..putFloat64(3.1415)
  ..putInt32(12345678);
final ByteData message = buffer.done();
await BinaryMessages.send('foo', message);
print('Message sent, reply ignored');

На Android такое сообщение можно получить как java.nio.ByteBuffer, используя следующий код Kotlin:

// Получить бинарные сообщения из Dart на Android
// Этот код может быть добавлен к подклассу FlutterActivity, 
// обычно в 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)
}

API ByteBuffer поддерживает считывание примитивных значений при автоматическом перемещении текущей позиции чтения. История с iOS аналогична; предложения по улучшению моего слабого Swift очень приветствуются:

// Получение бинарных сообщений от Dart на iOS.
// Этот код можно добавить в подкласс FlutterAppDelegate,
// обычно в 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)
}

Связь является двунаправленной, поэтому вы можете отправлять сообщения и в обратном направлении, с Java/Kotlin или Objective-C/Swift на Dart. Изменение направления вышеуказанной настройки выглядит следующим образом:

// Послать бинарное сообщение от Android
val message = ByteBuffer.allocateDirect(12)
message.putDouble(3.1415)
message.putInt(123456789)
flutterView.send("foo", message) { _ ->
  Log.i("MSG", "Message sent, reply ignored")
}

// Послать бинарное сообщение от 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")
}

// Получать бинарные сообщения от платформы
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;
});

Мелким шрифтом. Обязательные ответы. Каждое отправленное сообщение включает асинхронный ответ от получателя. В приведенных выше примерах нет интересного значения для обратной связи, но null ответ необходим для завершения Future в Dart и для выполнения двух колбеков платформы.

Потоки. Сообщения и ответы принимаются и должны отправляться в основном потоке пользовательского интерфейса платформы. В Dart есть только один поток для каждого изолята, то есть для каждого Flutter view, поэтому нет путаницы в том, какой поток здесь использовать.

Исключения. Любое необработанное исключение, созданное в обработчике сообщений Dart или Android, перехватывается фреймворком, регистрируется в журнале, и отправителю отправляется null-ответ. Неперехваченные исключения, созданные в обработчиках ответов, регистрируются.

Время жизни обработчика. Зарегистрированные обработчики сообщений сохраняются и поддерживаются вместе с представлением Flutter (имеется в виду изолят Dart, экземпляр Android FlutterView и iOS FlutterViewController). Вы можете сократить жизнь обработчика, отменив его регистрацию: просто установите null (или отличный) обработчик, используя то же имя канала.

Уникальность обработчика. Обработчики хранятся в хэш-мапе с ключом по имени канала, поэтому на канал может быть не более одного обработчика. На сообщение, отправленное по каналу, для которого на принимающей стороне не зарегистрирован обработчик сообщений, отвечает автоматически, используя null-ответ.

Синхронная коммуникация. Связь с платформой доступна только в асинхронном режиме. Это позволяет избежать блокирующих вызовов между потоками и проблем на системном уровне, которые могут возникнуть (низкая производительность, риск взаимоблокировок). На момент написания этой статьи не совсем понятно, действительно ли во Flutter нужна синхронная связь и если да, то в какой форме.


Работая на уровне двоичных сообщений, вам нужно беспокоиться о деликатных деталях, таких как порядок следования байтов и о том, как представлять сообщения более высокого уровня, такие как строки или карты, с использованием байтов. Вам также необходимо указывать правильное имя канала всякий раз, когда вы хотите отправить сообщение или зарегистрировать обработчик. Упрощая, это приводит нас к каналам платформы:

Канал платформы — это объект, который объединяет имя канала и кодек для сериализации/десериализации сообщений в двоичную форму и обратно.

Каналы сообщений: имя + кодек

Предположим, вы хотите отправлять и получать строковые сообщения вместо байтовых буферов. Это можно сделать с помощью канала сообщений, простого типа канала платформы, созданного с помощью строкового кодека. В приведенном ниже коде показано, как использовать каналы сообщений в обоих направлениях в Dart, Android и iOS:

// Строкове сообщения
// сторона Dart
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
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 сторона
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)
}

// Получать сообщния от Dart и отправлять ответы.
channel.setMessageHandler {
  (message: Any?, reply: FlutterReply) -> Void in
  os_log("Received: %@", type: .info, message as! String)
  reply("Hi from iOS")
}

Имя канала указывается только при создании канала. После этого, вызовы для отправки сообщения или установки обработчика сообщений можно выполнять без повторения имени канала. Что еще более важно, мы оставляем классу кодека строк решать, как интерпретировать буферы байтов как строки и наоборот.

Безусловно, это благородные преимущества, но вы, вероятно, согласитесь, что BasicMessageChannel не так уж и много делает. Что специально. Приведенный выше код Dart эквивалентен следующему использованию двоичных основ обмена сообщениями:

const codec = StringCodec();
// Отправить сообщение платформе и получить ответ.
final String reply = codec.decodeMessage(
  await BinaryMessages.send(
    'foo',
    codec.encodeMessage('Hello, world'),
  ),
);
print(reply);
// Получать сообщения от платформы и отправлять ответы.
BinaryMessages.setMessageHandler('foo', (ByteData message) async {
  print('Received: ${codec.decodeMessage(message)}');
  return codec.encodeMessage('Hi from Dart');
});

Это замечание относится и к реализациям каналов сообщений для Android и iOS. В этом не замешано никакой магии:

  • Каналы сообщений делегируют уровню обмена бинарного общения для всех коммуникаций.

  • Каналы сообщений не отслеживают сами зарегистрированные обработчики.

  • Каналы сообщений легковесны и не имеют состояния.

  • Два экземпляра канала сообщений, созданные с одинаковым именем канала и кодеком, эквивалентны (и мешают обмену данными друг с другом).

По разным историческим причинам платформа Flutter определяет четыре разных кодека сообщений:

  • StringCodec Кодирует строки с использованием UTF-8. Как мы только что видели, каналы сообщений с этим кодеком имеют тип BasicMessageChannel<String> в Dart.

  • BinaryCodec Реализуя сопоставление идентификаторов в байтовых буферах, этот кодек позволяет вам наслаждаться удобством объектов канала в тех случаях, когда вам не требуется кодирование/декодирование. Каналы сообщений Dart с этим кодеком имеют тип BasicMessageChannel<ByteData>.

  • JSONMessageCodec Работает с «JSON-подобными» значениями (строки, числа, логические значения, null, списки этих значений и мапы строка-ключ с этими данными). Списки и мапы неоднородны и могут быть вложены друг в друга. Во время кодирования значения преобразуются в строки JSON, а затем в байты с использованием UTF-8. Каналы сообщений Dart имеют тип BasicMessageChannel<dynamic> с этим кодеком.

  • StandardMessageCodec Работает с несколько более обобщенными значениями, чем кодек JSON, поддерживая также однородные буферы данных (UInt8List, Int32List, Int64List, Float64List) и мапы с нестроковыми ключами. Обработка чисел отличается от JSON тем, что целые числа Dart поступают на платформу как 32- или 64-битные целые числа со знаком, в зависимости от величины — никогда как числа с плавающей запятой. Значения кодируются в специальном, достаточно компактном и расширяемом двоичном формате. Стандартный кодек предназначен для выбора по умолчанию для канала связи во Flutter. Что касается JSON, каналы сообщений Dart, созданные с использованием стандартного кодека, имеют тип BasicMessageChannel<dynamic>.

Как вы могли догадаться, каналы сообщений работают с любой реализацией кодека сообщений, удовлетворяющей простому контракту. Это позволяет вам подключить свой собственный кодек, если вам это нужно. Вам нужно будет реализовать совместимое кодирование и декодирование в Dart, Java/Kotlin и Objective-C/Swift.

Мелким шрифтом. Эволюция кодека. Каждый кодек сообщения доступен в Dart как часть инфраструктуры Flutter, а также на обеих платформах как часть библиотек, предоставляемых Flutter для вашего кода Java/Kotlin или Objective-C/Swift. Flutter использует кодеки только для связи внутри приложения, а не в качестве формата сохранения. Это означает, что двоичная форма сообщений может меняться от одного выпуска Flutter к другому без предупреждения. Конечно, реализации кодеков Dart, Android и iOS разрабатываются вместе, чтобы гарантировать, что то, что закодировано отправителем, может быть успешно декодировано получателем в обоих направлениях.

Сообщения null. Любой кодек сообщения должен поддерживать и сохранять null сообщения, поскольку это ответ по умолчанию на сообщение, отправленное по каналу, для которого на принимающей стороне не зарегистрирован обработчик сообщений.

Статическая типизация сообщений в Dart. Канал сообщений, сконфигурированный со стандартным кодеком сообщений, придает dynamic тип сообщениям и ответам. Вы часто делаете свои ожидания типа явными, присваивая типизированной переменной:

final String reply1 = await channel.send(msg1);
final int reply2 = await channel.send(msg2);

Но есть предостережение при работе с ответами, содержащими параметры универсального (generic) типа:

final List<String> reply3 = await channel.send(msg3);      // Не работает.
final List<dynamic> reply3 = await channel.send(msg3);     // Работает.

Первая строка завершается ошибкой во время выполнения, если ответ не равен null. Стандартный кодек сообщений написан для разнородных списков и мап. Со стороны Dart они имеют типы времени выполнения List<dynamic> и Map<dynamic, dynamic>, а Dart 2 предотвращает присвоение таких значений переменным с более конкретными аргументами типа. Эта ситуация аналогична десериализации Dart JSON, которая создает List<dynamic> и Map<String, dynamic> — как и кодек сообщения JSON.

С Future у вас могут возникнуть похожие проблемы:

Future<String> greet() => channel.send('hello, world');    // Не работает.
Future<String> greet() async {                             // Работает.
  final String reply = await channel.send('hello, world');
  return reply;
}

Первый метод дает сбой во время выполнения, даже если полученный ответ является строкой. Реализация канала создает Future<dynamic> независимо от типа ответа, и такой объект не может быть назначен Future<String>.

Почему «basic» в BasicMessageChannel? Каналы сообщений, по-видимому, используются только в довольно ограниченных ситуациях, когда вы передаете некоторую форму однородного потока событий в подразумеваемом контексте. Например, события клавиатуры. Для большинства приложений каналов платформы вам потребуется сообщать не только значения, но и то, что вы хотите, чтобы произошло с каждым значением, или как вы хотите, чтобы оно интерпретировалось получателем. Один из способов сделать это состоит в том, чтобы сообщение представляло вызов метода со значением в качестве аргумента. Поэтому вам понадобится стандартный способ отделения имени метода от аргумента в сообщении. И вам также понадобится стандартный способ различать успешные и ошибочные ответы. Это то, что метод каналы делают для вас. Первоначально BasicMessageChannel назывался MessageChannel, но был переименован, чтобы не путать MessageChannel с MethodChannel в коде. Будучи более общеприменимыми, каналы методов сохранили более короткие названия.

Метод каналы: стандартизированные конверты

Каналы методов — это каналы платформы, предназначенные для вызова именованных фрагментов кода в Dart и Java/Kotlin или Objective-C/Swift. Каналы методов используют стандартизированные «конверты» сообщений для передачи имени метода и аргументов от отправителя к получателю, а также для различения успешных и ошибочных результатов в соответствующем ответе. Конверты и поддерживаемые полезные данные определяются отдельными классами кодеков методов, аналогично тому, как каналы сообщений используют кодеки сообщений.

Это все, что делают каналы метода: объединяют имя канала с кодеком.

В частности, не делается никаких предположений о том, какой код выполняется при получении сообщения на канале метода. Несмотря на то, что сообщение представляет собой вызов метода, вам не нужно вызывать метод. Вы можете просто включить имя метода и выполнить несколько строк кода для каждого случая.

Примечание. Это отсутствие подразумеваемой или автоматической привязки к методам и их параметрам может вас разочаровать. Это нормально, разочарование может быть продуктивным. Я предполагаю, что вы можете создать такое решение с нуля, используя обработку аннотаций и генерацию кода, или, возможно, вы можете повторно использовать части существующей инфраструктуры RPC. Flutter - open source, не стесняйтесь вносить свой вклад! Каналы методов доступны в качестве цели для генерации кода, если они соответствуют всем требованиям. А пока они полезны сами по себе в «ручном режиме».

Каналы методов были ответом команды Flutter на задачу определения работающего коммуникационного API для использования несуществующей в то время экосистемы плагинов. Мы хотели что-то, что авторы плагинов могли бы начать использовать сразу, без большого количества шаблонов или сложной настройки сборки. Я думаю, что концепция канала метода дает достойный ответ, но я был бы удивлен, если бы он остался единственным ответом.


Вот как вы могли бы использовать канал метода в простом случае вызова части кода платформы из Dart. Код связан со строкой имени, которая в данном случае не является именем метода, но могла бы им быть. Все, что он делает, — это создает строку приветствия и возвращает ее вызывающей стороне, поэтому мы можем кодировать это с разумным предположением, что вызов платформы не завершится ошибкой (обработку ошибок мы рассмотрим ниже):

// Вызов методов платформы, простой пример.
// Dart сторона.
const channel = MethodChannel('foo');
final String greeting = await channel.invokeMethod('bar', 'world');
print(greeting);

// Android сторона.
val channel = MethodChannel(flutterView, "foo")
channel.setMethodCallHandler { call, result ->
  when (call.method) {
    "bar" -> result.success("Hello, ${call.arguments}")
    else -> result.notImplemented()
  }
}
// iOS сторона.
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)
  }
}

Добавляя case к конструкциям switch, мы можем легко расширить описанное выше для обработки нескольких методов. Предложение по умолчанию обрабатывает ситуацию, когда вызывается неизвестный метод (скорее всего, из-за ошибки программирования).

Приведенный выше код Dart эквивалентен следующему:

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));

Реализации каналов методов для Android и iOS также представляют собой тонкую оболочку для вызовов основ обмена двоичными сообщениями. Null ответ используется для представления результата «не реализовано». Это удобно делает поведение на принимающей стороне безразличным к тому, прошел ли вызов к предложению по умолчанию в коммутаторе, или обработчик вызова метода вообще не был зарегистрирован в канале.

Значением аргумента в примере является одна строка world. Но кодек метода по умолчанию, метко названный «кодек стандартного метода», использует стандартный кодек сообщений для кодирования значений полезной нагрузки. Это означает, что все «обобщенные JSON-подобные» значения, описанные ранее, поддерживаются в качестве аргументов метода и (успешных) результатов. В частности, гетерогенные списки поддерживают несколько аргументов, а гетерогенные карты поддерживают именованные аргументы. Значение аргументов по умолчанию равно null. Несколько примеров:

await channel.invokeMethod('bar');
await channel.invokeMethod('bar', <dynamic>['world', 42, pi]);
await channel.invokeMethod('bar', <String, dynamic>{
  name: 'world',
  answer: 42,
  math: pi,
}));

Flutter SDK включает в себя два кодека метода:

  • StandardMethodCodec, который по умолчанию делегирует кодирование значений полезной нагрузки (payload) в StandardMessageCodec. Поскольку последний является расширяемым, то же самое можно сказать и о первом.

  • JSONMethodCodec, который делегирует кодирование значений полезной нагрузки (payload) в JSONMessageCodec.

Вы можете настроить каналы метода с любым кодеком метода, включая пользовательские. Чтобы полностью понять, что связано с реализацией кодека, давайте посмотрим, как обрабатываются ошибки на уровне API канала метода, расширив приведенный выше пример ошибочным методом buz:

// Вызовы метода с обработкой ошибок.
// Dart сторона.
const channel = MethodChannel('foo');
// Вызвать метод платформы.
const name = 'bar'; // или 'baz', или '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');
}
// Получаем вызовы методов от платформы и возвращаем результаты.
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 сторона.
val channel = MethodChannel(flutterView, "foo")
// ВЫзов метода Dart.
val name = "bar" // или "baz", или "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")
  }
})
// Получаем вызовы методов от Dart и возвращаем результаты.
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 сторона.
let channel = FlutterMethodChannel(
  name: "foo", binaryMessenger: flutterView)
// Вызов метода Dart.
let name = "bar" // или "baz", или "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)
  }
}
// Получаем вызовы методов от Dart и возвращаем результаты.
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)
}

Ошибки — это триплеты (код, сообщение, подробности), где код и сообщение — строки. Сообщение предназначено для человеческого восприятия, код в свою очередь, ну, для кода. Сведения об ошибке — это некоторое пользовательское значение, часто null, которое ограничивается только типами значений, поддерживаемыми кодеком.

Мелкий шрифт. Исключения. Любое неперехваченное исключение, созданное в обработчике вызова метода Dart или Android, перехватывается реализацией канала, регистрируется, и результат ошибки возвращается вызывающему объекту. Неперехваченные исключения, созданные в обработчиках результатов, регистрируются.

Кодировка конверта. То, как кодек метода кодирует свои конверты, является деталью реализации, точно так же, как кодеки сообщений преобразуют сообщения в байты. Например, кодек метода может использовать списки: вызовы методов могут быть закодированы как двухэлементные списки [имя метода, аргументы]; результаты успеха в виде одноэлементных списков [результат]; результаты ошибки в виде трехэлементных списков [код, сообщение, подробности]. Затем такой кодек метода может быть реализован простым делегированием базовому кодеку сообщений, который поддерживает по крайней мере списки, строки и null. Аргументы вызова метода, результаты успешного выполнения и сведения об ошибках будут произвольными значениями, поддерживаемыми этим кодеком сообщения.

Отличия API. В приведенных выше примерах кода показано, что каналы методов дают результаты по-разному в Dart, Android и iOS:

  • На стороне Dart вызов обрабатывается методом, возвращающим future. Future завершается с результатом вызова в случае успеха, с PlatformException в случае ошибки и с MissingPluginException в случае нереализованного.

  • В Android вызов обрабатывается методом, принимающим аргумент обратного вызова. Интерфейс обратного вызова определяет три метода, один из которых вызывается в зависимости от результата. Клиентский код реализует интерфейс обратного вызова, чтобы определить, что должно произойти в случае успеха, ошибки и невыполнения.

  • В iOS вызов аналогично обрабатывается методом, принимающим аргумент обратного вызова. Но здесь обратный вызов — это функция с одним аргументом, которой передается либо экземпляр FlutterError, либо константа FlutterMethodNotImplemented, либо, в случае успеха, результат вызова. Клиентский код предоставляет блок с условной логикой для обработки различных случаев по мере необходимости.

Эти различия, отраженные также в способе написания обработчиков вызовов сообщений, возникли как уступки стилям языков программирования (Dart, Java и Objective-C), используемых для реализации каналов методов Flutter SDK. Переделывание реализаций в Kotlin и Swift может устранить некоторые различия, но необходимо соблюдать осторожность, чтобы не усложнить использование каналов методов из Java и Objective-C.

Каналы событий: стримы

Канал событий — это специализированный канал платформы, предназначенный для использования в случае представления событий платформы Flutter в виде потока Dart. Flutter SDK в настоящее время не поддерживает симметричный случай предоставления потоков Dart коду платформы, хотя это можно было бы создать, если возникнет необходимость.

Вот как вы будете использовать поток событий платформы на стороне Dart:

// Потребление событий на сторонe Dart
const channel = EventChannel('foo');
channel.receiveBroadcastStream().listen((dynamic event) {
  print('Received event: $event');
}, onError: (dynamic error) {
  print('Received error: ${error.message}');
});

В приведенном ниже коде показано, как создавать события на стороне платформы, на примере событий датчика на Android. Основная проблема заключается в том, чтобы убедиться, что мы прослушиваем события от источника платформы (в данном случае от диспетчера датчиков) и отправляем их через канал событий именно тогда, когда 1) на стороне Dart есть хотя бы один прослушиватель потока и 2) окружающая Activity запущена. Упаковка необходимой логики в один класс увеличивает шанс сделать это правильно:

// 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()
  }
}

Если вы используете package android.arch.lifecycle в своем приложении, вы могли бы сделать SensorListener более автономным, сделав из него LifecycleObserver.

Мелкий шрифт. Жизнь обработчика потока. Обработчик потока на стороне платформы имеет два метода, onListen и onCancel, которые вызываются всякий раз, когда число слушателей потока Dart увеличивается с нуля до единицы и обратно соответственно. Это может происходить несколько раз. Предполагается, что реализация обработчика потока начинает заливать события в приемник событий при вызове первого и останавливается при вызове второго. Кроме того, он должен приостанавливаться, когда компонент внешнего приложения не работает. Приведенный выше код представляет собой типичный пример. Под прикрытием обработчик потока — это, конечно, просто двоичный обработчик сообщений, зарегистрированный в представлении Flutter с использованием имени канала событий.

Кодек. Канал событий настроен с помощью кодек метода, что позволяет нам различать события успеха и ошибки так же, как каналы методов могут различать результаты успеха и ошибки.

Аргументы и ошибки обработчика потока. Методы обработчика потока onListen и onCancel вызываются через вызовы канала метода. Итак, у нас есть вызовы методов управления от Dart к платформе и сообщения о событиях в обратном направлении, все на одном и том же логическом канале. Эта настройка позволяет передавать аргументы обоим методам управления и сообщать о любых ошибках. На стороне Dart аргументы, если они есть, передаются в вызове receiveBroadcastStream. Это означает, что они указываются только один раз, независимо от количества вызовов onListen и onCancel, происходящих в течение жизни потока. Любые ошибки, о которых сообщается, регистрируются.

Конец потока. Приемник событий имеет метод endOfStream, который можно вызвать, чтобы сигнализировать о том, что никаких дополнительных событий успеха или ошибки отправляться не будет. Для этой цели используется пустое двоичное сообщение. При получении на стороне Dart поток закрывается.

Жизнь потока. Поток Dart поддерживается контроллером потока, поступающим из входящих сообщений канала платформы. Двоичный обработчик сообщений регистрируется с использованием имени канала событий, чтобы получать входящие сообщения только тогда, когда у потока есть слушатели.

Рекомендации по использованию

Добавляйте префикс имен каналов по домену для уникальности.

Имена каналов — это просто строки, но они должны быть уникальными для всех объектов каналов, используемых для разных целей в вашем приложении. Вы можете сделать это, используя любую подходящую схему именования. Тем не менее, рекомендуемый подход для каналов, используемых в плагинах, заключается в использовании доменного имени и префикса имени плагина, например, some.body.example.com/sensors/foo для канала foo, используемого плагином sensors, разработанным, например, some.body по адресу example.com. Это позволяет потребителям плагинов комбинировать любое количество плагинов в своих приложениях без риска конфликтов имен каналов.

Рассмотрите возможность обработки каналов платформы как внутримодульной связи.

Код для вызова удаленных вызовов процедур в распределенных системах внешне похож на код, использующий каналы методов: вы вызываете метод, заданный строкой, и сериализуете свои аргументы и результаты. Поскольку компоненты распределенной системы часто разрабатываются и развертываются независимо, надежная проверка запросов и ответов имеет решающее значение и обычно выполняется в стиле проверки и регистрации на обеих сторонах сети.

Каналы платформы, с другой стороны, объединяют три фрагмента кода, которые разрабатываются и развертываются вместе, в одном компоненте.

Java/Kotlin ↔ Dart ↔ Objective-C/Swift

На самом деле очень часто имеет смысл упаковать подобную триаду в отдельный модуль кода, такой как плагин Flutter. Это означает, что необходимость проверки аргументов и результатов при вызовах канала метода должна быть сравнима с потребностью в таких проверках при вызовах обычных методов в одном и том же модуле.

Внутри модулей нашей главной заботой является защита от ошибок программирования, которые выходят за рамки статических проверок компилятора и остаются незамеченными во время выполнения, пока они не взорвутся нелокально во времени или пространстве. Разумный стиль кодирования состоит в том, чтобы делать предположения явными, используя типы или утверждения, что позволяет нам быстро и чисто ошибаться, например. с исключением. Детали, конечно, зависят от языка программирования. Примеры:

  • Если ожидается, что значение, полученное по каналу платформы, будет иметь определенный тип, немедленно присвойте его переменной этого типа.

  • Если ожидается, что значение, полученное по каналу платформы, будет не null, либо настройте его на немедленное разыменование, либо подтвердите, что оно не равно null, прежде чем сохранить его на потом. В зависимости от вашего языка программирования вы можете вместо этого присвоить его переменной ненулевого типа.

Два простых примера:

// Dart: мы ожидаем получить non-null список целых чисел.
for (final int n in await channel.invokeMethod('getFib', 100)) {
  print(n * n);
}
// Android: мы ожидаем non-null аргументы имени и возраста для
// асинхронной обработка, представленной в мапе со строковыми ключами.
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) { ... }

Код Android использует типизированный метод <T> T arguments(String key) метода MethodCall, который ищет ключ в аргументах, который, как предполагается, является картой, и приводит найденное значение к целевому типу (сайту вызова). Подходящее исключение генерируется, если это не удается по какой-либо причине. Будучи выброшенным из обработчика вызова метода, он будет зарегистрирован, а результат ошибки будет отправлен на сторону Dart.

Don`t mock platform channels (не издевайтесь над каналами платформы)

(Шутейка.) При написании модульных тестов для кода Dart, использующего каналы платформы, рефлекторной реакцией может быть имитация объекта канала, как если бы вы делали это с сетевым подключением.

Вы, конечно, можете это сделать, но на самом деле объекты каналов не нужно имитировать, чтобы они хорошо работали с модульными тестами. Вместо этого вы можете зарегистрировать фиктивное сообщение или обработчики методов, чтобы они играли роль платформы во время определенного теста. Вот модульный тест функции hello, которая должна вызывать метод bar на канале 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');
});

Чтобы протестировать код, устанавливающий обработчики сообщений или методов, можно синтезировать входящие сообщения с помощью BinaryMessages.handlePlatformMessage. В настоящее время этот метод не отображается на каналах платформы, хотя это можно легко сделать, как показано в приведенном ниже коде. Код определяет модульный тест класса Hello, который должен собирать входящие аргументы вызовов панели методов на канале foo, возвращая приветствия:

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;
}

Оба приведенных выше примера объявляют объект канала в модульном тесте. Это прекрасно работает — если вы не беспокоитесь о дублирующемся имени канала и кодеке — потому что все объекты канала с одинаковым именем и кодеком эквивалентны. Вы можете избежать дублирования, объявив канал как константу где-то, видимую как для вашего производственного кода, так и для теста.

Чего вам не нужно, так это предоставлять способ внедрить фиктивный (издевательский) канал в ваш производственный код.

Рассмотрите возможность автоматизированного тестирования взаимодействия с вашей платформой

Каналы платформы достаточно просты, но чтобы все работало из вашего пользовательского интерфейса Flutter с помощью пользовательского API Dart, поддерживаемого отдельной реализацией Java/Kotlin и Objective-C/Swift, требуется определенная осторожность. И поддержание работоспособности установки по мере внесения изменений в ваше приложение на практике потребует автоматического тестирования для защиты от регрессии. Этого нельзя достичь только с помощью модульного тестирования, потому что вам нужно реальное приложение, работающее для каналов платформы, чтобы фактически общаться с платформой.

Flutter имеет инфраструктуру тестирования интеграции flutter_driver, которая позволяет тестировать приложения Flutter, работающие на реальных устройствах и эмуляторах. Но flutter_driver в настоящее время не интегрирован с другими фреймворками, чтобы можно было тестировать компоненты Flutter и платформы. Я уверен, что это одна из областей, где Flutter улучшится в будущем.

В некоторых ситуациях вы можете использовать flutter_driver как есть, чтобы проверить использование канала вашей платформы. Это требует, чтобы ваш пользовательский интерфейс Flutter можно было использовать для запуска любого взаимодействия с платформой, а затем он обновлялся с достаточной детализацией, чтобы ваш тест мог установить результат взаимодействия.

Если вы не находитесь в такой ситуации или если вы упаковываете использование канала платформы в виде плагина Flutter, для которого вы хотите протестировать модуль, вы можете вместо этого написать простое приложение Flutter для целей тестирования. Это приложение должно иметь указанные выше характеристики, и его можно будет использовать с помощью flutter_driver. Вы найдете пример в репозитории Flutter GitHub.

Держите сторону платформы готовой к входящим синхронным вызовам

Каналы платформы являются только асинхронными. Но существует довольно много API-интерфейсов платформы, которые выполняют синхронные вызовы компонентов вашего хост-приложения, запрашивая информацию или помощь или предлагая окно возможностей. Одним из примеров является Activity.onSaveInstanceState на Android. Синхронность означает, что все должно быть сделано до возврата входящего вызова. Теперь вы можете включить информацию со стороны Dart в такую обработку, но слишком поздно начинать отправку асинхронных сообщений, когда синхронный вызов уже активен в основном потоке пользовательского интерфейса.

Подход, используемый Flutter, особенно для информации о семантике/доступности, заключается в упреждающей отправке обновленной (или обновленной) информации на сторону платформы всякий раз, когда информация изменяется на стороне Dart. Затем, когда поступает синхронный вызов, информация со стороны Dart уже присутствует и доступна для кода на стороне платформы.

Комментарии (0)