An In-Depth Dive Into Streaming Data Across Platform Channels on Flutter

In this tutorial, you’ll learn how to use Platform Channels to stream data into your Flutter app. By Wilberforce Uwadiegwu.

Leave a rating/review
Download materials
Save for later
Share

Flutter’s Platform Channels are a set of APIs that facilitate calling native platform APIs from Dart. There are three main platform channel APIs that can be used to communicate back and forth with the underlying native technology: MessageChannel, MethodChannel and EventChannel. These APIs provide an interface to call methods and pass data between code written in Java/Kotlin or Objective-C/Swift and Dart.

Platform channels make it easy to integrate native functionality, like camera, geolocation and deep links, into Flutter apps. In this tutorial, you’ll use platform channels to stream predefined events and arbitrary data from iOS and Android to Dart. You’ll learn:

  • How to use Event Channels to stream connectivity information to your flutter app.
  • Using Event Channels to send images in the form of binary data to your flutter app.
  • Setting up both native Android and iOS clients to interact with your Dart code.

Getting Started

To get started, download the starter project by clicking Download Materials at the top or bottom of this tutorial.

Open starter with the latest version of Android Studio or Visual Studio. Then, run flutter packages get from Terminal. If you’re building for iOS, you’ll also need to install iOS dependencies by navigating to the iOS directory and running pod install.

Run the project. You’ll see something like this:

Starter project screenshot

In this tutorial, you’ll build an app whose UI reacts in real-time to the platform’s network state events. Then, you’ll add support to stream and display an image’s bytes from the platform on Flutter.

But first, take a closer look at platform channels.

Platform Channels: An Overview

Like previously mentioned, Platform Channels are a way to send information from Dart to the native iOS or Android code. The platform channels API is made up of a few different utilities:

Binary Messaging is the lowest level Platform channel API. It’s a bi-directional and synchronous API that sits between Dart and platform code and facilitates passing byte buffers over a channel. It’s used to send sequences of bytes between Dart and platforms, such as iOS and Android.

The rest of the platform channel APIs are built atop Binary Messaging. They add support for high-level data structures like strings, maps and lists. As stated earlier, platform channel consists of MessageChannel, MethodChannel and EventChannel.

MessageChannel exposes raw data in the form of byte buffers. You can pass a codec to interpret the bytes to a high-level data structure, like an image. While Dart provides some off-the-shelf codecs, you can also write your own custom ones.

MethodChannel invokes named methods, with or without arguments, between Dart and native code. You can use it to send a one-off send-and-reply message. It doesn’t support a continuous stream of data.

EventChannel exposes data from the platform to Dart as streams. When you subscribe to an event channel on the Dart end, you get a continuous stream of data from iOS or Android.

To recap, Platform Channels allow you to send and received data both to and from platforms across a channel. In this case, the channel is a string, an identifier of the message and its destination.

With that out of the way, it’s time to write some code and start consuming some events!

Setting Up Event Consumption in Dart

Before you can use an EventChannel, you need to declare it.

Open network_stream_widget.dart. Inside NetworkStreamWidget, declare the eventChannel above build() like this:

final _eventChannel = const EventChannel('platform_channel_events/connectivity');

Then import 'package:flutter/services.dart' in the same file.

Congraulations, you’ve just created your first platform channel! You created an EventChannel. The eventChannel‘s name is "platform_channel_events/connectivity". The name can be anything you want, but it has to be unique.

Next, you’ll need a set of constant values for the supported network states. Like the event name, these values can be arbitrary, but must be consistent across Android, iOS, and Dart.

In constants.dart, inside Constants, add:

/// Event for when network state changes to Wifi
static const wifi = 0xFF;

/// Event for when network state changes to cellular/mobile data
static const cellular = 0xEE;

/// Event for when network is disconnected
static const disconnected = 0xDD;

/// Event for when network state is a state you do not
/// support (e.g VPN or Ethernet on Android)
static const unknown = 0xCC;

