"Flutter is powerful — but the real superpower is when you combine it with native Swift and Kotlin code through Method Channel. That's how Zeno: Between Beats reads real-time health sensor data on both iOS and Android."
Flutter gives you a beautiful cross-platform UI layer — but sometimes you need to go native. Whether it's accessing HealthKit on iOS, reading step count from Google Fit on Android, using a native SDK that has no Flutter plugin, or implementing a platform-specific feature — Method Channel is your bridge.
In this guide I'll cover everything: Method Channel (one-time calls), Event Channel (continuous streams), the Dart side, the Swift side, and the Kotlin side — with real patterns used in production at YenyaSoft.
What Is Method Channel?
Method Channel is Flutter's mechanism for calling native platform code from Dart — and getting a result back. Think of it as an RPC call across the Flutter/native boundary.
| Channel type | Direction | Use case |
|---|---|---|
MethodChannel |
Dart → Native → Dart (response) | One-time calls: get battery level, open camera, fetch device info |
EventChannel |
Native → Dart (stream) | Continuous data: heart rate sensor, GPS, accelerometer |
BasicMessageChannel |
Both directions | Custom codec, bi-directional messaging |
Architecture Overview
Before writing any code, understand the three layers involved:
- Dart layer — defines the channel name and calls methods or listens to streams
- iOS layer — Swift code in
AppDelegate.swiftor a dedicated plugin class handles the call - Android layer — Kotlin code in
MainActivity.ktor a dedicated plugin handles the call
The channel name is just a unique string — use reverse-domain format:
com.yourapp/feature. Both sides must use the exact same string.
Method Channel — Step by Step
Step 1 — Dart side
Create a service class to encapsulate your channel. Never call MethodChannel
directly from UI widgets.
health_service.dartimport 'package:flutter/services.dart'; class HealthService { // Channel name must match exactly on iOS and Android static const _channel = MethodChannel('com.zenoapp/health'); // One-time call: get current step count static Future<int> getStepCount() async { try { final result = await _channel.invokeMethod<int>('getStepCount'); return result ?? 0; } on PlatformException catch (e) { debugPrint('getStepCount failed: ${e.message}'); return 0; } } // Call with arguments static Future<Map<String, dynamic>> getHealthData({ required String startDate, required String endDate, }) async { try { final result = await _channel.invokeMethod<Map>( 'getHealthData', {'startDate': startDate, 'endDate': endDate}, ); return Map<String, dynamic>.from(result ?? {}); } on PlatformException catch (e) { throw Exception('Health data unavailable: ${e.message}'); } } }
Step 2 — iOS Swift side
In Xcode, open ios/Runner/AppDelegate.swift. Register the channel and handle
method calls.
AppDelegate.swiftimport UIKit import Flutter import HealthKit // add to Info.plist: NSHealthShareUsageDescription @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { private let healthStore = HKHealthStore() override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { let controller = window?.rootViewController as! FlutterViewController // Register the channel — same name as Dart let healthChannel = FlutterMethodChannel( name: "com.zenoapp/health", binaryMessenger: controller.binaryMessenger ) healthChannel.setMethodCallHandler { [weak self] call, result in guard let self = self else { return } switch call.method { case "getStepCount": self.fetchStepCount(result: result) case "getHealthData": guard let args = call.arguments as? [String: Any], let startDate = args["startDate"] as? String, let endDate = args["endDate"] as? String else { result(FlutterError( code: "INVALID_ARGS", message: "startDate and endDate required", details: nil )) return } self.fetchHealthData(startDate: startDate, endDate: endDate, result: result) default: result(FlutterMethodNotImplemented) } } return super.application(application, didFinishLaunchingWithOptions: launchOptions) } private func fetchStepCount(result: @escaping FlutterResult) { let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount)! let query = HKStatisticsQuery( quantityType: stepType, quantitySamplePredicate: HKQuery.predicateForSamplesToday(), options: .cumulativeSum ) { _, stats, _ in let steps = stats?.sumQuantity()?.doubleValue(for: .count()) ?? 0 DispatchQueue.main.async { result(Int(steps)) } } healthStore.execute(query) } private func fetchHealthData(startDate: String, endDate: String, result: @escaping FlutterResult) { // ... parse dates, query HKHealthStore, return as [String: Any] result(["steps": 8420, "calories": 320, "heartRate": 72]) } }
Step 3 — Android Kotlin side
In android/app/src/main/kotlin/.../MainActivity.kt, do the same — same channel
name, same method names.
MainActivity.ktimport io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { private val CHANNEL = "com.zenoapp/health" // same as Dart + Swift override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) .setMethodCallHandler { call, result -> when (call.method) { "getStepCount" -> { val steps = fetchStepCount() result.success(steps) } "getHealthData" -> { val startDate = call.argument<String>("startDate") val endDate = call.argument<String>("endDate") if (startDate == null || endDate == null) { result.error("INVALID_ARGS", "startDate and endDate required", null) } else { result.success(fetchHealthData(startDate, endDate)) } } else -> result.notImplemented() } } } private fun fetchStepCount(): Int { // Query Health Connect / Google Fit SDK here return 6340 } private fun fetchHealthData(startDate: String, endDate: String): Map<String, Any> { return mapOf("steps" to 6340, "calories" to 280, "heartRate" to 68) } }
Event Channel — Continuous Streams
Method Channel is request/response. For real-time sensor data — heart rate, accelerometer,
GPS — use EventChannel. It gives you a Dart Stream that pushes
native data whenever it arrives.
Dart — listen to the stream
heart_rate_service.dartclass HeartRateService { static const _eventChannel = EventChannel('com.zenoapp/heartrate'); // Returns a broadcast stream of heart rate BPM values static Stream<int> heartRateStream() { return _eventChannel .receiveBroadcastStream() .map((event) => (event as int)); } } // Usage in a BLoC or Cubit: late final StreamSubscription<int> _sub; void startListening() { _sub = HeartRateService.heartRateStream().listen( (bpm) => emit(HeartRateUpdated(bpm)), onError: (e) => emit(HeartRateError(e.toString())), ); }
Swift — push data to Dart
AppDelegate.swift (Event Channel)var eventSink: FlutterEventSink? // Register in application(_:didFinishLaunchingWithOptions:) let hrChannel = FlutterEventChannel( name: "com.zenoapp/heartrate", binaryMessenger: controller.binaryMessenger ) hrChannel.setStreamHandler(self) // Conform to FlutterStreamHandler extension AppDelegate: FlutterStreamHandler { func onListen( withArguments arguments: Any?, eventSink: @escaping FlutterEventSink ) -> FlutterError? { self.eventSink = eventSink startHeartRateMonitoring() // start your native sensor return nil } func onCancel(withArguments arguments: Any?) -> FlutterError? { eventSink = nil stopHeartRateMonitoring() // clean up when Dart unsubscribes return nil } } // Whenever new heart rate data arrives from HKLiveWorkout or CoreMotion: func didReceiveHeartRate(_ bpm: Int) { DispatchQueue.main.async { self.eventSink?(bpm) // push to Dart stream } }
Kotlin — push data to Dart
MainActivity.kt (Event Channel)import io.flutter.plugin.common.EventChannel private var eventSink: EventChannel.EventSink? = null // In configureFlutterEngine: EventChannel(flutterEngine.dartExecutor.binaryMessenger, "com.zenoapp/heartrate") .setStreamHandler(object : EventChannel.StreamHandler { override fun onListen(arguments: Any?, events: EventChannel.EventSink) { eventSink = events startHeartRateMonitoring() // start sensor } override fun onCancel(arguments: Any?) { eventSink = null stopHeartRateMonitoring() // clean up } }) // Push data whenever a new reading arrives: fun onNewHeartRate(bpm: Int) { runOnUiThread { eventSink?.success(bpm) } }
Data Type Mapping
Method Channel automatically converts between Dart, Swift, and Kotlin types. Know this table
— wrong types cause silent PlatformException.
| Dart | Swift | Kotlin |
|---|---|---|
null |
nil |
null |
bool |
Bool |
Boolean |
int |
Int |
Int |
double |
Double |
Double |
String |
String |
String |
Uint8List |
FlutterStandardTypedData |
ByteArray |
List |
[Any] |
List<Any> |
Map |
[String: Any] |
HashMap<String, Any> |
Best Practices
- Always wrap
invokeMethodin try/catch — never letPlatformExceptionpropagate to UI - Cancel
StreamSubscriptionin your BLoC/Cubitclose()method — prevents memory leaks - Always call Event Channel callbacks on the main/UI thread —
DispatchQueue.main.asyncin Swift,runOnUiThreadin Kotlin - Use a constant for the channel name — define it once in Dart and copy/paste exactly to native
- Return
FlutterMethodNotImplemented/result.notImplemented()for unknown methods — helps debug mismatched names - For complex objects, serialize to/from
Map<String, dynamic>— don't try to pass custom class instances - Keep native code in dedicated service classes — never put business logic directly in
AppDelegate/MainActivity
Common Errors & Fixes
| Error | Cause | Fix |
|---|---|---|
MissingPluginException |
Channel name mismatch between Dart and native | Copy-paste the channel string exactly — no trailing spaces |
PlatformException: null |
Native returned nil/null where
non-null expected |
Use invokeMethod<T?> and handle null in
Dart |
| No data on stream | onListen called but native sensor not started
|
Verify startSensor() is inside
onListen, not viewDidLoad
|
| UI freezes | Native code doing heavy work on main thread | Dispatch long operations to background thread, callback on main |
type 'int' is not a subtype |
Native returned Long in Kotlin (64-bit) but
Dart expected int |
Cast to Int in Kotlin before calling
result.success()
|
Production Patterns — Lessons from Zeno
In Zeno: Between Beats, Method Channel bridges HealthKit on iOS and Health Connect on Android to the same Flutter UI. A few patterns that made it maintainable:
- One Dart
PlatformHealthServiceclass owns all channels — BLoC never touches channels directly - Every method has a matching error code enum —
PERMISSION_DENIED,SENSOR_UNAVAILABLE,TIMEOUT— so the UI shows the right message - Native code is kept in a separate Swift file (
HealthBridge.swift) and Kotlin file (HealthBridge.kt), not inAppDelegate/MainActivity— much easier to maintain and test - Event Channel subscriptions are managed by the Cubit lifecycle —
startStream()inonListen, cancelled inclose()
Method Channel isn't a workaround — it's the intended architecture for accessing platform capabilities Flutter doesn't expose natively. Master it and you can unlock literally any native SDK from your Flutter app. 🚀
By Rohan Kumar Chaudhary | Flutter · iOS · Mobile Developer 💙
Happy Coding!