DEV Community

kanta13jp1
kanta13jp1

Posted on

Flutter Platform Channels Advanced — Type-Safe Dart-Native Communication

Flutter Platform Channels Advanced — Type-Safe Dart-Native Communication

Raw dynamic types in Flutter's MethodChannel are a hidden source of runtime errors. This article covers type-safe channel design, automatic code generation with Pigeon, and error-handling best practices.

Platform Channel Architecture

Flutter provides three channel types:

Dart (Flutter) ←→ Platform Channel ←→ Native (iOS/Android)
  MethodChannel    one-shot method calls
  EventChannel     continuous streams
  BasicMessageChannel  string/binary data
Enter fullscreen mode Exit fullscreen mode

The core issue with raw MethodChannel is the absence of compile-time type safety.

// ❌ Unsafe legacy approach
static const _channel = MethodChannel('com.example.app/sensor');

Future<double> getBatteryLevel() async {
  final result = await _channel.invokeMethod('getBatteryLevel');
  return result as double; // runtime cast failure waiting to happen
}
Enter fullscreen mode Exit fullscreen mode

Pigeon: Auto-Generated Type-Safe Channels

Pigeon generates native code from Dart interface definitions, eliminating manual casting entirely.

// pigeons/sensor_api.dart (definition file)
import 'package:pigeon/pigeon.dart';

@ConfigurePigeon(PigeonOptions(
  dartOut: 'lib/platform/sensor_api.g.dart',
  swiftOut: 'ios/Runner/SensorApi.g.swift',
  kotlinOut: 'android/app/src/main/kotlin/SensorApi.g.kt',
))
class BatteryInfo {
  BatteryInfo({required this.level, required this.isCharging});
  final double level;
  final bool isCharging;
}

@HostApi()
abstract class SensorHostApi {
  BatteryInfo getBatteryInfo();
  @async
  BatteryInfo refreshBatteryInfo();
}

@FlutterApi()
abstract class SensorFlutterApi {
  void onBatteryLevelChanged(double level);
}
Enter fullscreen mode Exit fullscreen mode

Run the generator:

dart run pigeon --input pigeons/sensor_api.dart
Enter fullscreen mode Exit fullscreen mode

Using Generated Code

// lib/platform/sensor_service.dart
import 'sensor_api.g.dart';

class SensorService {
  final SensorHostApi _api = SensorHostApi();

