Flutter Pigeon — Support Multiple instance APIs

Daniel Dallos
Flutter Native
Published in
6 min readOct 18, 2023
Photo by Didssph on Unsplash

If you are here, probably you don’t need an introduction to Flutter (Dart) and Native platform communication.

In case you want to refresh your knowledge of the topic, a quick recap:

…and before there was nothing… there were MethodChannels.

MethodChannels are a way to establish communication between Flutter and Native platform code (written in languages like Java, Kotlin for Android or Swift, and Objective-C for iOS, or other languages for Desktop platforms). They provide a means for passing data and invoking methods across the boundary between Flutter and native code. This is particularly useful when you need to access platform-specific functionality or services that are not available through Flutter’s out-of-the-box APIs.

MethodChannels are NOT type-safe.

Calling and receiving messages depends on the host and client declaring the same arguments and datatypes in order for messages to work.

See it with an example:

// Flutter Side
import 'package:flutter/services.dart';

const platform = MethodChannel('example.com/native_method');

Future<void> invokeNativeMethod() async {
try {
await platform.invokeMethod('someMethod', {"param": "value"});
} catch (e) {
print("Error: $e");
}
}
// Native Side (Android)
import io.flutter.embedding.android.FlutterActivity
import androidx.annotation.NonNull
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity: FlutterActivity() {
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "example.com/native_method")
.setMethodCallHandler { call, result ->
if (call.method == "someMethod") {
val param = call.argument<String>("param")
// Perform native code here
result.success("Result from native code")
} else {
result.notImplemented()
}
}
}
}

As you can see, strings and random objects are flying between platforms and ending up in a switch-case where we need to cast the arguments.

Not the best.

Photo by Tim Mossholder on Unsplash

Let there be Pigeons 🕊️

Pigeon is a code generator tool to make communication between Flutter and the host platform type-safe, easier, and faster.

Pigeon removes the necessity to manage strings across multiple platforms and languages. It also improves efficiency over common method channel patterns. Most importantly though, it removes the need to write custom platform channel code, since Pigeon generates it for you.

This sounds really promising.

Let’s see a basic Pigeon file:

import 'package:pigeon/pigeon.dart';

//run in the root folder: flutter pub run pigeon --input pigeons/text_api.dart

@ConfigurePigeon(PigeonOptions(
dartOut: 'lib/pigeon/theoplayer_flutter_api.g.dart',
dartOptions: DartOptions(),
kotlinOut: 'android/app/src/main/kotlin/com/danieldallos/pigeon_multi_instance_demo/pigeon/TextApi.g.kt',
kotlinOptions: KotlinOptions(
package: 'com.danieldallos.pigeon_multi_instance_demo.pigeon',
),
swiftOut: 'ios/Runner/pigeon/TextApi.g.swift',
swiftOptions: SwiftOptions(),
dartPackageName: 'pigeon_multi_instance_demo',
))


@HostApi()
abstract class NativeTextApi {
void setText(String text);
}

@FlutterApi()
abstract class FlutterTextApiHandler {
void textChanged(String text);
}

The tool needs a basic configuration object for code generation and then the API definitions.

The @HostApi() annotation marks APIs which are implemented in Native and can be called from Dart.

The @FlutterApi() annotation covers the APIs that are implemented in Dart and callable from Native.

Implementation is rather straightforward too:

// Dart/Flutter code
class NativeViewController implements FlutterTextApiHandler {
//setup
final NativeTextApi _nativeAPI = NativeTextApi();

NativeViewController(int id) {
//setup
FlutterTextApiHandler.setup(this);
}

@override
void textChanged(String text) {
//called from Native
print("textChanged: $text");
}

//pigeon API implementation
void changeText(String newText) {
//calling Native
_nativeAPI.setText(newText);
}
}
// Native Side (Android)
class NativeView(context: Context, viewId: Int, args: Any?, messenger: BinaryMessenger) : PlatformView, NativeTextApi {

private val flutterApi: FlutterTextApiHandler;

init {
...
//setup pigeon
NativeTextApi.setUp(messenger, this)
flutterApi = FlutterTextApiHandler(messenger)

}

...

//pigeon API implementation
override fun setText(text: String) {
// called from Dart
}
}

