Привет, Хабр! Сегодня поговорим о том, как Flutter-приложению выйти за пределы чисто Dart-мирка и воспользоваться возможностями родной платформы, например, вызвать API Android или iOS напрямую. Например, есть какая-нибудь классная фича в Android SDK, а в Flutter её нет. Как быть? Ответ — писать собственный плагин и использовать MethodChannel.
Зачем нужен MethodChannel и что это такое
Flutter сам по себе работает в своем изолированном мире: код на Dart исполняется в виртуальной машине (или компилируется в машинный код), UI рисуется посредством движка Skia, и всё такое. Но под само то приложение запускается на конкретной платформе, Android, iOS, Windows и т.д. Иногда возникает потребность вызывать платформенно-специфичный код. Например, получить уровень заряда батареи устройства, узнать версию операционной системы, прочитать IMEI или показать нативный виджет. Такие вещи не реализованы на чистом Flutter, ведь они зависят от платформы.
Platform Channels – это механизм, который позволяет Flutter-коду и нативному коду (Java/Kotlin на Android, Objective-C/Swift на iOS) общаться между собой. Flutter имеет несколько видов каналов: MethodChannel, BasicMessageChannel, EventChannel. У каждого своё предназначение. В данном случае нас интересует MethodChannel, самый распространенный. Он позволяет Dart-коду вызвать метод на стороне платформы и получить от него результат. Работает это асинхронно, с точки зрения Dart, мы вызываем некую функцию, которая под капотом отправляет сообщен��е в нативный код, там выполняется нужная логика, и ответ возвращается обратно в Dart.
MethodChannel хорош для разовых запросов и команд. Например, "дай мне текущий уровень батареи", это как раз метод, который вызывается по требованию и возвращает значение. Если же вам нужен постоянный поток данных от платформы, то лучше подошёл бы EventChannel, по которому нативный код может слать события непрерывно. Но для однократных вызовов MethodChannel то, что надо. В рамках этой статьи мы сконцентрируемся на подходе с MethodChannel, как наиболее типичном.
Итак, схема работы такая: на стороне Flutter мы создаём объект MethodChannel с определённым именем (идентификатором канала). С помощью него вызываем метод (по строковому ключу, Flutter подкапотно сериализует вызов и пересылает платформе. На стороне Android/iOS регистрируем обработчик вызова на этом же канале, по сути, прописываем, что делать, когда из Flutter прилетит метод с таким-то именем. В обработчике вызываем нужные нативные API и возвращаем результат обратно через специальный объект Result. Flutter получит этот результат и передаст его в Future в Dart-коде. Если метод не реализован на нативной стороне или произошла ошибка, это тоже будет передано (через исключение в Future). Визуально: Dart вызывает -> платформа исполняет и отвечает -> Dart продолжает работу с данными.
Очень важно, чтобы имя канала совпадало на обеих сторонах, иначе связь не установится. Также должен совпадать и имя метода, который вызывается. Эти имена обычные строки, так что следите за опечатками. Обычно имя канала оформляют в виде обратного домена, например "com.myapp.plugin/methodName", чтобы гарантированно быть уникальным в приложении.
Раз уж мы заговорили о соглашениях, добавлю: Flutter использует стандартный механизм сериализации для сообщений, который умеет передавать базовые типы данных: числа, строки, списки, карты и байтовые буферы. Этого достаточно для большинства задач. Если нужно передать, скажем, структуру с несколькими полями, можно сериализовать её в Map или List и так отправить. Платформенный код получит обычные типы (например, Dart-овский Map станет NSDictionary на iOS или HashMap на Android автоматически). Про всё это Flutter заботится сам, мы же просто передаем аргументы метода, и они прилетают нативному обработчику в удобоваримом виде.
Создаём плагин
Теперь разработаем мини-плагин, который возвращает уровень заряда батареи устройства. Это классический учебный пример, но на нём удобно показать все этапы. Предположим, хочется иметь в Flutter метод getBatteryLevel(), который дергает Android API BatteryManager или iOS API UIDevice.batteryLevel.
Первым делом нужно решить: будем ли мы оформлять это как отдельный плагин или просто внедрим код в наше приложение? Если вы планируете переиспользовать решение или поделиться им, лучше создать плагин через команду flutter create --template=plugin. Она сгенерирует структуру проекта, где есть папки android/ и ios/ с кодом, и файл lib/ с Dart-интерфейсом. Впрочем, для простоты можно и не выделять в отдельный пакет, а написать необходимый код прямо в проекте приложения – принципиальной разницы в самом механизме MethodChannel не будет.
Шаг 1. Инициация вызова.
На стороне Flutter нужно определить канал и вызвать метод. Обычно канал создают один раз где-нибудь в инициализации приложения (например, в initState виджета или в функции main()), а затем используют. Имя канала мы выбираем, скажем: "samples.flutter.dev/battery". Название может быть любым, но лучше указать домен вашей компании/приложения, чтобы избежать конфликтов. Далее, вызываем метод, например "getBatteryLevel". Сделаем Dart-класс для работы с нашим плагином:
import 'package:flutter/services.dart';
class BatteryLevel {
static const MethodChannel _channel = MethodChannel('samples.flutter.dev/battery');
static Future<int?> getBatteryLevel() async {
try {
final int? level = await _channel.invokeMethod<int>('getBatteryLevel');
return level;
} on PlatformException catch (e) {
print('Не удалось получить уровень батареи: $e');
return null;
}
}
}
Создали MethodChannel с именем samples.flutter.dev/battery. В методе getBatteryLevel дергаем _channel.invokeMethod и ждём результат. Обратите внимание: мы обернули вызов в блок try-catch по PlatformException. Если на нативной стороне что-то пошло не так (например, метод не реализован или выбросил ошибку), Flutter бросит исключение PlatformException. Мы ловим его и, например, логируем, а затем возвращаем null. В реальном приложении можно обработать ситуацию иначе (показать сообщение юзеру или еще что-то). Тип, указанный в invokeMethod<int>, подсказывает, какого типа результат мы ожидам, в данном случае целое число процентов заряда. Flutter сам приведет тип (или бросит, если ожидания не совпадут).
С Dart-частью конец. Теперь реализация на нативной стороне.
Android
Переключаемся на код Android, будем писать на kotlin. Предположим, мы работаем в модуле приложения (не отдельный плагин). Тогда нужный файл MainActivity.kt (или .java, если у вас Java, но Kotlin предпочтительнее). В случае с плагином код будет лежать в android/src/main/kotlin/.../YourPlugin.kt, но суть аналогична.
Нам нужно в MainActivity зарегистрировать MethodChannel и его обработчик. Flutter при старте приложения автоматом поднимет двигатель (FlutterEngine) и свяжет его с Activity. Мы можем переопределить метод configureFlutterEngine в нашей Activity, чтобы после инициализации движка добавить туда свой канал:
class MainActivity: FlutterActivity() {
private val CHANNEL = "samples.flutter.dev/battery"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
call, result ->
// Здесь будем обрабатывать вызовы с Flutter
if (call.method == "getBatteryLevel") {
val batteryLevel = getBatteryLevel()
if (batteryLevel != -1) {
result.success(batteryLevel) // возвращаем результат в Dart
} else {
result.error("UNAVAILABLE", "Battery level not available.", null)
}
} else {
result.notImplemented()
}
}
}
private fun getBatteryLevel(): Int {
val batteryManager = getSystemService(BATTERY_SERVICE) as BatteryManager
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
} else {
// Для старых устройств:
val intent = ContextWrapper(applicationContext).registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)?.let { level ->
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
if (scale > 0) (level * 100) / scale else -1
} ?: -1
}
}
}
Сначала определяем константу CHANNEL с именем нашего канала,она должна совпадать с тем, что мы указали в Dart (samples.flutter.dev/battery). Далее, в configureFlutterEngine создаём новый MethodChannel, передавая ему binaryMessenger от движка и имя канала. После этого вызываем setMethodCallHandler и передаем лямбду, которая будет обрабатывать вызовы. Функция получает два аргумента: call (содержащий информацию о вызове, в том числе имя метода и аргументы) и result (через него мы должны отправить ответ).
Внутри обработчика проверяем, если метод называется "getBatteryLevel", значит, Flutter запросил уровень батареи. Вызываем нашу функцию getBatteryLevel() (о ней чуть ниже) и получаем некий batteryLevel. Мы условились, что если получим -1, значит не удалось определить уровень (например, на устройстве батарея недоступна). Поэтому делаем: если значение корректное (!= -1), отправляем его обратно через result.success(batteryLevel). Если же нет, то просто дропаем ошибку: result.error(...). Метод error позволяет передать код ошибки (строка "UNAVAILABLE"), сообщение для логов и опционально данные ошибки (мы передаём null дополнительных данных). Flutter на стороне Dart превратит это в PlatformException с теми же кодом и сообщением. А если вдруг прилетел метод с другим именем, которого мы не ожидали, вызываем result.notImplemented(), Flutter тогда также бросит MissingPluginException на Dart стороне (мы такое не вызывали, но на всякий случай надо обрабатывать неизвестные методы).
Теперь про getBatteryLevel(). Этот метод использует обычный Android API для получения заряда батареи. Если мы на Android 5.0+ (API 21+), доступен класс BatteryManager с прямым методом getIntProperty(BATTERY_PROPERTY_CAPACITY),он возвращает процент зарядки. Если же мы на более старой версии, то используем костыль, регистрируем Battery_CHANGED Intent и вытаскиваем из него EXTRA_LEVEL и EXTRA_SCALE, чтобы вычислить процент вручную. В обоих случаях возвращаем целое число процента или -1, если не удалось.
Весь этот код выполнится на UI-потоке Android (это особенность MethodChannel, вызовы приходят в главный поток приложения). Для нашего случая это не так важно, получение процента батареи быстрая операция. Но имейте в виду, что нельзя делать в обработчике долговременные или тяжёлые операции, не уходя в бэкграунд, иначе подвесите UI. Если вам вдруг нужно выполнить что-то очень тяжелое, лучше внутри call.method == ... запустить асинхронную задачу (через новый поток/пул) и сразу вернуть управление, а результат уже отправить позже через result (но тогда нельзя вызывать result.success после возвращения из функции, нужно держать ссылку result и вызвать из того же потока). В общем, это сложнее, и выходит за рамки статьи. В большинстве плагинов все делается быстро и прямо, как в нашем примере.
Хорошо, Android готов. Когда Flutter вызовет BatteryLevel.getBatteryLevel(), Dart отправит сообщение, MethodChannel в MainActivity его перехватит, выполнит getBatteryLevel() и отправит ответ обратно.
iOS: реализация MethodChannel в Swift
Для полноты картины реализуем то же самое на iOS. Предположим, плагин у нас также вшит в приложение. Код писать будем на Swift (хотя можно и на Objective-C, но зачем, когда есть Swift). В проекте iOS Flutter-приложения по умолчанию может быть AppDelegate.swift. Нужно в нём сделать похожие шаги: создать FlutterMethodChannel с тем же именем и подписаться на вызовы.
Открываем ios/Runner/AppDelegate.swift. Найдём метод application(_:didFinishLaunchingWithOptions:), он вызывается при старте приложения. В нём после строки GeneratedPluginRegistrant.register(with: self) (или до, не суть) добавим код для канала:
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: "samples.flutter.dev/battery",
binaryMessenger: controller.binaryMessenger)
batteryChannel.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in
// Этот обработчик вызывается на основной iOS-треде
if call.method == "getBatteryLevel" {
self?.receiveBatteryLevel(result: result)
} else {
result(FlutterMethodNotImplemented)
}
}
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
private func receiveBatteryLevel(result: FlutterResult) {
let device = UIDevice.current
device.isBatteryMonitoringEnabled = true
if device.batteryState == UIDevice.BatteryState.unknown {
result(FlutterError(code: "UNAVAILABLE",
message: "Battery level not available.",
details: nil))
} else {
let level = Int(device.batteryLevel * 100)
result(level)
}
}
}
Получаем ссылку на корневой FlutterViewController (это представление Flutter в приложении iOS). Через него достаем binaryMessenger,канал связи. Создаем FlutterMethodChannel с именем "samples.flutter.dev/battery" (точно таким же, помним) и тем messenger.
Далее устанавливаем обработчик setMethodCallHandler. Он очень сильно похож на Kotlin-версию, только синтаксис Swift другой. В блоке мы проверяем call.method. Если это "getBatteryLevel", то вызываем наш метод receiveBatteryLevel(result:). Если какой-то другой метод, возвращаем FlutterMethodNotImplemented (аналог result.notImplemented()).
Метод receiveBatteryLevel уже использует iOS API. В iOS доступ к уровню батареи дает класс UIDevice. Нужно включить мониторинг батареи (device.isBatteryMonitoringEnabled = true), иначе batteryLevel всегда будет выдавать -1. После этого device.batteryLevel возвращает дробное число от 0.0 до 1.0 (или -1, если неизвестно). Мы проверяем batteryState == .unknown это значит, данные о батарее недоступны (например, на симуляторе часто так). Если так, отправляем через result ошибку FlutterError с кодом "UNAVAILABLE". Если всё норм, умножаем batteryLevel на 100 и приводим к Int, получаем процент зарядки. Его и возвращаем вызовом result(level) в Swift, напомню, обработчик принимает result как замыкание, и чтобы вернуть успешное значение, нужно его вызвать с этим значением. Для ошибки используется FlutterError или можно вызвать result(FlutterError(...)), как мы и сделали.
Swift-реализация готова. Не забудьте, кстати, что на iOS по умолчанию плагин, сгенерированный Flutter, может быть на Objective-C. Если хотите писать на Swift, надо добавить поддержу Swift (например, создавать Swift-файлы или использовать параметр flutter create -i swift). В нашем случае я показал вариант, где AppDelegate уже на Swift.
К этому моменту у нас есть: Dart-класс с методом, Android код, iOS код. Если всё собрать и запустить приложение на реальном устройстве или эмуляторе, то при вызове BatteryLevel.getBatteryLevel() Flutter отправит запрос, Android/iOS отработают и вернут процент. В Dart мы получим число. Можно, например, вывести его на экран или залогировать.
Тестирование и прочее
Чтобы убедиться, что наш плагин работает, можно вызвать метод и вывести результат в интерфейс. Например, заменить содержимое build() в Flutter-приложении на что-то вроде:
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: FutureBuilder<int?>(
future: BatteryLevel.getBatteryLevel(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Text('Запрашиваем уровень батареи...');
}
final battery = snapshot.data;
return Text(battery != null
? 'Уровень заряда: $battery%'
: 'Ошибка получения уровня заряда');
},
),
),
),
);
}
Запустив приложение на Android-эмуляторе или устройстве, вы должны увидеть текст с процентом заряда (либо сообщение об ошибке, если что-то не так). На iOS-симуляторе, кстати, batteryLevel почти всегда возвращает -1 (не поддерживается), так что там скорее всего отобразится наш путь с ошибкой "Battery level not available." — имейте это в виду.
Напоследок, несколько рекомендаций при работе с платформенными каналами:
Совпадение имён – критично: как я уже говорил, убедитесь, что строка канала и название метода полностью одинаковы в Dart и нативном коде. Даже лишний пробел или разница в регистре и ничего не заработает.
Одна сторона – один ответ: когда вы вызываете
result.successилиresult.error, канал на этом завершается. Нельзя вызыватьresultбольше одного раза, и нельзя не вызвать его вовсе, иначе Dart будет вечно ждать.MethodChannel vs EventChannel: как упоминалось, для потоковых данных (событий) лучше применять
EventChannel. Наш пример с батареей можно было бы реализовать через поток обновлений уровня заряда, подписавшись на системные броадкасты на Android и нотификации на iOS. Но это существенно сложнее. Если нужно, изучитеEventChannel, он позволяет нативному коду самим пушить события в Dart. А вот для вызова функций (как у нас) всегда используйте MethodChannel.Права и разрешения: не забывайте, что некоторые нативные функции требуют разрешений пользователя. Наш пример с батареей, нет, там не нужно ничего. А вот если бы вы через MethodChannel полезли, скажем, к камере или геолокации, надо учесть систему permission’ов Android/iOS. Flutter-плагин тут ничем не отличается от обычного нативного кода: запрашивайте разрешения, проверяйте их и только потом возвращайте результат. Иначе можно получить
SecurityExceptionна Android или просто 0 результат на iOS.Отладка: если что-то не работает, удобно ставить брейкпойнты в Android Studio (в коде Kotlin) или Xcode (Swift) внутри вашего обработчика, чтобы увидеть, приходит ли вызов. Также можно в Dart ловить исключения PlatformException и смотреть
e.codeиe.messageдля диагностики.
Берите и пробуйте, оно того стоит, ведь вы расширяете горизонты Flutter до возможностей родной платформы. Удачных дней!

Если хочется не только один раз подружить MethodChannel с платформой, но в целом уверенно собирать продакшен-приложения на Flutter, посмотрите в сторону курса «Flutter Mobile Developer» в OTUS. Там много практики с Dart, архитектурой, платформенным кодом и современными стейт-менеджерами, а ещё анимацией и веб-версией интерфейсов.
Чтобы узнать больше о формате обучения и познакомиться с преподавателями, приходите на бесплатные демо-уроки:
11 декабря. Telegram + Flutter Web — создаём современное веб-приложение с ботом и интерфейсом. Записаться
18 декабря. Flutter Twin — живые интерфейсы, отражающие и изменяющие реальность. Записаться