Skip to content
Blog

EventChannel for Streaming Data in Flutter: A Platform Channels Deep Dive

How to stream continuous data from native Android and iOS code into Flutter with EventChannel — sensor readings, location updates, and native events as Dart Streams.

Published on • October 28, 2026

AI Assistant

MethodChannel is Flutter’s best-known bridge to native code: call a method, get a result back. But many native capabilities don’t produce a single result — they produce a stream of events: sensor readings, location updates, Bluetooth characteristics, media player progress, connectivity changes.

For that, Flutter provides EventChannel — a one-way, native-to-Dart stream bridge. This post explains how it works, how to implement it on both platforms, and how to use it idiomatically from Dart.

The Three Channel Types

Flutter’s platform channels come in three flavors:

ChannelDirectionUse case
MethodChannelDart ⇄ native, request/responseOne-off calls: get battery level, open a file
EventChannelnative → Dart, continuousStreams: sensors, location, media progress
BasicMessageChannelbidirectional messagesCustom protocols, codecs

EventChannel is the one you reach for when the native side has a source of ongoing values and the Dart side wants a Stream.

How EventChannel Works Under the Hood

An EventChannel has a name and a MethodCodec. When Dart subscribes to the stream, the framework makes a hidden method call to the native side:

  1. onListen — invoked on the native side when the first Dart listener subscribes. This is where you start your native event source (e.g., register a sensor listener) and begin sending events.
  2. Events flow native → Dart — each value is encoded with the codec and delivered to every Dart listener.
  3. onCancel — invoked when the last Dart listener cancels. Stop the native source here to avoid leaks.

The native side pushes events through an EventSink; Dart receives them as a broadcast Stream.

This lifecycle contract is the most important thing to get right: start work in onListen, release work in onCancel, and make sure multiple subscribers don’t start duplicate native listeners.

Defining the Channel (Dart Side)

import 'dart:async';
import 'package:flutter/services.dart';

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

  static Stream<double> get amplitude {
    return _channel.receiveBroadcastStream().map((event) {
      final list = event as List<dynamic>;
      return (list[0] as num).abs() + (list[1] as num).abs() + (list[2] as num).abs();
    });
  }
}

receiveBroadcastStream() returns a broadcast Stream<dynamic>. Values arrive as whatever the standard codec supports: null, num, bool, String, Uint8List, List, Map. For complex payloads, encode as JSON (String) or send a typed data list.

Using it in a widget:

StreamBuilder<double>(
  stream: MotionStream.amplitude,
  initialData: 0,
  builder: (context, snapshot) {
    final amplitude = snapshot.data ?? 0;
    return Text('Motion: ${amplitude.toStringAsFixed(2)}');
  },
);

Because it’s a normal Dart stream, you get where, map, debounceTime, throttle (via rxdart), and StreamBuilder for free.

Android Implementation (Kotlin)

class MotionStreamHandler : EventChannel.StreamHandler {
    private var sensorManager: SensorManager? = null
    private var eventSink: EventChannel.EventSink? = null

    private val sensorListener = object : SensorEventListener {
        override fun onSensorChanged(event: SensorEvent) {
            eventSink?.success(listOf(event.values[0], event.values[1], event.values[2]))
        }
        override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
    }

    fun init(context: Context) {
        sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
    }

    override fun onListen(args: Any?, events: EventChannel.EventSink?) {
        eventSink = events
        val sensor = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
        sensor?.let {
            sensorManager?.registerListener(
                sensorListener, it, SensorManager.SENSOR_DELAY_GAME
            )
        }
    }

    override fun onCancel(args: Any?) {
        sensorManager?.unregisterListener(sensorListener)
        eventSink = null
    }
}

// In your FlutterActivity's configureFlutterEngine:
val channel = EventChannel(flutterEngine.dartExecutor.binaryMessenger, "com.example.app/motion")
MotionStreamHandler().init(this)
channel.setStreamHandler(MotionStreamHandler())

iOS Implementation (Swift)

class MotionStreamHandler: NSObject, FlutterStreamHandler {
    private var eventSink: FlutterEventSink?
    private let motionManager = CMMotionManager()

    func onListen(withArguments arguments: Any?,
                  eventSink events: @escaping FlutterEventSink) -> FlutterError? {
        eventSink = events
        motionManager.accelerometerUpdateInterval = 0.1
        motionManager.startAccelerometerUpdates(to: .main) { data, error in
            guard let data = data else { return }
            events([data.acceleration.x, data.acceleration.y, data.acceleration.z])
        }
        return nil
    }

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

// In AppDelegate / FlutterViewController setup:
let channel = FlutterEventChannel(name: "com.example.app/motion",
                                  binaryMessenger: controller.binaryMessenger)
channel.setStreamHandler(MotionStreamHandler())

Sending Errors and Done

The native EventSink supports three outcomes:

  • success(value) — emit an event.
  • error(code, message, details) — deliver a PlatformException to the Dart stream as an error event.
  • endOfStream() — close the stream; Dart listeners receive onDone.

Use endOfStream() when the native source is finite (e.g., a scan that completes). Use error() for recoverable failures like permission denied or hardware unavailable — the Dart side can then handle them with StreamBuilder’s hasError or a try/catch around the subscription.

Patterns and Pitfalls

One listener, many consumers

receiveBroadcastStream() returns a broadcast stream, so multiple widgets can listen. However, each listen call on the underlying channel is reference-counted: onListen fires when the count goes 0 → 1, onCancel when it goes 1 → 0. Native resources are therefore started once and stopped once — but only if you always use the same channel instance.

Don’t leak listeners

The classic bug: subscribing in a widget’s initState and never cancelling. Always store the StreamSubscription and cancel it in dispose — or sidestep the issue entirely by using StreamBuilder, which manages subscription lifecycles for you.

Threading

On Android, sensor callbacks arrive on the thread the handler was registered from; on iOS, the completion handler runs on the queue you specify. Don’t block these callbacks — they feed the UI. Do heavy work in a separate isolate or background executor and send the result through the sink.

Backpressure

EventChannels have no built-in backpressure. A fast native source (e.g., 200Hz sensors) will happily flood the Dart side. Throttle at the source when possible, or apply stream operators (throttle, sample) on the Dart side.

Encoding overhead

Each event crosses the platform bridge and is encoded by the codec. Sending a JSON string per event is convenient but allocates repeatedly; for very high rates, prefer Float64List/Uint8List (StandardMethodCodec supports typed data natively) to minimize copies.

EventChannel vs Alternatives

  • Polling with MethodChannel — simplest, but wastes battery and adds latency. Use only for genuinely low-frequency data.
  • Pigeon — generates type-safe channel code including event streams, eliminating hand-written glue. Great for production apps that want compile-time safety over hand-rolled channels.
  • FFI (dart:ffi) — for C/C++ libraries, bypassing the codec entirely with direct function calls and callbacks. More control, more responsibility.

EventChannel remains the right default for native-platform event sources: it’s built into the framework, integrates with Dart’s Stream model, and handles the listen/cancel lifecycle for you.

Conclusion

EventChannel turns native event sources into ordinary Dart streams. Define a channel name, implement onListen/onCancel on the native side to manage the underlying source, push values through EventSink, and consume the result with StreamBuilder or any stream operator. Get the lifecycle right, throttle aggressive sources, and the bridge effectively disappears — leaving you with reactive, platform-powered Flutter UIs.

Further Reading