Нативные SDK через Method Channels

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

Разработчику, привыкшему к Kotlin, Java, Swift или Objective-C, Flutter оставляет полный доступ к платформенным возможностям. Мост между Dart и нативным кодом называется MethodChannel.

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

Через MethodChannel можно:

  • вызывать API Android и iOS, которых нет во Flutter;
  • использовать сторонние SDK — сканеры штрихкодов, работу с Bluetooth, нативные элементы интерфейса;
  • выполнять операции, требующие нативной скорости или доступа к устройству;
  • быстро возвращать данные из нативного кода во Flutter.

Что это даёт разработчику с нативным опытом

ЗадачаКак помогает MethodChannel
Дотянуться до любого API платформыЛюбой вызов Android или iOS доступен из Flutter привычным нативным кодом.
Не тормозить интерфейсСообщения кодируются в двоичный вид и обрабатываются асинхронно.
Оставить критичный код нативнымЧувствительная к скорости и безопасности логика остаётся на Kotlin или Swift, интерфейс собирается на Dart.
Подключить SDK без обёрткиНативную библиотеку без поддержки Flutter можно использовать, не дожидаясь плагина.

Годы платформенного опыта при переходе на Flutter не пропадают: MethodChannel позволяет применить их ровно там, где нужно.

Что такое MethodChannel

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

Пример:

class _BatteryLevelScreenState extends State<BatteryLevelScreen> {

  // Имя канала должно в точности совпадать с именем на нативной стороне.
  static const platform = MethodChannel('com.example.battery');

  // Здесь будет храниться уровень заряда.
  String _batteryLevel = 'Unknown battery level.';

  // Вызываем нативный метод.
  Future<void> _getBatteryLevel() async {
    String batteryLevel;
    try {
      // Обращение к нативной стороне.
      final int result = await platform.invokeMethod('getBatteryLevel');
      batteryLevel = 'Battery level at $result%.';
    } on PlatformException catch (e) {
      // Обработка ошибки нативного кода.
      batteryLevel = "Failed to get battery level: '${e.message}'.";
    }

    // Обновляем интерфейс.
    setState(() {
      _batteryLevel = batteryLevel;
    });
  }

Канал работает по модели передачи сообщений. Вы задаёте уникальное имя канала — например, 'com.example/device' — и используете его с обеих сторон. Dart вызывает метод через invokeMethod(), передавая при необходимости данные. Нативная сторона регистрирует обработчик, который ждёт вызовов, выполняет код и возвращает результат.

Обмен асинхронный и несвязанный:

  • Dart не ждёт нативный код: возвращается Future, который завершается по готовности результата;
  • нативная сторона обязана явно вернуть success, error или notImplemented.

Ключевые понятия

  • Имя канала — строка, одинаковая с обеих сторон, например 'com.example/platform'. Чтобы имена не пересекались, их строят от домена приложения или организации.
  • Вызов метода — invokeMethod('methodName', arguments). Имя метода — обычная строка, аргументы — null или любое значение, поддерживаемое StandardMessageCodec: bool, int, double, string, List, Map.
  • Обработчик — нативный код регистрирует его (setMethodCallHandler на Android) и выполняет логику при совпадении имени метода.
  • Ответ — обработчик обязан вернуть result.success(...), result.error(...) или result.notImplemented(). Этот ответ и завершает Future на стороне Dart.

Как идёт сообщение

method-channels.avif

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

Когда это нужно

  • Требуется API Android или iOS, которого нет во Flutter и плагинах, — например, доступ к конкретному датчику или нативному хранилищу.
  • Нужно подключить закрытый или фирменный SDK: аналитику, платежи, распознавание текста.
  • Нужно открыть нативный экран: полноэкранный сканер, системный выбор файла.
  • Вы переносите на Flutter существующее нативное приложение по частям.

Чем MethodChannel не является

