Native Communication with a Callback in Flutter

While bridging platform APIs to Flutter, you may want to use callback functions in your Dart code to have a straightforward development experience. Unfortunately, out of box solutions in the Flutter framework can be either too limited (MethodChannel) or too complex (EventChannel) for such scenarios. In this post, we are going to investigate how to develop a workaround for that.

To bootstrap our demo project, we’ll use our a playground we created before. If you are curious about how to build one for your needs, check out this blog post before your proceed.

Related Topics

Take a look at these posts to learn about common communication patterns between Dart and native code if you already know what you are looking for.

Call Dart from Java or Objective-C using Method Channels

In Flutter, method channels are two-way communication tools and their APIs are uniform in all platforms. By storing a reference to a channel in any language, we can call methods with invokeMethod() and accept calls with setMethodCallHandler().

Our playground already defines a method call handler in Java and Objective-C but we need to add one for Dart as well.

Let’s create a callbacks.dart file to isolate our new channel and start building our callback based library.

import 'package:flutter/services.dart';

const _channel = const MethodChannel('callbacks');

Future<void> _methodCallHandler(MethodCall call) async {
  // TODO : fill this when necessary
}

//Use the code below before registering callbacks to be able to listen calls from the native side
//_channel.setMethodCallHandler(_methodCallHandler);

We will assign this handler lazily when a callback based communication is necessary.

You may have noticed that all invocations flow through this handler. This immediately creates the problem of figuring out which callback must be called when a call received from native side.

For an hypothetical scenario, our Dart code will use _channel to go to the native side. The native code will start producing values to send over the same channel. Our _methodCallHandler will receive those values and decide which Dart callback needs them.

This one to one mapping from received value to Dart callbacks must be done manually and in a memory safe way. We need a way to,

  • store our callbacks temporarily
  • map our callbacks by assigning unique ids to each call
  • and a way to stop receiving values when we no longer need them.

We can declare these requirements with the following aliases and globals.

typedef void MultiUseCallback(dynamic msg);
typedef void CancelListening();

int _nextCallbackId = 0;
Map<int, MultiUseCallback> _callbacksById = new Map();

Each time we call the native side from Dart, we will increment _nextCallbackId to store and send ids, then we will wait for those in _methodCallHandler to match our calls to temporarily stored callbacks in _callbackById. This handshake mechanism will be initialized in our library functions. Those functions will return disposable blocks of code (type aliased to CancelListening name) which can be invoked later to cancel our callback based communication.

In Dart, such mapping looks like this.

Future<void> _methodCallHandler(MethodCall call) async {
  switch (call.method) {
    case 'callListener':
      _callbacksById[call.arguments["id"]](call.arguments["args"]);
      break;
    default:
      print(
          'TestFairy: Ignoring invoke from native. This normally shouldn\'t happen.');
  }
}

Future<CancelListening> startListening(MultiUseCallback callback) async {
  _channel.setMethodCallHandler(_methodCallHandler);

  int currentListenerId = _nextCallbackId++;
  _callbacksById[currentListenerId] = callback;

  await _channel.invokeMethod("startListening", currentListenerId);

  return () {
    _channel.invokeMethod("cancelListening", currentListenerId);
    _callbacksById.remove(currentListenerId);
  };
}

This library assumes a contract with the native side.

  • Native side must implement "startListening" and "cancelListening" methods.
  • Native side must send values over "callListener" Dart method. This receiver expects a Map or NSDictionary of ids and arguments keyed under "id" and "args" respectively.

in Android (MainActivity.java), we close the circle like this.

// Callbacks
private Map<Integer, Runnable> callbackById = new HashMap<>();

void startListening(Object args, MethodChannel.Result result) {
  // Get callback id
  int currentListenerId = (int) args;

  // Prepare a timer like self calling task
  final Handler handler = new Handler();
  callbackById.put(currentListenerId, new Runnable() {
    @Override
    public void run() {
      if (callbackById.containsKey(currentListenerId)) {
        Map<String, Object> args = new HashMap();

        args.put("id", currentListenerId);
        args.put("args", "Hello listener! " + (System.currentTimeMillis() / 1000));

        // Send some value to callback
        channel.invokeMethod("callListener", args);
      }

      handler.postDelayed(this, 1000);
    }
  });

  // Run task
  handler.postDelayed(callbackById.get(currentListenerId), 1000);

  // Return immediately
  result.success(null);
}

void cancelListening(Object args, MethodChannel.Result result) {
  // Get callback id
  int currentListenerId = (int) args;

  // Remove callback
  callbackById.remove(currentListenerId);

  // Do additional stuff if required to cancel the listener

  result.success(null);
}

We used a self calling handler task to simulate a timer. You may use any kind of listeners from Android SDK or third party libraries to invoke "callListener". In our case, this demo listener will produce hello messages every second until Dart code decides to cancel.

Here is the same piece of functionality in Objective-C (AppDelegate.m).

#include "AppDelegate.h"
#include "GeneratedPluginRegistrant.h"

@implementation AppDelegate
{
    FlutterMethodChannel* channel;

    // Callbacks
    NSMutableDictionary* callbackById;
}

- (void) startListening:(id)args result:(FlutterResult)result {
    // Prepare callback dictionary
    if (self->callbackById == nil) self->callbackById = [NSMutableDictionary new];

    // Get callback id
    NSString* currentListenerId = [(NSNumber*) args stringValue];

    // Prepare a timer like self calling task
    void (^callback)(void) = ^() {
        void (^callback)(void) = [self->callbackById valueForKey:currentListenerId];
        if ([self->callbackById valueForKey:currentListenerId] != nil) {
            int time = (int) CFAbsoluteTimeGetCurrent();
            
            [self->channel invokeMethod:@"callListener"
                             arguments:@{
                                 @"id" : (NSNumber*) args,
                                 @"args" : [NSString stringWithFormat:@"Hello Listener! %d", time]
                             }
            ];
            
            dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC), dispatch_get_main_queue(), callback);
        }
    };

    // Run task
    [self->callbackById setObject:callback forKey:currentListenerId];
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC), dispatch_get_main_queue(), callback);

    // Return immediately
    result(nil);
}

- (void) cancelListening:(id)args result:(FlutterResult)result {
    // Get callback id
    NSString* currentListenerId = [(NSNumber*) args stringValue];

    // Remove callback
    [self->callbackById removeObjectForKey:currentListenerId];

    // Do additional stuff if required to cancel the listener
    
    result(nil);
}

@end

We utilized dispatch_after() to simulate a self calling task. This task, just like the one in Android, sends hello messages to Dart by invoking "callListener".

Remember that our playground is capable of matching method names received from Dart to instance methods of our activity and app delegate using reflection behind the scenes. Checkout onCreate() and application:didFinishLaunchingWithOptions: if you are interested in the details.

Test in Playground

Let’s import our library to playground and listen for hello messages for a few seconds.

In main.dart, find our playground main and replace it like this to test the result.

// Main function called when playground is run
bool running = false;
void runPlayground() async {
  if (running) return;
  running = true;

  var cancel = await startListening((msg) {
    setState(() {
      log(msg);
    });
  });

  await Future.delayed(Duration(seconds: 4));

  cancel();

  running = false;
}

An entire working demo can be found in this repo for your convenience.

For more complex scenarios such as publisher/subscribers pattern and Rx like event emitters, you may want to check out event channels from the Flutter framework. We will cover a small tutorial for them in the future.

Until then, take care and keep on hacking!