However, if you look carefully, you can notice some static Pigeon setup() methods without using any extra identifications.

This means every time a new instance of a class is created, the static setup method will override the setup for the previous instance… breaking the connection between the Native and the Flutter/Dart side for that instance.

If we dive deep into the generated files, we can see the issue:

// Pigeon-generated Dart class uses a single, instance-unaware channel to communicate
abstract class FlutterTextApiHandler {
static const MessageCodec<Object?> codec = StandardMessageCodec();

void textChanged(String text);

static void setup(FlutterTextApiHandler? api, {BinaryMessenger? binaryMessenger}) {
{
final BasicMessageChannel<Object?> channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.pigeon_multi_instance_demo.FlutterTextApiHandler.textChanged', codec,
binaryMessenger: binaryMessenger);
if (api == null) {
channel.setMessageHandler(null);
} else {
channel.setMessageHandler((Object? message) async {
assert(message != null,
'Argument for dev.flutter.pigeon.pigeon_multi_instance_demo.FlutterTextApiHandler.textChanged was null.');
final List<Object?> args = (message as List<Object?>?)!;
final String? arg_text = (args[0] as String?);
assert(arg_text != null,
'Argument for dev.flutter.pigeon.pigeon_multi_instance_demo.FlutterTextApiHandler.textChanged was null, expected non-null String.');
api.textChanged(arg_text!);
return;
});
}
}
}
}

The Pigeon-generated Dart class uses a single, instance-unaware channel to communicate across platforms.

How can we fix this?

  1. Don’t use Pigeon, revert back to MethodChannels, and use unique channel names per instance.
    this is going to hurt again because we lose type-safety.
  2. Instead of sending our simple objects over the bridges, we can enhance them with an instance ID… so after both sides parse it, they can redirect the calls to the right instances.
    e.g. setText("newText") — > setText({"id": 1, "text": "newText"})
    this will require some refactoring to handle the messages on a higher level
  3. Contribute to Pigeon. Fork the repo and make a PR with the changes.
    this is a really nice option, however, it will take time (and probably we need to introduce it on all platforms, not only on mobile)
  4. Tweak the way the generated Pigeon works
    the underlying channels are using BinaryMessenger to send the data over. Let’s tweak it in a way that it can understand identificators.

PigeonMultiInstanceBinaryMessengerWrapper

The sole purpose of this class is to act as a middle-man on both sides and suffix the messages traveling through the BinaryMessenger used by the generated Pigeon code, keeping the implementation instance-aware.

// The Dart side
import 'dart:ui' as ui;
import 'package:flutter/services.dart';

class PigeonMultiInstanceBinaryMessengerWrapper implements BinaryMessenger {

final BinaryMessenger _binaryMessenger;
final String _channelSuffix;

PigeonMultiInstanceBinaryMessengerWrapper(
{required String suffix, BinaryMessenger? binaryMessenger})
: _channelSuffix = suffix, _binaryMessenger = binaryMessenger ?? ServicesBinding.instance.defaultBinaryMessenger;

@override
Future<void> handlePlatformMessage(String channel, ByteData? data, ui.PlatformMessageResponseCallback? callback) {
return _binaryMessenger.handlePlatformMessage("$channel/$_channelSuffix", data, callback);
}

@override
Future<ByteData?>? send(String channel, ByteData? message) {
return _binaryMessenger.send("$channel/$_channelSuffix", message);
}

@override
void setMessageHandler(String channel, MessageHandler? handler) {
_binaryMessenger.setMessageHandler("$channel/$_channelSuffix", handler);
}

}
// Native side (Android)
import io.flutter.plugin.common.BinaryMessenger
import java.nio.ByteBuffer