  • Это не общая память. Все данные копируются через сериализацию. Поддерживаются только стандартные типы — примитивы, списки, словари, типизированные массивы. Передача больших объёмов требует полной сериализации и разбора.
  • Это не синхронный вызов. Вызовы сразу возвращают Future и ничего не блокируют; результат приходит через цикл событий, а ошибки платформы — исключением PlatformException.
  • Это не готовый контракт. Имена методов, аргументы и типы вы задаёте сами с обеих сторон. Проверки на этапе компиляции нет — расхождение всплывёт только при работе приложения. Поэтому контракт стоит описать и проверять входные данные.

Где это применяется

Плагины Flutter покрывают многое, но не всё. Когда нужного плагина нет или он не подходит, MethodChannel даёт прямой путь.

По сути, такая интеграция — это и есть разработка плагина: вы пишете тот же нативный мост, только упакованный под своё приложение, а не как публичный пакет. Готовый результат подключается к FlutterFlow.

Возможности устройства, которых нет в плагинах

Пример: уровень сигнала сотовой сети, подробная информация о батарее, тепловое состояние устройства.

  • Низкоуровневые API вроде TelephonyManager на Android или CoreTelephony на iOS через плагины почти не доступны.
  • Им нужны свои разрешения и прямой нативный вызов.
  • Через MethodChannel вы берёте ровно то, что требуется, не дожидаясь обновления плагина и не написав его целиком.

Что это даёт: доступ к телеметрии и диагностике — тому, на чём держатся приложения для выездных работ, тестовые инструменты и корпоративная отчётность.

Закрытые SDK поставщиков

Пример: SDK проверки личности, сканер документов, защищённое хранилище.

  • Многие поставщики выпускают только SDK для Android и iOS, без обёрток для Flutter.
  • Небольшая нативная обёртка и канал открывают ровно ту часть, которая нужна.
  • Обновления SDK не задевают интерфейс на Flutter.

Что это даёт: ключевые функции — проверка клиента, биометрия, платежи — работают без зависимости от авторов плагинов.

Временный показ нативного экрана

Пример: нативный просмотрщик PDF, камера из SDK поставщика, интерфейс дополненной реальности.

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

Что это даёт: нативный опыт там, где он нужен, без вмешательства в остальную отрисовку.

Фоновые задачи и события платформы

Пример: вход в геозону, обновление токена уведомлений, изменение состояния Bluetooth-устройства.

  • Эти события возникают в нативных службах и фоновых задачах.
  • События можно накапливать на нативной стороне и передавать во Flutter, когда приложение активно.
  • Для непрерывного потока данных берут EventChannel — MethodChannel рассчитан на разовые обмены.

Что это даёт: интеграцию на уровне системы — местоположение, питание, Bluetooth — без опроса и сложной логики на Dart.

Защищённые данные устройства

Пример: IMEI, MAC-адрес, отпечаток устройства, системные идентификаторы.

  • Такие API часто требуют особых прав и нативных запросов разрешений.
  • Нативный код проверяет права, фильтрует данные и решает, что безопасно возвращать.

Что это даёт: работа с чувствительными данными остаётся под нативным контролем — то, что требуется в корпоративной и регулируемой среде.

Реализация

Разберём канал целиком: объявление на Dart, подключение нативного кода и обмен аргументами и результатами.

1. Сторона Dart

Канал создаётся классом MethodChannel из пакета services. Вызов всегда начинает Dart, нативная сторона отвечает.

Объявление канала:

import 'package:flutter/services.dart';
const platform = MethodChannel('com.example/device');
  • Имя 'com.example/device' должно в точности совпадать с нативной стороной.
  • Имена строятся от домена в обратном порядке — так они не пересекаются.

Вызов метода:

Future<String> getBatteryLevel() async {
  try {
    final int result = await platform.invokeMethod('getBatteryLevel');
    return 'Battery level: $result%';
  } on PlatformException catch (e) {
    return 'Failed to get battery level: ${e.message}';
  }
}
  • invokeMethod передаёт имя метода и, при необходимости, аргументы.
  • Результат приходит асинхронно через Future.
  • Вызов всегда оборачивают в try-catch: PlatformException возникает, если нативный код выбросил ошибку, метод не реализован или не удалась сериализация данных.

Что учесть:

  • Аргументы передаются вторым параметром invokeMethod() — например, Map<String, dynamic>.
  • Результатом может быть любой тип, совместимый с JSON: int, String, bool, double, List, Map.

2. Сторона Android (Kotlin)

Обработчик регистрируется в MainActivity и по умолчанию выполняется в главном потоке, поэтому долгие операции нужно выносить в фоновый.

Импорты:

import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import android.os.BatteryManager
import android.content.Context

Обработчик:

class MainActivity: FlutterActivity() {
  private val CHANNEL = "com.example/device"
  override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)
    MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
      call, result ->
      when (call.method) {
        "getBatteryLevel" -> {
          val batteryLevel = getBatteryLevel()
          if (batteryLevel != -1) {
            result.success(batteryLevel)
          } else {
            result.error("UNAVAILABLE", "Battery level not available.", null)
          }
        }
        else -> result.notImplemented()
      }
    }
  }
  private fun getBatteryLevel(): Int {
    val batteryManager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
    return batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
  }
}