This code only supports wifi, cellular and disconnected states. You can use unknown for any other network state that is out of the scope of this tutorial.

That set of constants is great, but it’d be great to have an enumerated set of values for better type safety. In utils.dart, add the following declaration right above the commented statements:

/// Connection is an enum of supported network states
enum Connection {
  /// When connection state is [Constants.wifi]
  wifi,

  /// When connection state is [Constants.cellular]
  cellular,

  /// When connection state is [Constants.disconnected]
  disconnected,

  /// When connection state is [Constants.unknown]
  unknown
}

Now that you’ve got an enum, you need a function to map the network constants to Connection. First, import 'constants.dart' in utils.dart. Then add this statement below the enum you just declared:

/// converts the network events to the appropriate values of 
/// the [Connection] enum
Connection intToConnection(int connectionInt) {
  var connection = Connection.unknown;
  switch (connectionInt) {
    case Constants.wifi:
      connection = Connection.wifi;
      break;
    case Constants.cellular:
      connection = Connection.cellular;
      break;
    case Constants.disconnected:
      connection = Connection.disconnected;
      break;
    case Constants.unknown:
      connection = Connection.unknown;
      break;
  }
  return connection;
}

Each connection state is going to be shown with a different color and different text, so you’ll need two more similar functions: getConnectionColor() and getConnectionMessage(). getConnectionColor() maps Connection to Color objects while getConnectionMessage() maps Connection to user-readable strings.

You’ll find these functions in utils.dart. Uncomment them and then import 'package:flutter/material.dart'.

Since you’re using an EventChannel, you need to get the actual stream from the eventChannel object.

Open network_stream_widget.dart. In NetworkStreamWidget, just above the return statement in build(), add:

final networkStream = _eventChannel
    .receiveBroadcastStream()
    .distinct()
    .map((dynamic event) => intToConnection(event as int));

Then import 'utils.dart' into same file.

In the piece of code above, you use the receiveBroadcastStream() method to get a stream of events from the EventChannel. You used the distinct() method to filter out duplicate events. Finally, you converted the events to Connection values with intToConnection().

Run the project. You’ll see a UI similar to the previous screenshot.

Starter project screenshot

Reacting to Streamed Events

Now it’s time to update the UI. You’re going to use a StreamBuilder since you’re operating on a Stream returned from eventChannel.

Still in NetworkStreamWidget, replace the returned widget in build() with:

@override
Widget build(BuildContext context) {
  ...
  return StreamBuilder<Connection>(
    initialData: Connection.disconnected,
    stream: networkStream,
    builder: (context, snapshot) {
      final connection = snapshot.data ?? Connection.unknown;
      final message = getConnectionMessage(connection);
      final color = getConnectionColor(connection);
      return _NetworkStateWidget(message: message, color: color);
    },
  );
}

With the statements above:

  1. First, you return a StreamBuilder from build().
  2. You pass Connection.disconnected as the initial state for the widget, and networkStream as the actual Stream being used.
  3. In StreamBuilder‘s builder(), you get the event from the snapshot and convert it to color and user-readable string.
  4. Finally, you pass these values to _NetworkStateWidget. _NetworkStateWidget builds the widgets necessary to display the network states.

Run the project. You’ll something like this:

First run after setting up event channels on Flutter

It’s starting to look good, but it’s not reactive yet. Also, a MissingPluginException error appears in the log, but that’s expected for now.

Now that you have everything all wired up on the Dart side, it’s time to
tackle event dispatching on Android.

Event Dispatching on Android

Since you’re going to be working with native android code, you’ll write this section in Kotlin. You’ll set up an EventChannel with the same name you used earlier. Then you’ll listen for network state changes and notify the event callback of the EventChannel when the network state changes.

Setting Up the EventChannel

The first step is to create an EventChannel in native Android code to mimic the one you created in Dart.

In the starter project directory, open the android folder with Android Studio. Now open the MainActivity class and declare the channel name above configureFlutterEngine():

private val networkEventChannel = "platform_channel_events/connectivity"

