Receiving Events and Data Streams from Native Code in Your Flutter App

Flutter is a powerful cross-platform framework for building natively compiled applications from a single codebase. However, sometimes you need to integrate Flutter apps with platform-specific (native) code. One common requirement is to receive events and data streams from native code into your Flutter application. This post delves into how to achieve this using MethodChannels and EventChannels in Flutter.

Understanding Native Integration in Flutter

Flutter’s architecture allows developers to write platform-agnostic code, but it also provides mechanisms to interact with the underlying native platforms (Android and iOS) when needed. This is accomplished primarily through:

  • Method Channels: Used for one-off communications where Flutter calls native code and receives a single response.
  • Event Channels: Used for continuous, asynchronous communication from native code to Flutter. This is ideal for streaming data or receiving event updates.

Why Receive Events and Data Streams?

Several scenarios warrant receiving events and data streams from native code:

  • Hardware Sensors: Accessing and processing data from device sensors like GPS, accelerometer, gyroscope, etc.
  • Real-Time Data: Receiving real-time data feeds from native APIs (e.g., financial market data, sensor data from external devices).
  • Native Libraries: Integrating with native libraries that provide continuous data updates (e.g., camera preview feeds, audio streams).
  • Platform Events: Handling platform-specific events that require continuous monitoring and processing (e.g., network status changes, battery level updates).

Setting up the Flutter Project

First, ensure you have a Flutter project set up. If not, create a new one using:

flutter create native_stream_app
cd native_stream_app

Part 1: Method Channels – Establishing Initial Communication

Although our primary goal is event streams, it’s good practice to use Method Channels to initially configure the native components.

1. Defining the Method Channel in Flutter

Open lib/main.dart and add the following:

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

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Native Stream App',
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State {
  static const platform = MethodChannel('com.example.native_stream_app/method_channel');
  String _message = 'Waiting for native message...';

  Future _getMessageFromNative() async {
    String message;
    try {
      final String result = await platform.invokeMethod('getMessage');
      message = 'Native message: $result';
    } on PlatformException catch (e) {
      message = "Failed to get message: '${e.message}'.";
    }

    setState(() {
      _message = message;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Native Stream Example'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(_message),
            ElevatedButton(
              onPressed: _getMessageFromNative,
              child: Text('Get Native Message'),
            ),
          ],
        ),
      ),
    );
  }
}

Here, we define a MethodChannel with the name 'com.example.native_stream_app/method_channel'. The _getMessageFromNative function calls the native method getMessage.

2. Implementing the Method Channel on Android (Kotlin)

Open android/app/src/main/kotlin/com/example/native_stream_app/MainActivity.kt and modify it:

package com.example.native_stream_app

import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity: FlutterActivity() {
    private val CHANNEL = "com.example.native_stream_app/method_channel"

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

        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
            call, result ->
            if (call.method == "getMessage") {
                result.success("Hello from Android Native!")
            } else {
                result.notImplemented()
            }
        }
    }
}

This sets up the method handler for the getMessage method.

3. Implementing the Method Channel on iOS (Swift)

Open ios/Runner/AppDelegate.swift and modify it:

import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    let methodChannel = FlutterMethodChannel(name: "com.example.native_stream_app/method_channel",
                                              binaryMessenger: controller.binaryMessenger)
    methodChannel.setMethodCallHandler { (call, result) in
      if call.method == "getMessage" {
        result("Hello from iOS Native!")
      } else {
        result(FlutterMethodNotImplemented)
      }
    }
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

Similar to Android, this provides the implementation for getMessage in the native iOS environment.

Now, run the app to verify that you can receive a message from the native side when you press the button.

Part 2: Event Channels – Streaming Data from Native Code

Now, let’s set up the event channel for continuous data streaming from native code to Flutter.

1. Defining the Event Channel in Flutter