Что учесть:

  • Ответ возвращается ровно одним из трёх способов:
    • result.success(data) — данные уходят в Dart;
    • result.error(code, message, details) — в Dart возникнет PlatformException;
    • result.notImplemented() — в Dart возникнет MissingPluginException.
  • Вызывать result дважды нельзя: на один вызов метода Flutter ждёт ровно один ответ.
  • Если внутри есть ввод-вывод, сеть или что-то блокирующее, выносите это в фоновый поток:
    Thread(Runnable {
        val resultData = longRunningOperation()
        runOnUiThread {
            result.success(resultData)
        }
    }).start()

3. Сторона iOS (Swift)

Канал обрабатывается через FlutterMethodChannel в AppDelegate.swift; обработчик так же по умолчанию выполняется в главном потоке.

Объявление канала:

import UIKit
import Flutter
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller = window?.rootViewController as! FlutterViewController
    let batteryChannel = FlutterMethodChannel(name: "com.example/device",
    binaryMessenger: controller.binaryMessenger)

Обработчик:

    batteryChannel.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in
      if call.method == "getBatteryLevel" {
        UIDevice.current.isBatteryMonitoringEnabled = true
        let level = UIDevice.current.batteryLevel
        if level >= 0 {
          result(Int(level * 100))
        } else {
          result(FlutterError(code: "UNAVAILABLE",
                              message: "Battery level not available.",
                              details: nil))
        }
      } else {
        result(FlutterMethodNotImplemented)
      }
    }
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

Что учесть:

  • На каждый вызов — ровно один ответ.
  • Подробности об ошибке передаются через FlutterError.
  • Долгие операции выполняйте в DispatchQueue.global().async, а результат возвращайте через DispatchQueue.main.async.

Что стоит делать

  • Держать имена каналов и методов одинаковыми с обеих сторон.
  • Обмениваться только стандартными типами: String, int, bool, List, Map.
  • Явно обрабатывать ошибки на обеих сторонах.
  • Выносить долгие операции в фоновые потоки.
  • Держать нативную часть тонкой и тестируемой, отделяя логику SDK от кода канала.

MethodChannel во FlutterFlow

FlutterFlow генерирует полноценное приложение на Flutter и позволяет писать свой код на Dart через собственные действия и функции. А вот править нативный код — Kotlin и Swift — прямо в веб-интерфейсе нельзя. Отсюда и порядок работы: нативная часть выносится в отдельный плагин.

Шаг 1. Собрать свой плагин

1.1 Создать заготовку

flutter create --template=plugin --platforms=android,ios my_custom_plugin

Команда создаёт структуру плагина для Android и iOS.

1.2 Написать нативный код

В папках android и ios реализуйте нужную функциональность. Для примера с уровнем заряда:

  • Android (Kotlin): в MyCustomPlugin.kt обратитесь к BatteryManager.
  • iOS (Swift): в MyCustomPlugin.swift используйте UIDevice.

1.3 Опубликовать на GitHub

  1. Создайте репозиторий Git в папке плагина.
  2. Зафиксируйте изменения.
  3. Отправьте репозиторий на GitHub.

Проверьте pubspec.yaml и расставьте теги версий.

Шаг 2. Подключить плагин к проекту FlutterFlow

  1. Откройте Custom Code > Custom Actions или Custom Widgets.

  2. Создайте собственное действие или виджет.

  3. В панели Settings справа найдите Dependencies.

  4. Добавьте плагин по адресу репозитория:

      my_custom_plugin:
        git:
          url: https://github.com/yourusername/my_custom_plugin.git
  5. В редакторе кода добавьте импорт:

    import 'package:my_custom_plugin/my_custom_plugin.dart';
  6. Используйте API плагина.

Подробнее — в разделе про подключение неопубликованных и закрытых пакетов.

