The Tricky Task of Keeping Flutter Running (Vol. 2)

Pau Picas
Stuart Tech
Published in
6 min readDec 23, 2019

This is a continuation of our earlier article which explained how to keep Flutter running on Android when the app is in the background. In the first article, Sergi Castellsagué recounted our first experiences and the solutions that were applied in the new courier app that we recently developed at Stuart.

Some time has passed since then! We want to share some of our new ideas, as well as highlighting which of our original ideas worked well and are still being using in our app.

Why are you guys so obsessed on keeping the app running in the background?

Running an app while the user isn’t focused on it may seem like an odd thing to do, especially when mobile operating systems tend to be designed to prevent/discourage this. We like to think that we have a good reason to do this!

Our app for couriers must track precisely the device location and receive invitations for new jobs with zero delays via websockets. These two features are critical for us and we must ensure that they are working in all conditions, no matter whether the app is in the foreground or background.

We also want to avoid having platform-specific code as much as we can. In this way, our app can be maintained in the same way by all developers of the Stuart mobile team, no matter whether they are Android or iOS developers. This means that all of the business logic must be written in Dart.

This requirement prevents us from writing websocket logic/location tracking in Java, Kotlin or Swift.

Back button destroys FlutterActivity

As we explained in the first article, all the Dart code that runs under the Flutter engine is executed by a unique Activity called FlutterActivity. When this Activity is destroyed, the Dart code may no longer be executed.

This means that if our app is executing some Dart code to keep a connection to a websocket server, the code will be terminated when the user presses the back button! This made us very sad 😞

Fortunately, we found a way to work around it 😉

The trick is: we intercept when the back button is pressed and tell the Android native code to call moveTaskToBack. This moves the FlutterActivity to the background instead of destroying it.

The following widget wraps all the logic to intercept the back button and call the required native code:

class AppRetainWidget extends StatelessWidget {
const AppRetainWidget({Key key, this.child}) : super(key: key);

final Widget child;

final _channel = const MethodChannel('com.example/app_retain');

@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
if (Platform.isAndroid) {
if (Navigator.of(context).canPop()) {
return true;
} else {
_channel.invokeMethod('sendToBackground');
return false;
}
} else {
return true;
}
},
child: child,
);
}
}

The next thing is to wrap our home widget with AppRetainWidget.

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: AppRetainWidget(
child: MyHomePage(),
),
);
}
}

And finally here is the native code in the Android side that will call moveTaskToBack:

class MainActivity : FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GeneratedPluginRegistrant.registerWith(this)

MethodChannel(flutterView, "com.example/app_retain").apply {
setMethodCallHandler { method, result ->
if (method.method == "sendToBackground") {
moveTaskToBack(true)
result.success(null)
}
}
}
}
}

Tada! 🎉

If you want to see more complete example we invite you to check the companion sample project of this article here.

But Android still kills our process!

Preventing the back button from destroying our FlutterActivity is fine, but if the system needs more memory it will definitely kill our app process.

Android is really strict with these things 🔪 😱

When the process running the app is killed, the FlutterActivity is halted and we already know what happens with Dart...

A first approach to prevent our process from being killed by the system is to increase the priority so other processes are more likely to be killed before ours. This can be easily achieved by launching a foreground Service (See Android process lifecycle).

This works well, but then we realised that there is another problem: The user can still destroy theFlutterActivity if they remove it from the recent apps list!

FlutterNativeView, a new hope (again)

What would you say if I told you that there is a (not-very-well documented) way to run Dart code without having a FlutterActivity running? Sounds good, right?

The only issue is that it runs the code in a separate Isolate, and we don’t want to have two Isolates communicating with each other by using Ports (More info in the first article).

In the end, we decided to monitor theFlutterActivity lifecycle by creating a headless FlutterNativeView whenever the FlutterActivity is not running. If the user opens the app again we automatically destroy the FlutterNativeView, so the main app code is the only one executed.

