Mastodon

Wednesday, September 16, 2015

Taming the Android Activity, Part 3

In the parts one and two of this series I covered how to use a Fragment to maintain state through configuration changes and how to launch a new Activity and wait for its results using async/await. In the third part I take this a step further and show how to get direct access to a new (in-process) Activity instance so that you can call methods on it, use events, or whatever else you need to do.

Activities - Taking Decoupling Too Far?

The Android Activity is designed to be a modular piece of UI that can be reused across applications. For this reason Android's design forces complete decoupling. When you launch a new Activity you cannot access the new instance directly. Instead, you have to communicate with it through Intent objects. You use an Intent when launching the Activity in order to give it input, and when the Activity finishes it can communicate back its results via another Intent object. In this way the Activity is sure to work no matter how it was launched, whether by another in-process Activity or some other application's Activity.

This approach is great for allowing apps to interoperate, but in my opinion it ignores one important reality: by far, most Activity instances are launched in-process, and many are only useful within that process. Unfortunately, the Android architecture is designed exclusively for the inter-process use case, which makes the more common in-process use case unnecessarily tedious.

The biggest downside to the design is that it makes launching and gathering results from new UI screens very difficult. In most UI frameworks when a developer needs to show a new UI he simply needs to construct a new object by calling its constructor and passing in any necessary data as arguments. Further, he can call methods, set properties, and attach to events in order to communicate with the new object. In contrast, on Android he would have to deal with Intents, possible serializing any complex data structures or even implementing a Content Provider. In order to get results back he would likewise need to deserialize data or pull data from a Content Provider.

Generally I encourage strategies to decouple code, but I think Android has taken this too far by forcing decoupling across processes. In this post I will show a better way.

One Neat Trick

The only methods Android provides for launching an Activity are the StartActivity/StartActivityForResult methods. Neither of these methods provide access to the actual Activity instance that Android automatically creates for you. Somehow we need to be able to get access to that object so that we can interact with it. Fortunately, there is an API that we can use for this: the Application.IActivityLifecycleCallbacks interface. This is an interface that you can implement to handle the various Activity lifecycle events for all Activity instances that exist in the current process. In order to use this interface you can call RegisterActivityLifecycleCallbacks on the Application instance. Once you've done that you can handle all of the Activity lifecycle events (OnCreate, OnDestroy, OnPause, etc.) for all Activity objects in the entire application. The specific event we care about is OnCreate, which is called each time an Activity instance is created. Since each of these callbacks is also passed the Activity instance itself we can use this callback to get the instance that Android created for us.

Designing the API

Now that we have a technique for getting access to any Activity that is created in our application we can start to piece together how we can extend our existing FragmentActivity and FragmentBase classes (see part 2) to give clients access to that instance as well. First, we need to think about the API that we want to build. While the callbacks previously mentioned give us access to an Activity instance, what we actually need is access to an associated Fragment. The reason is that, as described in part one, the Activity object might be destroyed and recreated multiple times, which makes it not very useful. What we really want is the retained Fragment for that Activity, which holds all of the state.

Further, since the creation of the Activity and the Fragment both are asynchronous operations we need a way to pass the Fragment instance back to the caller at some point in the future. We could use a Task for this, which would be nice, but since I am already using a Task to get back the results of the Activity I chose to use a callback in this case. You could easily change this in your implementation.

Combining these two requirements I came up with this API:

public Task<IAsyncActivityResult> StartActivityForResultAsync<TFragmentActivity, TFragment>(Action<TFragment> fragmentInitializer, CancellationToken cancellationToken = default(CancellationToken))
    where TFragmentActivity : IFragmentActivity
    where TFragment : Fragment
{
    // ...
}

The usage then would look like this:

StartActivityForResultAsync<MyActivity, MyActivityFragment>(fragment =>
    {
        // Use fragment for setting properties, calling methods, or attaching to events.
    });

I will refer to the Action<TFragment> callback that you pass in as a "Fragment initializer callback" to distinguish it from the Activity lifecycle event callbacks.

Implementation

To make this work we're going to start with the code from part two and extend it a bit. First, we need to tweak the FragmentActivity class to add an event that will tell us when its Fragment is created. In order to make some generic code a bit easier to deal with we'll also add a simple interface:

/// <summary>
/// An interface for an Activity that uses a retained Fragment for its implementation.
/// </summary>
public interface IFragmentActivity
{
    /// <summary>
    /// The top-level fragment which manages the view and state for this activity.
    /// </summary>
    Fragment Fragment { get; }

    /// <summary>
    /// Invoked when the main fragment is first created.
    /// </summary>
    event EventHandler FragmentLoaded;
}

Implementing this interface in FragmentActivity is straightforward so I'll skip forward to the FragmentBase class.

