Rating: 4.41 / 5 Based on 22 reviews
Using background processes in Flutter apps is common. However, when adding Flutter to existing native apps, things can get more complicated. As part of a proof-of-concept that we developed for a client from the banking sector, we had to implement a business process that required some work to be performed in the background in a Flutter module. This module was to be injected into native Android and iOS apps. See our case and code examples of implementing background services in Flutter add-to-app.
If you want to learn more about Flutter add to app read our previous article.
Mobile apps sometimes have to perform some operations in the background. It is a non-trivial technical challenge to make that work reliably, and some restrictions on the operating system side must always be kept in mind.
Background processing covers many interesting use cases in mobile apps, such as:
Let’s imagine the following generic scenario. The user navigates the native part of the app from which they can open a Flutter screen. On the Flutter screen is a button to initiate background processing (think long-term calculations or data synchronization). This button starts a background process which will continue running even after the user closes the app.
When the background process is finished, we want to show another Flutter screen, this time some dialog. This dialog can appear anywhere in the app (both over a native or a Flutter screen). If the app is not in the foreground, we can show a push notification that will open the dialog screen (this is a more typical scenario).
We need some form of concurrency to implement that process. Because we’re dealing with the Flutter add-to-app scenario, the navigation paths of native and Flutter screens can cross. That means that we need multiple execution environments for Flutter - we won’t be able to reuse a single Flutter engine for the two screens and the background service.
Dart supports concurrency with the concept of isolate. Isolates use a single thread of execution and don’t share any mutable objects with other isolates. Separate memory is where isolates differ from traditional threads known from other languages. Each isolate processes events in its event loop. Isolates can communicate between themselves by passing messages via so-called ports.
Therefore, in our case, we will need three isolates:
The Flutter add-to-app approach is based on the notion of FlutterEngine - the execution environment that is running inside the native app. If we want to have multiple isolates, we need multiple engines. Recently, the Flutter team added a new concept called FlutterEngineGroup, which allows engine instances to share memory resources, significantly decreasing resource consumption. We are going to leverage this addition to make our solution more performant. Each isolate will run by a separate engine in the group.
Let’s create base classes for hosts and clients - they will serve as an abstract communication protocol for our isolates. Hosts will register isolates and receive messages sent by other isolates. Other isolates will use clients to send messages to the isolate. We will make those types generic so that isolates can define their message contracts.
abstract class IsolateClient<T> {
IsolateClient(this._isolateName);
final String _isolateName;
@protected
SendPort? get sendPort => IsolateNameServer.lookupPortByName(_isolateName);
@protected
void send(T message) => sendPort?.send(message);
}
const _isolateName = 'mainIsolate';
abstract class MainIsolateMessage {}
class BackgroundServiceStarted extends MainIsolateMessage {}
class MainIsolateClient extends IsolateClient<MainIsolateMessage> {
MainIsolateClient() : super(_isolateName);
}
class MainIsolateHost extends IsolateHost<MainIsolateMessage> {
MainIsolateHost._(ReceivePort port)
: super(receivePort: port, isolateName: _isolateName);
factory MainIsolateHost.register() {
return MainIsolateHost._(registerIsolate(_isolateName));
}
}
We will also need to define a communication protocol between Dart and native code with method channels. We will use the pigeon package to avoid writing boilerplate code in Dart, Swift, and Kotlin. It is a code generator for generating typesafe contracts for communication between Flutter and the host platform. With Pigeon, you create a Dart schema file, and the package generates Dart, Objective-C, and Java files with models and method channel invocations.
This will be our Dart schema:
@HostApi()
abstract class NativeMainApi {
void startService(ComputationNotification notification);
void stopService();
}
@HostApi()
abstract class NativeDialogApi {
void closeDialog();
}
@HostApi()
abstract class NativeBackgroundServiceApi {
void stopService();
void openDialog();
void updateNotification(ComputationNotification notification);
}
class ComputationNotification {
late final String title;
late final String message;
late final int percentProgress;
}
HostApis define a native interface accessible from Dart code. Pigeon also offers FlutterApis for communication from native platforms to Flutter, but for simplicity’s sake, in this example, we’re only going to use single-way communication. We define separate APIs for each isolate.
The main isolate will be able to start and stop the computation. The background service isolate defines an interface for stopping the native service, opening a Flutter dialog, and updating a notification shown in the system tray. The dialog isolate can close itself (and destroy the native Flutter engine).
Each isolate needs a separate entrypoint (the primary function). We can define them in separate files or a single file. Still, in the latter case, we need to remember to add the pragma annotation so that this function name won’t get stripped or get its name changed during compilation optimizations, and the native code can call it.
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MainApp());
}
@pragma('vm:entry-point')
void backgroundServiceMain() {
WidgetsFlutterBinding.ensureInitialized();
startBackgroundService();
}
@pragma('vm:entry-point')
void dialogMain() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const DialogApp());
}
On iOS, we used background tasks - the case was simple enough (the process was time-limited), so they fulfilled our requirements. For more complex scenarios, you can resort to the Background Fetch API - the implementation would be similar.
This piece of code starts up a Dart service and begins a background task so that it will continue executing in the background.
private let dartEntrypoint = "backgroundServiceMain"
func start(notification: LNCDComputationNotification) {
if engine == nil {
let appDelegate = UIApplication.shared.delegate as! AppDelegate
let engine = appDelegate.flutterEngineGroup.makeEngine(withEntrypoint: dartEntrypoint, libraryURI: nil)
engine.run()
}
backgroundTaskIdentifier = UIApplication.shared.beginBackgroundTask(
withName: backgroundTaskName,
expirationHandler: {
self.stop()
})
lastNotification = notification
}
We will base our Android implementation on the foreground service - a type of service that displays a system notification so that the user is aware that the app is performing some work when they are not interacting with it. An implementation using a background service would be analogous.
In total, we will use 3 activities:
The service will start Dart code in the following way:
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (engine == null) {
ComputationServiceNotification.createNotificationChannel(applicationContext)
val notification = ComputationServiceNotification.createNotification(applicationContext, intent!!)
startForeground(SERVICE_ID, notification)
startDartService()
}
return super.onStartCommand(intent, flags, startId)
}
private fun startDartService() {
engine = FlutterUtils.createOrGetEngine(this, AppFlutterEngine.computationService)
engine!!.let {
Api.NativeBackgroundServiceApi.setup(it.dartExecutor, ComputationServiceApiHandler(this))
api = Api.FlutterMainApi(it.dartExecutor)
}
}
First, we see if the engine is not running already. Then, we create a notification, start the foreground service and create a FlutterEngine which will execute Dart code in the background.
The whole setup requires some boilerplate. You can check out the complete example here.
This article showed you how to implement background processing in native apps with added Flutter. While the implementation is not trivial and has many gotchas, the most important thing we have shown is that you can keep the whole core business logic multiplatform in Dart. Native code is mostly infrastructural and generic. It is something necessary because we need to integrate Flutter with native apps.
When hearing about doing something in the background, especially in the add-to-app, one could quickly start looking at doing a native implementation. We can really leverage Flutter here and have the business logic written and tested once, and be assured that it works the same way on the Android and iOS applications.
If you need help with the Flutter add-to-app, you can reach out to our Flutter development agency.