Шаг 3. Вызвать плагин из собственного действия

  1. Создайте собственное действие.

  2. Вызовите в нём плагин:

      Future<int> getBatteryLevel() async {
        final batteryLevel = await MyCustomPlugin.getBatteryLevel();
        return batteryLevel;
      }
  3. Скомпилируйте код и убедитесь, что ошибок нет.

  4. Используйте действие в потоках наравне со встроенными.

Закрытые репозитории

Если репозиторий плагина закрытый, FlutterFlow нужен доступ — учётные данные или ключ SSH. Как это настроить, описано в разделе про неопубликованные и закрытые пакеты.

Типичные ошибки и отладка

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

MissingPluginException

Симптом: Flutter выбрасывает MissingPluginException — метод или плагин не реализован.

Что произошло: Dart вызвал метод, но нативная сторона не узнала ни канал, ни имя метода.

Причины:

  • имя канала в Dart не совпадает с нативным;
  • обработчик setMethodCallHandler не зарегистрирован или зарегистрирован не там;
  • нативный код был перезаписан при повторной выгрузке проекта из FlutterFlow;
  • метод вызван до того, как движок Flutter и канал полностью инициализировались;
  • сработала горячая перезагрузка — она обновляет только код Dart.

Что делать:

  • сверить имена каналов посимвольно, с учётом регистра;
  • на Android регистрировать канал внутри configureFlutterEngine();
  • на iOS — внутри didFinishLaunchingWithOptions();
  • залогировать регистрацию каналов при запуске приложения;
  • после правки нативного кода делать полный перезапуск: платформенная часть требует пересборки.

Несовпадение типов

Симптом: приложение падает на приведении типов или неожиданно возвращает null.

Что произошло: данные, которыми обменялись стороны, оказались не того вида.

Причины:

  • Dart отправляет Map, а нативная сторона ждёт String, или наоборот;
  • нативный код возвращает объект платформы, который Flutter не может сериализовать;
  • тип результата не поддерживается StandardMessageCodec.

Что делать:

  • пользоваться только стандартными типами: int, double, String, bool, List, Map с безопасным для JSON содержимым;
  • на стороне Dart указывать ожидаемый тип: invokeMethod<int>(...);
  • на нативной стороне проверять типы до использования, применять безопасное приведение;
  • не отправлять сложные объекты вроде ответов нативного SDK — превращать их в простой словарь или строку.

Нет ответа или зависание

Симптом: вызов invokeMethod() не возвращается или интерфейс замирает.

Что произошло: нативная сторона не завершила вызов либо заняла главный поток долгой операцией.

Причины:

  • обработчик не вызвал ни result.success, ни result.error, ни result.notImplemented;
  • внутри обработчика возникло исключение, и ответ не ушёл;
  • тяжёлая работа — файлы, сеть — выполняется в главном потоке.

Что делать:

  • вызывать ровно один из трёх ответов, всегда;
  • оборачивать нативный код в try/catch и сообщать об исключениях;
  • выносить долгие операции в фоновый поток, корутину или очередь;
  • показывать индикатор загрузки и предусматривать таймаут на стороне Dart.

Повторный вызов result

Симптом: ошибка вида «Reply already submitted» или непредсказуемые результаты.

Что произошло: нативный код ответил на один вызов больше одного раза.

Причины:

  • из-за ошибки в логике выполнились обе ветки — и успешная, и с ошибкой;
  • асинхронные операции наперегонки возвращают ответ;
  • таймаут, повтор или исключение вызывают второй ответ.

Что делать:

  • отслеживать факт ответа флагом, например var responded = false;
  • прерывать выполнение return после ответа;
  • выстраивать асинхронные вызовы так, чтобы отрабатывала ровно одна ветка.

Отладка по платформам

Flutter и Dart:

  • логируйте вызовы и результаты через print() или debugPrint();
  • оборачивайте invokeMethod в try/catch и пишите исключения в журнал;
  • ставьте записи до и после вызова, чтобы видеть ход выполнения;
  • смотрите журнал и состояние приложения в Flutter DevTools.

Android (Kotlin, Java):

  • Log.d("MethodChannel", "Received: ${call.method}") внутри обработчика;
  • фильтрация журнала: adb logcat | grep flutter;
  • проверьте, что configureFlutterEngine() действительно вызывается — в старых проектах это могло требовать ручной настройки;
  • пользуйтесь точками останова в Android Studio.