Modify lib/main.dart to include the event channel setup and stream handling:

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

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Native Stream App',
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State {
  static const methodChannel = MethodChannel('com.example.native_stream_app/method_channel');
  static const eventChannel = EventChannel('com.example.native_stream_app/event_channel');

  String _message = 'Waiting for native message...';
  String _streamMessage = 'Waiting for stream data...';
  StreamSubscription? _streamSubscription;

  @override
  void initState() {
    super.initState();
    _startStream();
  }

  Future _getMessageFromNative() async {
    String message;
    try {
      final String result = await methodChannel.invokeMethod('getMessage');
      message = 'Native message: $result';
    } on PlatformException catch (e) {
      message = "Failed to get message: '${e.message}'.";
    }

    setState(() {
      _message = message;
    });
  }

  void _startStream() {
    _streamSubscription = eventChannel.receiveBroadcastStream().listen((data) {
      setState(() {
        _streamMessage = 'Stream data: $data';
      });
    }, onError: (error) {
      setState(() {
        _streamMessage = 'Error: ${error.toString()}';
      });
    }, onDone: () {
      setState(() {
        _streamMessage = 'Stream closed';
      });
    });
  }

  void _stopStream() {
    _streamSubscription?.cancel();
    setState(() {
      _streamMessage = 'Stream stopped';
    });
  }

  @override
  void dispose() {
    _stopStream();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Native Stream Example'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(_message),
            ElevatedButton(
              onPressed: _getMessageFromNative,
              child: Text('Get Native Message'),
            ),
            SizedBox(height: 20),
            Text(_streamMessage),
          ],
        ),
      ),
    );
  }
}

In this code:

  • We define an EventChannel named 'com.example.native_stream_app/event_channel'.
  • The _startStream function listens to the stream and updates the UI with the received data.
  • The _stopStream function cancels the stream subscription when the widget is disposed of to avoid memory leaks.

2. Implementing the Event Channel on Android (Kotlin)

Modify android/app/src/main/kotlin/com/example/native_stream_app/MainActivity.kt to stream data:

package com.example.native_stream_app

import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.EventChannel
import kotlinx.coroutines.*

class MainActivity: FlutterActivity() {
    private val METHOD_CHANNEL = "com.example.native_stream_app/method_channel"
    private val EVENT_CHANNEL = "com.example.native_stream_app/event_channel"
    private var eventSink: EventChannel.EventSink? = null
    private var job: Job? = null

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

        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL).setMethodCallHandler {
            call, result ->
            if (call.method == "getMessage") {
                result.success("Hello from Android Native!")
            } else {
                result.notImplemented()
            }
        }

        EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL).setStreamHandler(
            object : EventChannel.StreamHandler {
                override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
                    eventSink = events
                    startStreaming()
                }

                override fun onCancel(arguments: Any?) {
                    eventSink = null
                    job?.cancel()
                }
            }
        )
    }

    private fun startStreaming() {
        job = CoroutineScope(Dispatchers.Default).launch {
            var counter = 0
            while (isActive) {
                delay(1000)
                val data = "Data from Android: ${counter++}"
                eventSink?.success(data)
            }
        }
    }
}

Explanation:

  • A new EventChannel is set up with the specified name.
  • setStreamHandler is used to handle the lifecycle of the stream.
  • onListen starts a coroutine that emits data every second.
  • onCancel cancels the coroutine and clears the eventSink.

3. Implementing the Event Channel on iOS (Swift)

Modify ios/Runner/AppDelegate.swift to stream data from iOS:

import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate, FlutterStreamHandler {
  private var eventSink: FlutterEventSink?
  private var timer: Timer?

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    let methodChannel = FlutterMethodChannel(name: "com.example.native_stream_app/method_channel",
                                              binaryMessenger: controller.binaryMessenger)
    methodChannel.setMethodCallHandler { (call, result) in
      if call.method == "getMessage" {
        result("Hello from iOS Native!")
      } else {
        result(FlutterMethodNotImplemented)
      }
    }

    let eventChannel = FlutterEventChannel(name: "com.example.native_stream_app/event_channel",
                                            binaryMessenger: controller.binaryMessenger)
    eventChannel.setStreamHandler(self)

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
    self.eventSink = events
    startStreaming()
    return nil
  }

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

  private func startStreaming() {
    var counter = 0
    timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
      let data = "Data from iOS: (counter)"
      self.eventSink?(data)
      counter += 1
    }
  }
}

Explanation:

  • A new FlutterEventChannel is set up.
  • The AppDelegate implements FlutterStreamHandler to manage the stream lifecycle.
  • onListen starts a timer that emits data every second.
  • onCancel stops the timer and clears the eventSink.

Run your Flutter app, and you should now see the stream data being received and displayed in real-time in your Flutter UI.

Conclusion

Receiving events and data streams from native code is a crucial part of building rich, integrated Flutter applications. By using MethodChannels for initial communication and EventChannels for continuous data streaming, you can seamlessly bridge the gap between Flutter’s cross-platform environment and platform-specific functionalities. Remember to properly manage resources, especially when dealing with streams, to ensure your app is performant and stable.