class PigeonMultiInstanceBinaryMessengerWrapper(
private val messenger: BinaryMessenger,
private val channelSuffix: String,
) : BinaryMessenger {

override fun send(channel: String, message: ByteBuffer?) {
messenger.send("$channel/$channelSuffix", message)
}

override fun send(channel: String, message: ByteBuffer?, callback: BinaryMessenger.BinaryReply?) {
messenger.send("$channel/$channelSuffix", message, callback)
}

override fun setMessageHandler(channel: String, handler: BinaryMessenger.BinaryMessageHandler?) {
messenger.setMessageHandler("$channel/$channelSuffix", handler)
}
}
// Native side (iOS)
import Foundation
import Flutter

class PigeonMultiInstanceBinaryMessengerWrapper: NSObject, FlutterBinaryMessenger {

let channelSuffix: String
let messenger: FlutterBinaryMessenger

init(with messenger: FlutterBinaryMessenger, channelSuffix: String) {
self.messenger = messenger
self.channelSuffix = channelSuffix
}

func send(onChannel channel: String, message: Data?, binaryReply callback: FlutterBinaryReply? = nil) {
messenger.send(onChannel: "\(channel)/\(channelSuffix)", message: message, binaryReply: callback)
}

func send(onChannel channel: String, message: Data?) {
messenger.send(onChannel: "\(channel)/\(channelSuffix)", message: message)
}

func setMessageHandlerOnChannel(_ channel: String, binaryMessageHandler handler: FlutterBinaryMessageHandler? = nil) -> FlutterBinaryMessengerConnection {
messenger.setMessageHandlerOnChannel("\(channel)/\(channelSuffix)", binaryMessageHandler: handler)
}

func cleanUpConnection(_ connection: FlutterBinaryMessengerConnection) {
messenger.cleanUpConnection(connection)
}
}

And see what update is required in the previously shared snipped to use this new BinaryMessenger:

// Dart/Flutter code
class NativeViewController implements FlutterTextApiHandler {
//setup
late final NativeTextApi _nativeAPI;
late final PigeonMultiInstanceBinaryMessengerWrapper _pigeonMessenger;

NativeViewController(int id) {
//setup
//NEW LINE:
_pigeonMessenger = PigeonMultiInstanceBinaryMessengerWrapper(suffix: 'id_$id');

_nativeAPI = NativeTextApi(binaryMessenger: _pigeonMessenger);
FlutterTextApiHandler.setup(this, binaryMessenger: _pigeonMessenger);
}

@override
void textChanged(String text) {
//called from Native
print("textChanged: $text");
}

//pigeon API implementation
void changeText(String newText) {
//calling Native
_nativeAPI.setText(newText);
}
}
// Native Side (Android)
class NativeView(context: Context, viewId: Int, args: Any?, messenger: BinaryMessenger) : PlatformView, NativeTextApi {

private val flutterApi: FlutterTextApiHandler
private val pigeonMessenger: PigeonMultiInstanceBinaryMessengerWrapper

init {
...
//setup pigeon
//NEW LINE:
pigeonMessenger = PigeonMultiInstanceBinaryMessengerWrapper(messenger, "id_$viewId");

NativeTextApi.setUp(pigeonMessenger, this)
flutterApi = FlutterTextApiHandler(pigeonMessenger)

}

...

//pigeon API implementation
override fun setText(text: String) {
// called from Dart
}
}

As you can see, the change is really minimal.

We only need to initialize our new instance-aware class (with a unique ID) and pass it to the FlutterApiand HostApideclarations.

That simple! 😎

This post is intentionally missing the iOS part of the code samples to keep the article short(ish).

You can find all sample code (including iOS) at https://github.com/Danesz/flutter_pigeon_multi_instance_demo.git

Have fun, I hope it helps!

PS: I am playing with the idea of adding multi-instance support to the Pigeon library itself… but that’s for another day!

⚠️ ️Flutter course alert

I am building a Flutter developer course focusing on Native code and Flutter collaboration.

You can find topics like this in the course!

Interested?

Head over to https://flutternative.dev!

--

--