iOS (Swift, Objective-C):

  • трассируйте обработчик через print() или NSLog();
  • следите за журналом Xcode при запуске — там видны проблемы регистрации канала;
  • проверьте, что result(...) вызывается ровно один раз;
  • убедитесь, что AppDelegate корректно приводит window?.rootViewController к FlutterViewController.

Производительность и архитектура

инфо
Важно для пользователей FlutterFlow

FlutterFlow генерирует чистый код на Dart и позволяет вставлять свою логику через собственные действия, но править нативный код — Kotlin и Swift — внутри платформы нельзя.

Не занимайте главный поток

  • Все вызовы MethodChannel по умолчанию обрабатываются в главном потоке, который отвечает и за отрисовку.
  • Работа с базой, файлами, Bluetooth и сетью обязана уходить в фоновый поток.
  • На Android это Executors или корутины, на iOS — DispatchQueue.global().
  • Результат возвращайте в главный поток: runOnUiThread на Android, DispatchQueue.main.async на iOS.

Даже несколько миллисекунд блокировки дают пропущенные кадры и рваную анимацию — особенно заметно на недорогих устройствах.

совет

Внутри FlutterFlow всё выглядит гладко, но на реальном устройстве медленная операция в Kotlin или Swift заморозит приложение. Выносите такие задачи в фон до возврата в Dart.

Держите код канала тонким

  • Обработчик канала — это контроллер, а не сервис: он должен передавать работу нормальным нативным компонентам.
  • Тонкий слой между Dart и нативным кодом проще сопровождать.
  • Например, getBatteryLevel в Kotlin должен просто вызвать BatteryService().getLevel().
  • Тогда нативная часть развивается независимо от интерфейса.

Такое разделение даёт лучшее покрытие тестами и избавляет от ошибок, которые трудно отследить между слоями.

Только данные, совместимые с JSON

  • Обмен идёт через StandardMessageCodec.
  • Он поддерживает ограниченный набор типов: int, double, bool, String, List, Map и null.
  • Нативные типы — Bitmap, Bundle, NSData, UIColor — нужно сначала превратить в структуру, дружественную JSON.
  • Сложные данные, например результат сканирования штрихкода или сведения об устройстве, сериализуйте в плоский словарь или строку JSON.

Несовпадение типов не поймается при компиляции — оно уронит приложение во время работы.

совет

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

Проверяйте входные данные

  • Относитесь к вызовам из Dart как к запросам извне: они могут прийти в любом виде.
  • Разбирайте имена методов через when или switch.
  • Проверяйте наличие и тип аргументов до использования:
val timeout = call.argument<Int>("timeout") ?: return result.error("INVALID", "Missing timeout", null)
  • Защитный код избавляет от неожиданного поведения, нативных падений и неверной работы с оборудованием.
совет

Собственные действия во FlutterFlow принимают параметры из интерфейса, но если параметр не задан или передан неверно, код на Dart всё равно выполнится. Проверяйте такие значения на нативной стороне.

Логируйте с обеих сторон

  • Пишите в журнал каждый вызов канала — и на Dart, и на нативной стороне:
    • какой метод вызван;
    • с какими аргументами;
    • что вернулось и сколько это заняло.
  • Пользуйтесь структурированными записями: Log.d("MethodChannel", "method=... args=... result=...") на Android, NSLog или print() на iOS.
  • Согласуйте отметки времени между слоями — это сильно помогает при разборе.

Когда что-то ломается в продакшене, разница между десятиминутным исправлением и многодневным расследованием обычно упирается именно в журналы.

совет

debugPrint() внутри собственных действий выводит записи рядом с платформенными. В тестовых сборках по ним видно, приходят ли нативные результаты.

Итог

MethodChannel — основной способ выйти за пределы того, что дают плагины: прямой доступ к нативным API и SDK, полный контроль над интеграцией.

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

  • понятный интерфейс между Dart и нативным слоем;
  • переиспользование оптимизированных под платформу SDK;
  • возможность выпускать сложные функции, не дожидаясь плагинов;
  • схему интеграции, которая выдерживает рост команды и продукта.

В будущем взаимодействие с платформой станет проще за счёт двух инструментов: FFIgen генерирует привязки к API Objective-C и Swift, JNIgen — к API Java и Kotlin.

перевод официальной документации FlutterFlow

обновлено

⌕ ESC