Platform Channels & Native Integration

BasicMessageChannel: Bidirectional Arbitrary Messaging

16 min Lesson 6 of 11

BasicMessageChannel: Bidirectional Arbitrary Messaging

BasicMessageChannel is the most flexible of Flutter's three platform channel types. Unlike MethodChannel (which follows a call-and-response RPC model) and EventChannel (which streams events from native to Dart), BasicMessageChannel allows either side — Dart or native — to initiate a message at any time, and the recipient may optionally send a reply. This makes it ideal for free-form, low-latency, bidirectional communication where neither side is a strict "caller" or "listener".

Key distinction: With MethodChannel, Dart calls native and waits for a result. With BasicMessageChannel, both Dart and native can send messages to each other independently, and each message may carry an arbitrary reply.

When to Choose BasicMessageChannel

Use BasicMessageChannel when:

  • Native code needs to push data to Dart without Dart first invoking a method call
  • Dart needs to send arbitrary structured data to native and receive a structured reply
  • You want to exchange non-standard types (binary blobs, custom objects) using a custom MessageCodec
  • You need a lightweight two-way pipe without the overhead of a full event stream

MessageCodec Options

Every BasicMessageChannel is parameterised with a MessageCodec<T> that serialises messages to bytes and deserialises them on the other side. Flutter ships four built-in codecs:

  • StandardMessageCodec — the default; handles null, bool, int, double, String, Uint8List, Int32List, Int64List, Float64List, List, and Map
  • JSONMessageCodec — encodes to/from JSON; convenient when native already speaks JSON
  • BinaryCodec — passes raw ByteData with zero encoding overhead; fastest for binary payloads
  • StringCodec — UTF-8 plain strings; simplest for text-only protocols

You can also implement MessageCodec<T> directly to support custom wire formats (e.g., protobuf or MessagePack).

Dart-Side: Sending and Receiving

Declare the channel once, then use send() to push a message (and await the optional reply) and setMessageHandler() to receive messages initiated by the native side.

Example 1 — Dart sends a message and receives a reply

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

// Declare a typed channel using the standard codec
const _channel = BasicMessageChannel<Object?>(
  'com.example.app/settings',
  StandardMessageCodec(),
);

class NativeSettingsService {
  /// Called at startup: ask native for the current settings map.
  Future<Map<Object?, Object?>?> fetchSettings() async {
    final reply = await _channel.send({'action': 'getSettings'});
    if (reply is Map) {
      return reply as Map<Object?, Object?>;
    }
    return null;
  }

  /// Register a handler so native can PUSH updated settings to Dart.
  void listenForUpdates(void Function(Map<Object?, Object?>) onUpdate) {
    _channel.setMessageHandler((message) async {
      if (message is Map) {
        onUpdate(message as Map<Object?, Object?>);
      }
      // Return null or an acknowledgement string
      return 'ack';
    });
  }
}
Tip: send() returns a Future that resolves to the native reply, or null if the native handler does not reply. Always handle the null case to avoid unexpected LateInitializationError bugs.

Native-Side: Android (Kotlin)

On Android, mirror the channel declaration in MainActivity (or any FlutterEngine holder). Use setMessageHandler to receive from Dart, and call send on the channel instance to push unsolicited messages to Dart.

Example 2 — Android Kotlin handler + unsolicited push

import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.StandardMessageCodec

class MainActivity : FlutterActivity() {

    private lateinit var settingsChannel: BasicMessageChannel<Any?>

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

        settingsChannel = BasicMessageChannel(
            flutterEngine.dartExecutor.binaryMessenger,
            "com.example.app/settings",
            StandardMessageCodec.INSTANCE
        )

        // Handle messages FROM Dart
        settingsChannel.setMessageHandler { message, reply ->
            if (message is Map<*, *> && message["action"] == "getSettings") {
                val settings = mapOf(
                    "theme" to "dark",
                    "fontSize" to 16,
                    "language" to "en"
                )
                reply.reply(settings)   // send reply back to Dart
            } else {
                reply.reply(null)
            }
        }
    }

    // Call this from anywhere in your Android code to PUSH to Dart
    fun pushSettingsUpdate(newSettings: Map<String, Any>) {
        settingsChannel.send(newSettings) { reply ->
            // optional: handle Dart's acknowledgement
            println("Dart ack: $reply")
        }
    }
}

Native-Side: iOS (Swift)

The Swift API is symmetric. Register a FlutterBasicMessageChannel in AppDelegate and call sendMessage to initiate messages from iOS to Dart.

Example 3 — iOS Swift handler

import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {

    var settingsChannel: FlutterBasicMessageChannel?

    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        let controller = window?.rootViewController as! FlutterViewController
        let messenger = controller.binaryMessenger

        settingsChannel = FlutterBasicMessageChannel(
            name: "com.example.app/settings",
            binaryMessenger: messenger,
            codec: FlutterStandardMessageCodec.sharedInstance()
        )

        // Receive messages from Dart
        settingsChannel?.setMessageHandler { message, reply in
            if let dict = message as? [String: Any],
               dict["action"] as? String == "getSettings" {
                reply(["theme": "dark", "fontSize": 16, "language": "en"])
            } else {
                reply(nil)
            }
        }

        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }

    // Push unsolicited update from iOS to Dart
    func pushSettingsUpdate(_ settings: [String: Any]) {
        settingsChannel?.sendMessage(settings) { reply in
            print("Dart ack: \(reply ?? "none")")
        }
    }
}
Warning: All BasicMessageChannel calls must be made on the platform's main thread on the native side, and the channel must be set up after the Flutter engine is fully initialised. Calling send before the engine is ready silently drops the message.

Comparing the Three Channel Types

Understanding when to use each channel type prevents over-engineering:

  • MethodChannel — Dart calls a named method on native; native returns a result or throws. Best for discrete operations (get battery level, open camera).
  • EventChannel — Native streams continuous events to Dart (sensor data, connectivity changes). Dart cannot send messages back through it.
  • BasicMessageChannel — Full bidirectional messaging. Either side can initiate. Supports custom codecs. Best for peer-to-peer data exchange or custom protocols.

Summary

BasicMessageChannel fills the gap between the rigid RPC model of MethodChannel and the one-way stream of EventChannel. By choosing the right MessageCodec — standard, JSON, binary, or custom — you can efficiently exchange any structured data between Dart and native code, with either side able to initiate communication at any time.

Key Takeaway: When you need native code to push arbitrary data to Dart (or vice versa) without a prior Dart invocation, BasicMessageChannel with StandardMessageCodec is your go-to tool. Register the handler early, keep channel names namespaced (e.g., com.yourapp/channelname), and always handle null replies gracefully.