Next, in configureFlutterEngine(), right below the call to super add:

EventChannel(flutterEngine.dartExecutor.binaryMessenger, networkEventChannel)
    .setStreamHandler(NetworkStreamHandler(this))

And then import io.flutter.plugin.common.EventChannel.

The above code creates a new EventChannel with the channel name you specified in the last step. It uses the default BinaryMessenger provided by the flutter engine and then sets a StreamHandler to facilitate the actual event stream.

You’re done with MainActivity for now. Good job! You’re killing it!👏

Now open NetworkStreamHandler, and declare the event callback above onListen() like this:

private var eventSink: EventChannel.EventSink? = null

An EventSink is just a fancy callback.

Inside onListen(), assign eventSink and call startListeningNetworkChanges():

eventSink = events
startListeningNetworkChanges()

To save you some time, startListeningNetworkChanges() is already implemented for you. It gets a reference to Android’s connectivity manager and registers networkCallback. networkCallback receives changes to the network state.

Next, you’ll work on dispatching connectivity events.

Dispatching Events

Now you’ll declare constants for the supported network states in Android, just like you did in Flutter.

In the Constants.kt file, add these statements inside Constants:

const val wifi = 0xFF
const val cellular = 0xEE
const val disconnected = 0xDD
const val unknown = 0xCC

The actual event dispatching to Flutter happens in networkCallback inside NetworkStreamHandler. Override onLost() and onCapabilitiesChanged() like this:

private val networkCallback = object : ConnectivityManager.NetworkCallback() {
    override fun onLost(network: Network) {
        super.onLost(network)
        // Notify Flutter that the network is disconnected
        activity?.runOnUiThread { eventSink?.success(Constants.disconnected) }
    }

    override fun onCapabilitiesChanged(network: Network, netCap: NetworkCapabilities) {
        super.onCapabilitiesChanged(network, netCap)
        // Pick the supported network states and notify Flutter of this new state
        val status =
            when {
                netCap.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> Constants.wifi
                netCap.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> Constants.cellular
                else -> Constants.unknown
            }
        activity?.runOnUiThread { eventSink?.success(status) }
    }
}

Make sure to add the import: import android.net.Network.

The above code may be intimidating, but it’s actually pretty simple. Here’s a breakdown:

  1. First, you’re sending a Constants.disconnected message to the eventSink callback whenever connectivity is lost.
  2. Then, you’re checking what the network state is in the onCapabilitiesChanged method. If the network has WIFI you send the wifi constant. If it has cellular, you send the cellular constant. Otherwise you send an unknown constant.

Note that the OS calls both onLost and onCapabilitiesChanged in the background. Since Flutter requires you to send platform channel events on the main thread, runOnUiThread is used.

Next, you’ll clean up after yourself by tearing down the EventChannel.

Tearing Down the EventChannel

To shut down the event channel, you need to stop listening to network changes and nullify the fields.

Still in NetworkStreamHandler, call the following statements inside onCancel():

stopListeningNetworkChanges()
eventSink = null
activity = null

Like startListeningNetworkChanges(), stopListeningNetworkChanges() is already implemented for you.

Run the project on Android. Toggle Wifi and Mobile Data and you’ll see the UI update:

Show network connnectivity changes

That’s all for Android! Time to head over to iOS.

Event Dispatching on iOS

Now that you’re in iOS land, you’ll write this section in Swift with Xcode.

In the ios folder in starter project open Runner.xcworkspace with Xcode.

As you did for Android, you’ll set up an EventChannel with the same name as Flutter. Then, you’ll listen for network state changes and dispatch the events to Flutter.

First, you’ll set up the EventChannel.

Setting Up EventChannel

Inside AppDelegate.swift, declare the channel name above application() in AppDelegate:

private let networkEventChannel = "platform_channel_events/connectivity"

You’ll be using Reachability, a popular iOS library, to listen for changes to network states.

Still in AppDelegate, paste these statements inside application(), above GeneratedPluginRegistrant.register(with: self):

