Platform Channels & Native Integration

EventChannel: Streaming Native Events to Dart

16 min Lesson 5 of 11

EventChannel: Streaming Native Events to Dart

EventChannel is Flutter's mechanism for creating a continuous, asynchronous stream of data flowing from native platform code (Android/iOS) into Dart. Unlike MethodChannel, which is request–response (one call, one result), an EventChannel models a broadcast stream: the native side pushes events whenever they occur, and the Dart side reacts via a standard Stream subscription. Common real-world uses include sensor readings, connectivity changes, geolocation updates, Bluetooth scan results, and battery-level monitoring.

Note: EventChannel is a one-directional stream — native pushes events to Dart. If you also need Dart to send data to native, combine it with a MethodChannel.

How EventChannel Works

The lifecycle has two sides that mirror each other:

  • Dart side — creates an EventChannel with a unique name and calls .receiveBroadcastStream() to obtain a Stream. Subscribing to that stream (listen) signals native to start, and cancelling the subscription signals native to stop.
  • Native side — registers a StreamHandler (Android: EventChannel.StreamHandler; iOS: FlutterStreamHandler). The handler's onListen callback receives an EventSink (Android) / FlutterEventSink (iOS) which is used to push events. The onCancel callback tears down any platform resources.
Tip: The channel name string must be identical on both the Dart side and the native side — a mismatch silently produces no events. Use a reverse-domain convention such as com.example.myapp/battery.

Dart-Side Implementation

On the Dart side you create one EventChannel instance, expose the stream, and subscribe to it inside a StatefulWidget so you can cancel the subscription when the widget is disposed:

Dart — battery level stream

import 'package:flutter/services.dart';

// 1. Declare the channel (same name as native)
const EventChannel _batteryChannel =
    EventChannel('com.example.myapp/battery');

class BatteryPage extends StatefulWidget {
  const BatteryPage({super.key});

  @override
  State<BatteryPage> createState() => _BatteryPageState();
}

class _BatteryPageState extends State<BatteryPage> {
  // 2. Hold the subscription so we can cancel it
  StreamSubscription<dynamic>? _subscription;
  String _batteryLevel = 'Unknown';

  @override
  void initState() {
    super.initState();
    // 3. Subscribe — this triggers onListen on the native side
    _subscription = _batteryChannel
        .receiveBroadcastStream()
        .listen(
          (dynamic event) {
            setState(() {
              _batteryLevel = '$event%';
            });
          },
          onError: (dynamic error) {
            setState(() {
              _batteryLevel = 'Error: ${(error as PlatformException).message}';
            });
          },
        );
  }

  @override
  void dispose() {
    // 4. Cancel — this triggers onCancel on the native side
    _subscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Battery Monitor')),
      body: Center(
        child: Text(
          'Battery: $_batteryLevel',
          style: Theme.of(context).textTheme.headlineMedium,
        ),
      ),
    );
  }
}

Android (Kotlin) StreamHandler Setup

In your MainActivity.kt, register the channel and implement EventChannel.StreamHandler. The onListen method starts your native event source and stores the EventSink. The onCancel method cleans up resources.

Android Kotlin — battery StreamHandler

import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager

class MainActivity : FlutterActivity() {

    private val CHANNEL = "com.example.myapp/battery"
    private var eventSink: EventChannel.EventSink? = null
    private var batteryReceiver: BroadcastReceiver? = null

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

        EventChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
            .setStreamHandler(object : EventChannel.StreamHandler {

                override fun onListen(arguments: Any?, sink: EventChannel.EventSink) {
                    // Store the sink for later use
                    eventSink = sink
                    // Register the broadcast receiver
                    batteryReceiver = object : BroadcastReceiver() {
                        override fun onReceive(ctx: Context?, intent: Intent?) {
                            val level = intent?.getIntExtra(
                                BatteryManager.EXTRA_LEVEL, -1
                            ) ?: -1
                            if (level == -1) {
                                sink.error("UNAVAILABLE", "Battery level unavailable", null)
                            } else {
                                sink.success(level)
                            }
                        }
                    }
                    registerReceiver(batteryReceiver,
                        IntentFilter(Intent.ACTION_BATTERY_CHANGED))
                }

                override fun onCancel(arguments: Any?) {
                    // Unregister to avoid memory leaks
                    unregisterReceiver(batteryReceiver)
                    batteryReceiver = null
                    eventSink = null
                }
            })
    }
}

iOS (Swift) StreamHandler Setup

In AppDelegate.swift, register the channel with a FlutterEventChannel and implement FlutterStreamHandler. Use a Timer or system API to push events through the stored FlutterEventSink.

iOS Swift — battery StreamHandler

import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {

    private let channelName = "com.example.myapp/battery"
    private var eventSink: FlutterEventSink?
    private var timer: Timer?

    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        let controller = window?.rootViewController as! FlutterViewController
        let batteryChannel = FlutterEventChannel(
            name: channelName,
            binaryMessenger: controller.binaryMessenger
        )
        batteryChannel.setStreamHandler(self)
        UIDevice.current.isBatteryMonitoringEnabled = true
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
}

extension AppDelegate: FlutterStreamHandler {

    func onListen(withArguments arguments: Any?,
                  eventSink events: @escaping FlutterEventSink) -> FlutterError? {
        self.eventSink = events
        // Poll battery every 10 seconds
        timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in
            let level = Int(UIDevice.current.batteryLevel * 100)
            self?.eventSink?(level)
        }
        // Send immediately
        eventSink?(Int(UIDevice.current.batteryLevel * 100))
        return nil
    }

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

Stream Cancellation and Resource Management

Proper teardown is critical to avoid memory leaks and battery drain. Follow these rules:

  • Always store the StreamSubscription returned by listen() and call cancel() in dispose().
  • On Android, unregister receivers, stop location updates, or cancel timers inside onCancel.
  • On iOS, invalidate timers, remove observers, and nil out the eventSink reference in onCancel.
  • Never call sink.success() after onCancel has fired — doing so crashes the app.
Warning: Storing and using an EventSink / FlutterEventSink after onCancel is called will throw an exception. Always null-check or set the sink reference to null/nil in your cancel handler.

Passing Arguments to the Stream

You can pass configuration arguments from Dart to the native onListen callback via receiveBroadcastStream(arguments):

Dart — passing arguments to onListen

// Dart: pass a refresh interval argument
_subscription = _sensorChannel
    .receiveBroadcastStream({'intervalMs': 500})
    .listen((dynamic event) {
      // handle event
    });

// Kotlin onListen receives it as a Map
override fun onListen(arguments: Any?, sink: EventChannel.EventSink) {
    val args = arguments as? Map<*, *>
    val intervalMs = (args?.get("intervalMs") as? Int) ?: 1000
    // use intervalMs to configure your sensor polling rate
}

Summary

EventChannel enables a native-to-Dart push stream for continuous events. The Dart side calls receiveBroadcastStream().listen() to start and cancels the subscription to stop. Android uses EventChannel.StreamHandler with an EventSink; iOS uses FlutterStreamHandler with a FlutterEventSink. Always clean up native resources in onCancel and null the sink reference to prevent post-cancel crashes. Optional arguments passed via receiveBroadcastStream(args) let you configure the native stream on a per-subscription basis.