  Future<BatteryInfo> getBatteryInfo() async {
    try {
      return await _api.refreshBatteryInfo();
    } on PlatformException catch (e) {
      throw SensorException(e.code, e.message);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The generated code contains zero dynamic casts — type errors surface at compile time.

EventChannel for Real-Time Native Data

Use EventChannel for continuous native events:

class AccelerometerService {
  static const _channel = EventChannel('com.example.app/accelerometer');

  Stream<AccelerometerData> get stream => _channel
      .receiveBroadcastStream()
      .map((event) => AccelerometerData.fromMap(Map<String, dynamic>.from(event)));
}

class AccelerometerData {
  const AccelerometerData({
    required this.x,
    required this.y,
    required this.z,
    required this.timestamp,
  });

  final double x;
  final double y;
  final double z;
  final DateTime timestamp;

  factory AccelerometerData.fromMap(Map<String, dynamic> map) {
    return AccelerometerData(
      x: (map['x'] as num).toDouble(),
      y: (map['y'] as num).toDouble(),
      z: (map['z'] as num).toDouble(),
      timestamp: DateTime.fromMillisecondsSinceEpoch(map['timestamp'] as int),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

iOS Implementation (Swift)

// AppDelegate.swift
import Flutter
import UIKit
import CoreMotion

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  private let motionManager = CMMotionManager()
  private var eventSink: FlutterEventSink?

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller = window?.rootViewController as! FlutterViewController
    let channel = FlutterEventChannel(
      name: "com.example.app/accelerometer",
      binaryMessenger: controller.binaryMessenger
    )
    channel.setStreamHandler(self)
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

extension AppDelegate: FlutterStreamHandler {
  func onListen(
    withArguments arguments: Any?,
    eventSink events: @escaping FlutterEventSink
  ) -> FlutterError? {
    self.eventSink = events
    motionManager.accelerometerUpdateInterval = 0.1
    motionManager.startAccelerometerUpdates(to: .main) { [weak self] data, error in
      guard let data = data else { return }
      self?.eventSink?([
        "x": data.acceleration.x,
        "y": data.acceleration.y,
        "z": data.acceleration.z,
        "timestamp": Int(Date().timeIntervalSince1970 * 1000)
      ])
    }
    return nil
  }

  func onCancel(withArguments arguments: Any?) -> FlutterError? {
    motionManager.stopAccelerometerUpdates()
    eventSink = nil
    return nil
  }
}
Enter fullscreen mode Exit fullscreen mode

Android Implementation (Kotlin)

// MainActivity.kt
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager

class MainActivity: FlutterActivity() {
    private val sensorManager by lazy {
        getSystemService(SENSOR_SERVICE) as SensorManager
    }

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        EventChannel(
            flutterEngine.dartExecutor.binaryMessenger,
            "com.example.app/accelerometer"
        ).setStreamHandler(object : EventChannel.StreamHandler {
            private var listener: SensorEventListener? = null

            override fun onListen(args: Any?, sink: EventChannel.EventSink) {
                val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
                listener = object : SensorEventListener {
                    override fun onSensorChanged(event: SensorEvent) {
                        sink.success(mapOf(
                            "x" to event.values[0].toDouble(),
                            "y" to event.values[1].toDouble(),
                            "z" to event.values[2].toDouble(),
                            "timestamp" to System.currentTimeMillis()
                        ))
                    }
                    override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
                }
                sensorManager.registerListener(
                    listener, sensor, SensorManager.SENSOR_DELAY_NORMAL
                )
            }

            override fun onCancel(args: Any?) {
                sensorManager.unregisterListener(listener)
                listener = null
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Type-Safe Error Handling

sealed class PlatformChannelException implements Exception {
  const PlatformChannelException(this.message);
  final String message;
}

class PermissionDeniedException extends PlatformChannelException {
  const PermissionDeniedException() : super('Sensor access permission denied');
}

class HardwareUnavailableException extends PlatformChannelException {
  const HardwareUnavailableException(String sensor)
      : super('$sensor sensor is not available on this device');
}

extension PlatformExceptionMapper on PlatformException {
  PlatformChannelException toTyped() => switch (code) {
    'PERMISSION_DENIED' => const PermissionDeniedException(),
    'HARDWARE_UNAVAILABLE' => HardwareUnavailableException(message ?? 'Unknown'),
    _ => PlatformChannelException('Unexpected error: $message'),
  };
}
Enter fullscreen mode Exit fullscreen mode

Testing Strategy

// test/platform/sensor_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/services.dart';

void main() {
  TestWidgetsFlutterBinding.ensureInitialized();

  group('SensorService', () {
    setUp(() {
      TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
          .setMockMethodCallHandler(
        const MethodChannel('com.example.app/sensor'),
        (MethodCall call) async {
          if (call.method == 'getBatteryInfo') {
            return {'level': 85.0, 'isCharging': true};
          }
          throw PlatformException(code: 'NOT_IMPLEMENTED');
        },
      );
    });

    tearDown(() {
      TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
          .setMockMethodCallHandler(
              const MethodChannel('com.example.app/sensor'), null);
    });

    test('getBatteryInfo returns typed data', () async {
      final service = SensorService();
      final info = await service.getBatteryInfo();
      expect(info.level, 85.0);
      expect(info.isCharging, true);
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Performance Optimization

Platform Channel calls are non-blocking on the main thread, but high-frequency message passing can degrade performance.

// ✅ Throttle to reduce call frequency
class ThrottledSensorService {
  Stream<AccelerometerData> get stream => AccelerometerService()
      .stream
      .throttleTime(const Duration(milliseconds: 100)); // limit to 10 Hz
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Pigeon auto-generates type-safe bindings — eliminates dynamic casting
  2. Sealed classes + switch provide exhaustive error coverage
  3. EventChannel handles continuous native streams elegantly
  4. Mock MethodCall handlers make unit tests straightforward
  5. Throttling prevents performance degradation from high-frequency events

Type-safe native integration applies to plugins, sensors, file system access, and more. Adopting Pigeon alone prevents the majority of runtime channel errors.


Building a life-management app that replaces 21 competing SaaS tools with Flutter + Supabase. Follow the journey → @kanta13jp1

Top comments (0)