Разработка Platform Channel для Flutter-приложения (iOS)
Flutter отлично закрывает 80–90% задач через Dart-пакеты. Но когда нужен прямой доступ к нативному iOS-API — CoreNFC, NetworkExtension для VPN, AVFoundation с кастомными настройками сессии, PassKit для Wallet-пропусков — Dart-обёртки либо не существуют, либо устарели на 2 версии SDK. Вот здесь пишется Platform Channel вручную.
Три типа каналов и когда что использовать
Flutter предоставляет три механизма коммуникации с нативным кодом.
MethodChannel — запрос/ответ. Dart вызывает метод, Swift отвечает один раз. Подходит для большинства задач: биометрия, работа с Keychain, одноразовые запросы к системным API. Самый распространённый тип.
EventChannel — поток данных от нативного кода к Dart. Используется для подписок: данные с датчиков через CoreMotion, статус Bluetooth-соединения через CoreBluetooth, изменения сетевого интерфейса через NWPathMonitor. Данные текут непрерывно, пока Dart-сторона слушает.
BasicMessageChannel — произвольный обмен сообщениями в обе стороны с кастомным кодеком. Редкий случай, нужен когда стандартный StandardMessageCodec (поддерживает String, int, double, List, Map, Uint8List) недостаточен.
Смешивать типы без причины не стоит: видел проекты, где EventChannel использовали там, где достаточно MethodChannel с одним вызовом — это создаёт лишние подписки и утечки памяти, если StreamController не закрывается при уничтожении канала.
Где ломается чаще всего
Потоки и FlutterResult
FlutterResult — это Objective-C callback, который Flutter передаёт в Swift для ответа. Главное правило: вызывать его строго один раз. Вызвал дважды — крэш в рантайме с сообщением Call to FlutterResult callback after it has been released.
Типичная ловушка с AVCaptureSession: метод запускает сессию захвата асинхронно, результат приходит через completion на background queue. Если не диспатчить result через DispatchQueue.main.async, Flutter иногда получает ответ на неожиданном потоке — поведение непредсказуемое, баг воспроизводится не всегда.
channel.setMethodCallHandler { [weak self] call, result in
guard call.method == "startCapture" else {
result(FlutterMethodNotImplemented)
return
}
self?.session.startRunning(completion: { success, error in
DispatchQueue.main.async {
if let error = error {
result(FlutterError(code: "CAPTURE_ERROR",
message: error.localizedDescription,
details: nil))
} else {
result(success)
}
}
})
}
Сериализация через StandardMessageCodec
StandardMessageCodec умеет в Uint8List, что спасает при передаче небольших бинарных данных (превью изображения, зашифрованный payload). Но для объектов сложнее словаря — всё равно нужна ручная сериализация. Попытка передать Data напрямую без конвертации в FlutterStandardTypedData приводит к silent failure: Dart получает null вместо данных.
EventChannel и утечки
FlutterEventSink нужно обнулять в onCancel:
final class SensorStreamHandler: NSObject, FlutterStreamHandler {
private var motionManager = CMMotionManager()
private var eventSink: FlutterEventSink?
func onListen(withArguments arguments: Any?,
eventSink events: @escaping FlutterEventSink) -> FlutterError? {
eventSink = events
motionManager.startAccelerometerUpdates(to: .main) { [weak self] data, _ in
guard let data = data else { return }
self?.eventSink?(["x": data.acceleration.x, "y": data.acceleration.y])
}
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
motionManager.stopAccelerometerUpdates()
eventSink = nil // критично: без этого будут обращения к освобождённому объекту
return nil
}
}
Пропустить eventSink = nil — значит получить EXC_BAD_ACCESS через несколько минут работы, когда motionManager попытается отправить данные в уже уничтоженный канал.
Как мы строим канал
Начинаем с определения контракта: метод, аргументы, возвращаемый тип, коды ошибок. Документируем в комментариях на обеих сторонах синхронно. Это критично для команд, где iOS-разработчик и Flutter-разработчик — разные люди.
На Dart-стороне оборачиваем MethodChannel в отдельный класс-сервис с типизированным API:
class BiometricService {
static const _channel = MethodChannel('com.app/biometric');
Future<bool> authenticate(String reason) async {
try {
return await _channel.invokeMethod<bool>('authenticate', {'reason': reason}) ?? false;
} on PlatformException catch (e) {
if (e.code == 'BIOMETRIC_UNAVAILABLE') return false;
rethrow;
}
}
}
Прямое обращение к MethodChannel из виджета — антипаттерн: теряется типизация, обработка ошибок расползается по всему дереву.
Тестируем с MockMethodCallHandler в unit-тестах на Dart-стороне и через XCTest на Swift-стороне. Изолированная проверка каждой части ускоряет отладку в разы.
Что входит в работу
- Проектирование контракта канала (имена методов, типы аргументов, коды ошибок)
- Реализация Swift-обработчика с правильной потокобезопасностью
- Dart-сервис с типизированным публичным API
- Обработка edge cases: устройство не поддерживает функцию, пользователь отказал в разрешении
- Unit-тесты для обеих сторон
- Проверка на реальном устройстве (не только симулятор — многие iOS API недоступны в симуляторе)
Сроки
3–5 дней. Простой MethodChannel для одного системного вызова — 2–3 дня с тестами. EventChannel с непрерывным потоком данных и управлением жизненным циклом — 4–5 дней. Стоимость рассчитывается индивидуально после анализа требований.