The headless FlutterNativeView creates a new Isolate where only the code that is important for us to run in the background is executed (Websockets, location tracking, etc.). This means that all the logic related to the UI is not executed at all.

In order to detect whether the FlutterActivity is running, we register an ActivityLifecycleCallbacks that checks our activity state:

class App : FlutterApplication() {
override fun onCreate() {
super.onCreate()
registerActivityLifecycleCallbacks(
LifecycleDetector.callbacks)
}
}

And here is the implementation of the LifecycleDetector:

object LifecycleDetector {
val callbacks: Application.ActivityLifecycleCallbacks =
LifecycleCallbacks()

var listener: Listener? = null

var isActivityRunning = false
private set

interface Listener {
fun onFlutterActivityCreated()
fun onFlutterActivityDestroyed()
}

private class LifecycleCallbacks :
Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(
activity: Activity, state: Bundle?) {
if (activity is MainActivity) {
isActivityRunning = true
listener?.onFlutterActivityCreated()
}
}

override fun onActivityDestroyed(activity: Activity) {
if (activity is MainActivity) {
isActivityRunning = false
listener?.onFlutterActivityDestroyed()
}
}

// Additional overrides omitted for brevity
}
}

The LifecycleDetector is then used within our foreground service, and starts and stops a FlutterNativeView when required:

class BackgroundService : Service(), LifecycleDetector.Listener {
override fun onCreate() {
super.onCreate()
// This is important to call it or anything that is
// Flutter specific won't work (I.e. method channels)
FlutterMain.ensureInitializationComplete(this, null)
LifecycleDetector.listener = this
}
override fun onFlutterActivityCreated() {
stopFlutterNativeView()
}
override fun onFlutterActivityDestroyed() {
startFlutterNativeView()
}
private fun startFlutterNativeView() {
val callback = getCallbackInformation()
view = FlutterNativeView(this, true).apply {
GeneratedPluginRegistrant
.registerWith(pluginRegistry)

val args = FlutterRunArguments().apply {
bundlePath = FlutterMain.findAppBundlePath()
entrypoint = callback.callbackName
libraryPath = callback.callbackLibraryPath
}

runFromBundle
(args)
}
}
// Additional code omitted by brevity
}

And what about getCallbackInformation?

This function returns a FlutterCallbackInformation that is generated on the Flutter side and points to the Dart method that will be executed when the Isolate is launched.

To pass this info from Flutter to Android, we use a MethodChannel like this in the Dart code:

var channel = const MethodChannel('com.example/background_service');
var callbackHandle = PluginUtilities
.getCallbackHandle(backgroundMain);
channel.invokeMethod('startService', callbackHandle.toRawHandle());

The backgroundMain method will be executed when the background Isolate is started. It acts like the main method that first gets executed when a Flutter app starts:

void backgroundMain() {
// This is important to call it at first place or anything
// that is Flutter specific won't work (I.e. method channels).
WidgetsFlutterBinding.ensureInitialized();
// The following services are the same that are started
// in the app's main method. In this way we reuse the same
// code.
WebsocketService().start();
DeviceLocationListener().start();
}

And that about does it!

Obviously, this is a very simplified version of what we do, but hopefully reveals enough of the important concepts needed to successfully run background code in Flutter apps.

If you want to see a more complete example we invite you to check the companion sample project of this article here.

Conclusions

Avoiding the premature destruction of the FlutterActivity with the use of moveTaskToBack was a huge win for us.

For now, we think our solution is reasonable and we expect that the use of this obscure functionality from the SDK will keep working in future versions of Android (and you too, Samsung 😘🙏).

Finally, the use of FlutterNativeView for running background tasks was an amazing discovery and, now we’ve ironed out the initial caveats, it has become a powerful part of the dev team’s toolset.

We hope you enjoyed this article! If you have any questions or observations please do write a comment below.

Like what you see? We’re hiring! 🚀 Check out our open engineering positions.

--

--