ActivityTask, an helper for async/await on Android
While at I/O last week, we happened to attend the Architecture Component talk on Android lifecycle (which I recommend you watch). While the solutions presented there are definitely interesting and, in some cases, map to patterns we already have in .NET, it definitely resonated with us present on how those Android lifecycle nitpicks make one specific C# feature more cumbersome to use: async/await.
With async/await, the two main nitpicks that are pertaining to Android developers are:
- Because of Android resource system works, a change of configuration (e.g. screen rotation) will recreate
Activity
instances by default - Because
await
is oblivious to anActivity
lifecycle, it may execute continuations while in an undesired state causing anIllegateStateException
Note that we already introduced something that partially address those qualms in the form of the ActivityController with the added bonus of providing convenient asynchronous wrapper methods for StartActivityForResult
-based workflows.
In this case, based on some other fun I had previously, I figured there was potentially a way to leverage C# 7 new customizable async state machines drivers to achieve something that could help mitigate those two issues without requiring too much changes in your code.
Enter ActivityTask (also on NuGet).
This little library has two main primitives:
ActivityScope
allows to track the most recent instance of one of your Activity subclass so that potential underlying re-creations are transparent to youActivityTask
acts as a standardTask
return value in an async method but customize the state machine driver (in cooperation withActivityScope
) to make continuation scheduling aware of the activity lifecycle.
Under the hood, ActivityScope
simply registers a listener at the Application
level to listen to global activity lifecycle events. When it detects the activity it’s tracking is about to die, it will mark it so that when it’s respawned it can re-associate with it. Because it implements an implicit conversion operator to Activity
, you can pass the scope wherever an activity instance is needed to ensure you always use a valid value.
As for ActivityTask
the implementation is pretty boring (it pretty much defers to a TaskCompletionSource
), the interesting portion is with ActivityScopeMethodBuilder
that drives the async state machine. For all intent and purpose, it will behave like the default driver for Task
. However, thanks to the extra ActivityScope
method argument, it will additionally make sure that any continuation is only executed when the activity tracked by the scope is in a usable state at that moment. If not, it will simply keep the continuation queued up until the tracked activity is resumed.
To see how all of this fits together, here is the code for the activity of the test app in the GitHub repository:
[Activity(Label = "ActivityTaskTest", MainLauncher = true, Icon = "@mipmap/icon")]
public class MainActivity : Activity
{
static bool launched = false;
protected override async void OnCreate(Bundle savedInstanceState)
{
base.OnCreate(savedInstanceState);
// Set our view from the "main" layout resource
SetContentView(Resource.Layout.Main);
if (!launched)
{
launched = true;
using (var scope = ActivityScope.Of(this))
await DoAsyncStuff(scope);
}
}
TextView MyLabel(Activity activity) => activity.FindViewById<TextView>(Resource.Id.myLabel);
async ActivityTask DoAsyncStuff(ActivityScope scope)
{
await Task.Delay(3000); // Medium network call
MyLabel(scope).Text = "Step 1";
await Task.Delay(5000); // Big network call
MyLabel(scope).Text = "Step 2";
}
}
This example simulates launching a series of asynchronous operations the very first time an activity is created. Beforehand though, it creates an ActivityScope
to track the lifetime of the current activity and pass it down. Between each asynchronous substep, the activity instance is used to fetch the label onscreen and update its text.
The idea is to trigger a damaging event during one of those Task.Delay
calls. Some that you can test are rotating your device (thus killing and re-creating the activity) or pressing the home button to pause the activity and reopen it after the delay expired.
For instance, if rotating the screen after “Step 1” is shown, you should see the original text of the label briefly re-appears (because the layout was inflated from scratch) and soon after see “Step 2” be set meaning the async method used the correct new activity instance to locate the label.
If pausing the activity after “Step 1” is shown, resuming the activity after the second delay expires should result in “Step 2” being displayed immediately as the callback got executed during the resume process instead of running while the activity was in the background.