Long-Running Isolates in Flutter

The long-running Isolate is a technique that I have used now in my contracting to run extensive processes in the background of a Flutter app. Running background code came up as a need while working with a local startup FytFeed in order to power some on-device integrations. I wrote code that detected the availbility of information in HealthKit and shipped those updates off all while being separate from the main isolate to avoid any UI jank and keep the interface responsive and clean. The paradigm is pretty powerful! I’d like to share what I learned implementing this code.

Spoiler alert, I got a lot of my initial ideas and implementation from here

What is an Isolate Anyway?

An Isolate is a core-level construct present in Dart, the programming language that you use when building Flutter. According to Dart’s official docs, a Dart isolate has a single thread of execution, shares no mutable objects, and has it’s own memory heap. In Dart, this is useful because there is no possible way to introduce bugs in the way of shared memory. Mutexes and other synchronization techniques become unnecessary, because there is no data to protect access to.

It is possible to run on multiple cores in Dart though. It’s possible by utilizing Isolates and creating more Isolates to run. It’s typical that in a Flutter application only the main Isolate is used, but there are several use cases which lend themselves well to spawning more Isolates for things like long-running processes.

Message Passing and Isolates

void main() async {
  // Read some data.
  final jsonData = await _parseInBackground();

  // Use that data
  print('Number of JSON keys: ${jsonData.length}');
}

// Spawns an isolate and waits for the first message
Future<Map<String, dynamic>> _parseInBackground() async {
  final p = ReceivePort();
  await Isolate.spawn(_readAndParseJson, p.sendPort);
  return await p.first as Map<String, dynamic>;
}

This example is lifted directly from the documentation that I linked above, and it intends to show off a simple example of running a background task using an Isolate. Isolates communicate via message passing, and the specific mechanism involved is the ReceivePort. This ReceivePort I have loosely thought of as a Unix pipe, because it establishes bi-directional communication between the two isolates that are using it.

With this example, it’s easy to see that jsonData would probably take a long time to actually be parsed (and let’s be honest, we’ve all been there with large JSON files). Using _parseInBackground as it is coded will hand the work that _readAndParseJson does off to the Isolate, while also passing a reference to the send port. You can then await on the send port for whatever message that the isolate needs to send. This await allows Dart to yield execution off to some other async process that may be ready after the CPU’s events.

Long-running Isolates

The astute reader may notice that the above example isn’t really intended to be a long-running isolate. It is instead meant to take what is a long running task and background it. In theory it acts a lot like ./long_task & except we can await on the execution in order to obtain the results. Async programming really can be fun sometimes…

With my current contract though, simply backgrounding the execution of the task wasn’t going to suffice. The Apple HealthKit data being generated can be generated at any time, which necessitates the need of a long-running background process. Additionally, it’s not required that I do anything in the UI with the data that I get from HealthKit. All that has to happen is the data needs to be shipped off to the backend in the correct format, all without the user ever getting wise to what’s going on. For that I still used Isolates, but in a way that allowed the Isolate to hang around.

The HealthKit query

pub.dev is filled to the brim with so many interesting and useful packages. One of those packages, health_kit_reporter gave me exactly what I needed. The author based his package on an existing CocoaPod, and matched the features to the CocoaPod nearly perfectly. Step one complete: finding a package that will run these queries.

Step two, how do I run queries in the background? health_kit_reporter (and HealthKit in general) support the concept of ObserverQuery docs here, which allow you to specify types of data that you would like and then ask HealthKit for streaming updates to that data. Perfect, this matches the webhook-based setup of other integrations that I have done for FytFeed, but it is on device, problem solved right? Not necessarily.

Keeping the Subscription Alive

I wanted the subscription to be kept alive, even in the background, and this had to be done even when the app was suspended. To my knowledge, this was a problem largely solved by Isolates (although I may be incorrect about this, time will tell). The concept of a long-running Isolate appealed to me. To keep the isolate alive, I simply needed something running within the scope of the Isolate that would be enclosed by the Isolate worker function.

void _isolateEntryPoint(SendPort sendPort) {
  // Set up the integration here.
  print("spawning isolate");
  final receivePort = ReceivePort();
  sendPort.send(receivePort.sendPort);

  var activeIntegrations = [];
  StreamSubscription<dynamic> streamSub;
  
  // Listen for new messages
  receivePort.listen((Object message) async {
    if (message is IntegrationMessage) {
      // Our start message - just return, no work to do.
      if (message.op == IntegrationOp.start && message.type == null) {
        sendPort.send(activeIntegrations);
        return;
      }
    }
  }
...
}

This snippet represents the majority of the work that I did on the project, and launches the isolate + the subscription listening for messages on the SendPort.receivePort that this Isolate was started with. Now check out the code that actually starts the Isolate, all managed within the context of a class.

class IntegrationIsolate() {
...
  IntegrationIsolate() {
    final newReceivePort = ReceivePort();

    ...
    _receivePort = newReceivePort;
    _receiveQueue = StreamQueue(newReceivePort);
  }

  /// Was the isolate started?
  bool started() {
    return _isolate != null;
  }

  /// Start the isolate, and additionally provide the isolate with any
  /// previously-running integrations if they were found. Do this by checking
  /// the activeIntegrations present within the Hive box.
  Future<void> start() async {
    // Starts up the isolate
    if (_isolate == null) {
      _isolate = await Isolate.spawn(_isolateEntryPoint, _receivePort.sendPort);
      _sendPort = await _receiveQueue.next;
    }
    ...
  }
}

Code has been truncated for readability purposes. You can see most of the core concepts present within the above code snippet. Within the IntegrationIsolate default constructor you see the receive port being created along with a StreamQueue. Don’t worry too much about the StreamQueue object, it is mainly to provide a nice queue-like abstraction over the SendPort. Within the start() method you can see the isolate actually being created with Isolate.spawn, and we start up our _isolateEntrypoint worker function with just one argument, the receive port.

And that’s about it! The receivePort.listen stream subscription will handle any new messages that are inbound on the Isolate’s receivePort that it sets up, and you can handle those messages however you want! One thing that I did though, is to create a class for storing my message “schema” that I was sending to the worker isolate. This allowed for me to handle messages in the worker isolate in a nice, managed way without being too messy.

Update

Since doing this implementation, I have come to realize that an Isolate will not run on app termination. It will run when the app is suspended (sorta), but not if the user entirely closes the app. To combat this, I store the fact that I require the isolate to disk, and on app startup I will restart the Isolate if required, while additionally running an Apple HealthKit query for any missed data while terminated. Is it perfect? no, but it is a start!

 Share!

 
I run WindleWare! Feel free to reach out!

Subscribe for Exclusive Updates

* indicates required