The first thing we need is the ability to register for lifecycle callbacks. In order to avoid holding this object in memory for too long we also need to keep track of when we can safely unregister for the callbacks. To accomplish this we will add a field to our class to track how many Fragment initialization callbacks (the callbacks passed in to our new StartActivityForResultAsync overload) we have pending. When this count is incremented from 0 to 1 we will register from the callbacks, and when it is decremented to 0 we will unregister. Then we just need to increment it whenever someone calls our new StartActivityForResultAsync overload and decrement it whenever we satisfy one of those callbacks. The support code look like this:

private int _numberOfPendingFragmentInitializers;

private void AddPendingFragmentInitializer()
{
    _numberOfPendingFragmentInitializers++;
    if (_numberOfPendingFragmentInitializers == 1)
    {
        var application = (Application)Application.Context;
        application.RegisterActivityLifecycleCallbacks(this);
    }
}

private void RemovePendingFragmentInitializer()
{
    if (_numberOfPendingFragmentInitializers <= 0)
    {
        throw new InvalidOperationException("Too many calls to RemovePendingFragmentInitializer");
    }

    _numberOfPendingFragmentInitializers--;
    if (_numberOfPendingFragmentInitializers == 0)
    {
        var application = (Application)Application.Context;
        application.UnregisterActivityLifecycleCallbacks(this);
    }
}

Just in case we should also be sure to unregister in OnDestroy:

public override void OnDestroy()
{
    base.OnDestroy();

    if (_numberOfPendingFragmentInitializers != 0)
    {
        var application = (Application)Application.Context;
        application.UnregisterActivityLifecycleCallbacks(this);
        _numberOfPendingFragmentInitializers = 0;
    }
}

We'll come back to these functions later. The next step is to keep track of the Fragment initializer callbacks themselves so that we can find them and call them when needed. We already have a private class (AsyncActivityResult) for keeping track of our pending async Activity requests so we can just add the initializer callback there:

public Action<Fragment> FragmentInitializer { get; private set; }

public AsyncActivityResult(int requestCode, Action<Fragment> fragmentInitializer)
{
    RequestCode = requestCode;
    FragmentInitializer = fragmentInitializer;
}

Now we have to have some code that calls this initializer. This part is tricky for a few reasons. First, the lifecycle callbacks that we will be receiving are for Activity objects, not Fragment objects. What we want is the Fragment. When an Activity is first created (even one of our FragmentActivity subclasses) it doesn't have a Fragment. There's a delay between when the Activity is created and when it creates its Fragment. That is why we added the FragmentLoaded event to FragmentActivity. The second complication is that, as mentioned in part one, an Activity may be destroyed and recreated at pretty much any time. That means even if we find an Activity we're interested in and attach to its FragmentLoaded event we still have to be prepared for that Activity to be destroyed before it actually loads its Fragment. In that case a new Activity will be created, and we have to attach to its FragmentLoaded event. We can't stop looking until we finally get that event handler callback.

In order to identify the Activity instances we are interested in when we get our lifecycle callbacks we will add some extra data to the Intent object we use to launch it. This extra data will be an integer recording the request code that we used. It's important to note here that the request codes are unique across instances of FragmentBase so we won't mix them up. We did this by making the _nextAsyncActivityRequestCode field static. We will add this extra data at the point where we are creating our Intent object, which happens in our StartActivityForResultAsync method:

public Task<IAsyncActivityResult> StartActivityForResultAsync<TFragmentActivity, TFragment>(Action<TFragment> fragmentInitializer, CancellationToken cancellationToken = default(CancellationToken))
    where TFragmentActivity : IFragmentActivity
    where TFragment : Fragment
{
    Action<Fragment> fragmentInitializerAdaptor = null;
    if (fragmentInitializer != null)
    {
        fragmentInitializerAdaptor = fragment => fragmentInitializer((TFragment)fragment);
    }

    return StartActivityForResultAsyncCore(
        requestCode =>
        {
            AddPendingFragmentInitializer();
            var intent = new Intent(Activity, typeof(TFragmentActivity));
            intent.PutExtra(AsyncActivityRequestCodeExtra, requestCode);
            Activity.StartActivityForResult(intent, requestCode);
        },
        cancellationToken,
        fragmentInitializerAdaptor);
}

The StartActivityForResultAsyncCore method does some setup of the AsyncActivityResult object, but it uses a callback to do the work of creating the Intent and launching it. In this case we need to add our extra data and also call AddPendingFragmentInitializer so that we can register for our lifecycle event callbacks.

You may have also noticed the fragmentInitializerAdaptor variable. This code is just to avoid using generics for the AsyncActivityResult class, which keeps track of the Fragment initializer callback for us. The code that will call this callback doesn't know the type of the Fragment, and it would be difficult to keep track of that information so we encode it in a wrapper lambda via a downcast.