let controller = window?.rootViewController as! FlutterViewController   
FlutterEventChannel(name: networkEventChannel, binaryMessenger: controller.binaryMessenger)
            .setStreamHandler(NetworkStreamHandler(reachability: reachability))

In the statements above, you:

  1. Get a reference to the root ViewController and instantiate an EventChannel with the same name you used in Flutter and Android.
  2. Then pass an instance of NetworkStreamHandler as the stream handler to the EventChannel. NetworkStreamHandler is responsible for dispatching the events to Flutter.

Next up you’ll dispatch the actual connectivity events.

Dispatching Events

Once again, in Constants.swift add the following constants as members of Constants:

static let wifi = 0xFF
static let cellular = 0xEE
static let disconnected = 0xDD
static let unknown = 0xCC

Now, open NetworkStreamHandler.swift. Then declare the event callback right below the Reachability variable in NetworkStreamHandler:

private var eventSink: FlutterEventSink? = nil

Next, you’ll set up Reachability in NetworkStreamHandler. First, create a function that Reachability calls when the network state changes. In this function, you’ll use switch to check the state and send the appropriate event to Flutter.

Below onCancel() add:

@objc func connectionChanged(notification: NSNotification) {
    let reachability = notification.object as! Reachability
    switch reachability.connection {
    case .wifi:
        eventSink?(Constants.wifi)
    case .cellular:
        eventSink?(Constants.cellular)
    case .unavailable:
        eventSink?(Constants.disconnected)
    case .none:
        eventSink?(Constants.unknown)
    }
}

Now, you need to tell Reachability, “Hey, buddy, call connectionChanged when you detect a network state change :]”.

Inside onListen(), right above the return statement, add:

