Розробка 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 на фоновому потоці. Якщо не диспетчити 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 приводить до мовчазної помилки: 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
- Обробка граничних випадків: пристрій не підтримує функцію, користувач відмовив у дозволі
- Unit-тести для обох сторін
- Перевірка на реальному пристрої (не тільки симулятор — багато iOS API недоступні в симуляторі)
Графік
3–5 днів. Простий MethodChannel для одного системного виклику — 2–3 дні з тестами. EventChannel з безперервним потоком даних та управлінням життєвим циклом — 4–5 днів. Вартість розраховується індивідуально після аналізу вимог.