Now we have recorded the Fragment initializer callback and registered for our lifecycle event callbacks so the next step is implementing those lifecycle event callbacks. The only callback we care about is OnCreate. The rest are just empty methods. Here is the interesting one:

void Application.IActivityLifecycleCallbacks.OnActivityCreated(Activity activity, Bundle savedInstanceState)
{
    var fragmentActivity = activity as IFragmentActivity;
    if (fragmentActivity == null)
    {
        return;
    }

    var intent = activity.Intent;
    if (intent != null && intent.HasExtra(AsyncActivityRequestCodeExtra))
    {
        int requestCode = intent.GetIntExtra(AsyncActivityRequestCodeExtra, 0);
        AsyncActivityResult asyncActivityResult;
        if (_pendingAsyncActivities.TryGetValue(requestCode, out asyncActivityResult))
        {
            if (asyncActivityResult.FragmentInitializer == null)
            {
                return;
            }

            fragmentActivity.FragmentLoaded += (s, e) =>
            {
                asyncActivityResult.FragmentInitializer(fragmentActivity.Fragment);

                // It is possible that this activity is created and destroyed multiple times before loading the fragment.
                // Don't stop listening for new activities until we actually get the fragment we are interested in.
                RemovePendingFragmentInitializer();
            };
        }
    }
}

Remember that this method will be called for every Activity instance created in our application while we are still registered so most of the code is there to filter out instances we don't care about. First we ignore any Activity that doesn't implement our new interface IFragmentActivity. Next we filter out any instances that don't have our expected extra data in the Intent. After that we have to check if the request code stored in that extra data is one of the request codes that we know about. Lastly, we ignore any instances that don't have a Fragment initializer.

Once we know that the Activity instance that was just created is one that we care about we need to attach to its FragmentLoaded event. When that event is called we can then call the Fragment initializer (giving it the Fragment instance). Again, there's a chance that this event will never be triggered because this particular Activity instance may get destroyed before the Fragment is loaded. That's why it's important to only call RemovePendingFragmentInitializer inside the event handler so that we keep looking for new Activity instances as long as necessary.

Example

The implementation is now complete. Now let's look at an example usage. For that I will just take the example from part 2 and modify it to use the new API. There is only one small function that we need to modify so I'll just show you a before and after. This is the original code:

_button.Click += async (s, e) =>
{
    var result = await StartActivityForResultAsync<AsyncActivity>();
    if (result.ResultCode == Result.Ok)
    {
        _text = result.Data.GetStringExtra(AsyncActivity.TextExtra);
        UpdateText();
    }
};

Note that we have to check the result code and then use the Intent to pull out the data we care about. This is a simple example, but you can imagine that this could get quite complex if the result data were complex. What we want instead is to use a regular C# event. First we need to add that event to our AsyncActivityFragment class so here is that code:

public event EventHandler DoneClicked;

private void OnDoneClicked()
{
    if (DoneClicked != null)
    {
        DoneClicked(this, EventArgs.Empty);
    }
}

Then we add the invocation:

button.Click += delegate
{
    OnDoneClicked();
    var resultData = new Intent();
    resultData.PutExtra(AsyncActivity.TextExtra, _editText.Text);
    Activity.SetResult(Result.Ok, resultData);
    Activity.Finish();
};

Note that I left the rest of the Intent-based code in case this Activity is used outside of our application. If that's not the case then you could remove that code.

Now that we have our event we just need to use it, which requires using our new overload of StartActivityForResultAsync. Here it is:

_button.Click += (s, e) => StartActivityForResultAsync<AsyncActivity, AsyncActivityFragment>(fragment =>
{
    fragment.DoneClicked += delegate
    {
        _text = fragment.Text;
        UpdateText();
    };
});

Now we pass in a callback (our "Fragment initializer callback), and in that callback we have access to the Fragment. Then we can just attach to the new event that we added to the Fragment. From that event we can directly access the Text property. Now we have no need of the Intent. This code is simpler, easier to understand, easier to maintain, and entirely type-safe.

The full example with all of the code for FragmentActivity, FragmentBase, and our example usage is available on GitHub.

Summary

Through all three parts of this series you have learned how to deal with preserving state across configuration changes, how to use async/await to wait for Activity results, and now how to get direct access to the Fragment object for a new Activity. Using these techniques you can greatly reduce the complexity of dealing with multi-screen Android applications. Stay tuned for one more addendum post in which I will explain the one (rare) scenario that the previous two posts don't handle well.

1 comment:

  1. I'm about 2 1/2 years late to the party, but that's okay. Love this Android series. And I first found your blog last year via the very insightful iOS posts. Thanks a lot for sharing.

    ReplyDelete