eventSink = events
NotificationCenter.default.addObserver(self, selector: #selector(connectionChanged(notification:)), name: Notification.Name.reachabilityChanged, object: reachability)
do {
    try reachability.startNotifier()
} catch {
   return FlutterError(code: "1", message: "Could not start notififer", details: nil)
}

Here you assign eventSink and observe network changes with Reachability.

Now, you’ll tear down the EventChannel.

Tearing Down the EventChannel

To clean up the EventChannel, you need to nullify eventSink and stop observing network state changes.

Inside onCancel, paste this code above the return statement:

reachability.stopNotifier()
NotificationCenter.default.removeObserver(self, name: .reachabilityChanged, object: reachability)
eventSink = nil

Run the project on iOS. You’ll see something like this:

Show network connectivity changes on iOS

Congratulations! You completed the first part of this tutorial!

Congratulatios gif

Next up, you’ll learn how to stream an image from native code to Dart.

Streaming Arbitrary Bytes

The solution above works when you want an infinite stream of events. But what if you just want to stream a sequence of bytes and terminate? For example, maybe you want to stream an image, video or file.

In this section, you’ll solve this problem by streaming an image from native Android and iOS to Flutter. Additionally, a progress indicator will visualize the stream’s progress.

You’ll start by listening for images in the form of raw bytes.

Receiving, Concatenating and Consuming Bytes on Flutter

Since you’re streaming an arbitrary list of bytes, you’ll need some way to know that you’re actually at the end of that list. To do that, you’ll need a special delimiter signal.

In constants.dart, add the following Constants:

/// Event that denotes an end of the stream
static const eof = -0xFFFFFF;

You’ll use this value at the end of the stream to signify the end of your image. Note that like the other constants in Constants, this can be any value as long as it’s consistent across Android and iOS.

Next, inside main.dart, find build() of _MyHomePage. Replace the the Expanded widget with const Expanded(child: ImageStreamWidget()). Then import 'network_stream_widget.dart'.

body should now look like this:

body: Column(
  children: [
     const NetworkStreamWidget(),
     const Expanded(child: ImageStreamWidget()),
  ],
),

To receive and process the events, you’ll divide the image into three events:

  1. File size: The first event.
  2. The actual image bytes: Received in chunks. Successive bytes are concatenated.
  3. End of stream: The last event.

In _ImageStreamWidgetState, below startImageStream(), add the following function.

void onReceiveImageByte(dynamic event) {
  // Check if this is the first event. The first event is the file size
  if (imageSize == null && event is int && event != Constants.eof) {
    setState(() => imageSize = event);
    return;
  }

  // Check if this is the end-of-file event.
  // End-of-file event denotes the end of the stream
  if (event == Constants.eof) {
    imageSubscription?.cancel();
    setState(() => streamComplete = true);
    return;
  }

  // Receive and concatenate the image bytes
  final byteArray = (event as List<dynamic>).cast<int>();
  setState(() => imageBytes.addAll(byteArray));
}

This function handles processing the events.

Make sure to import 'constants.dart' as well.

Next, you need a channel to handle the events. Declare the image stream EventChannel in _ImageStreamWidgetState anywhere above build() like this:

final eventChannel = const EventChannel('platform_channel_events/image');

Import 'package:flutter/services.dart' into the same file.

Note that this channel’s name is different than what you used for streaming connectivity because this is a new channel that serves a different purpose.

Now, you need to receive the events from the channel. Paste this statement inside startImageStream():

imageSubscription = eventChannel.receiveBroadcastStream(
        {'quality': 0.9, 'chunkSize': 100}).listen(onReceiveImageByte);

In the statements above, you:

  1. Listen to the event stream and call onReceiveImageByte() when there’s new data.
  2. You pass quality and chunkSize to the native ends. quality is the quality of the image you want to receive and chunkSize determines the number of chunks you want to split the image bytes into.

Run the project. You’ll see something like this:

Screenshot after adding ImageStreamWidget to _MyHomePage

Tap Stream Image. At this point, you won’t see any UI changes. You’ll also see a MissingPluginException error in the log. Don’t worry, that’s expected!

Next up you’ll send the image on Android.

Streaming Bytes on Android

In Constants.kt, inside Constants add:

const val eof = -0xFFFFFF

Go back to MainActivity.kt. Below networkEventChannel, declare the image event channel name like this:

private val imageEventChannel = "platform_channel_events/image"

Next, in configureFlutterEngine(), right below the call to super, call this statement:

EventChannel(flutterEngine.dartExecutor.binaryMessenger, imageEventChannel)
    .setStreamHandler(ImageStreamHandler(this))

Now, open ImageStreamHandler.kt, and declare the event callback above onListen() like so:

private var eventSink: EventChannel.EventSink? = null

This should all look pretty similar, since it’s exactly the same setup you used for connectivity changes!

Next, paste this function in ImageStreamHandler.kt below onCancel():

private fun dispatchImageEvents(quality: Double, chunkSize: Int) {
    GlobalScope.launch(Dispatchers.IO) {
        if (activity == null) return@launch

        // Decode the drawable
        val bitmap = BitmapFactory.decodeResource(activity!!.resources, R.drawable.cute_cat_unsplash)

        // Compress the drawable using the quality passed from Flutter
        val stream = ByteArrayOutputStream()
        bitmap.compress(Bitmap.CompressFormat.JPEG, (quality * 100).toInt(), stream)

        // Convert the compressed image stream to byte array
        val byteArray = stream.toByteArray()

        // Dispatch the first event (which is the size of the array/image)
        withContext(Dispatchers.Main) {
            eventSink?.success(byteArray.size)
        }

        // Split the array into chunks using the chunkSize passed from Flutter
        val parts = byteArray.size / chunkSize
        val chunks = byteArray.toList().chunked(parts)

        // Loop through the chunks and dispatch each chuck to Flutter
        chunks.forEach {
            // Mimic buffering with a 50 mills delay
            delay(50)
            withContext(Dispatchers.Main) {
                eventSink?.success(it)
            }
        }
        withContext(Dispatchers.Main) {
            // Finally, dispatch an event to indicate the end of the image stream
            eventSink?.success(Constants.eof)
        }
    }
}

And add the following import statements:

import kotlinx.coroutines.*
import java.io.ByteArrayOutputStream
import android.graphics.Bitmap
import android.graphics.BitmapFactory

That’s a lot of code! Luckily you don’t really need to know much about it – all you need to know is that it gets an image contained in the project and converts it into a list of bytes on a background thread using Coroutines.

Next, you’ll assign eventSink and get the parameters you passed from Flutter. Then, you’ll call dispatchImageEvents() with these parameters.

Write these statements inside onListen():

eventSink = events
val quality = (arguments as Map<*, *>)["quality"] as Double
val chunkSize = arguments["chunkSize"] as Int
dispatchImageEvents(quality, chunkSize)

Then, clean up by nullifying eventSink and activity in onCancel():

eventSink = null
activity = null

Run the app on Android and tap Stream Image. You’ll see something like this:

Loading a cat image

This beautiful cat photo is by Cédric VT on Unsplash

Now, you’ll replicate the same steps you did on Android for iOS but in Swift.

Streaming Bytes on iOS

In Constants.swift, add a new member to Constants:

static let eof = -0xFFFFFF

Declare the event name in AppDelegate below networkEventChannel like this:

private let imageEventChannel = "platform_channel_events/image"

Next, you’ll set the handler of the image stream events.

Navigate to application(). Inside AppDelegate, above GeneratedPluginRegistrant.register(with: self), add:

FlutterEventChannel(name: imageEventChannel, binaryMessenger: controller.binaryMessenger)
   .setStreamHandler(ImageStreamHandler())

Then, navigate to ImageStreamHandler.swift. In ImageStreamHandler, below onCancel(), paste:

func dispatchImageEvents(quality: Double, chunkSize: Int, eventSink: @escaping FlutterEventSink) -> Void {
    
    // Load the image into memory
    guard let image  = UIImage(named: "cute_dog_unsplash.jpg"),
          
    // Compress the image using the quality passed from Flutter
    let data = image.jpegData(compressionQuality: CGFloat(quality)) else {return}
    
    // Get the size of the image
    let length = data.count
    
    // Dispatch the first event (which is the size of the image)
    eventSink(length)
    
    DispatchQueue.global(qos: .background).async {
        
        // Split the image into chunks using the chunkSize passed from Flutter
        let parts = length / chunkSize
        var offset = 0
        
        // Repeat this block of statements until you have dispatched the last chunk
        repeat {
            // Mimic buffering with a 1 mill delay
            usleep(1000)
            
            let thisChunkSize = ((length - offset) > parts) ? parts : (length - offset)
            let chunk  = data.subdata(in: offset..<offset + thisChunkSize)
            
            // Dispatch each chunk to Flutter
            eventSink(chunk)
            offset += thisChunkSize
            
        } while (offset < length)
        
        // Dispatch an event to indicate the end of the stream
        eventSink(Constants.eof)
    }
}

Just like on Android, the above code is just chunking an image into a list of bytes.

Finally, you need to pass the arguments you passed from Flutter to dispatchImageEvents.

Paste this function in onListen(), right above the return statement:

let args = arguments as! Dictionary<String, Any>
let quality = args["quality"] as! Double
let chunkSize = args["chunkSize"] as! Int
dispatchImageEvents(quality: quality, chunkSize: chunkSize, eventSink: events)

Run the app on iOS and tap Stream Image. You'll see something like this:

Loading a dog image

This beautiful dog photo is by Marliese Streefland on Unsplash

Congratulations! You did such an amazing job. Now, go out there and continue building world-class apps!

Where to Go From Here?

You can find the completed project inside completed in files you downloaded earlier or by clicking Download Materials at the top or bottom of this tutorial.

In this tutorial, you learned how to stream from predetermined events to arbitrary bytes from native Android and iOS to Flutter.

To learn more about the differences between MethodChannel and EventChannel, see this Stackoverflow discussion. If you're interested in knowing how Flutter Platform Channels works under the hood, checkout this post by Mikkel Ravn.

I hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!