TransactionTooLargeException and a Bridge to Safety — Part 2

Brian Yencho
Livefront
Published in
10 min readDec 18, 2019
Photo by Immortal shots

In the first article of this series, I discussed what TransactionTooLargeException is, when and why it happens, and how the Bridge library can be used to avoid the problem. If you are not yet familiar with how to use Bridge you should definitely check out that article before continuing. What I’d like to do now is to take a peek inside the library itself and focus on a few tricks used to make it all work. First, though, we have a bit of bookkeeping to discuss.

A quick look at the documentation will show that all the interactions with Bridge take the form of static method calls: Bridge.saveInstanceState, Bridge.restoreInstanceState, etc. However, there is very little actual code in the Bridge class itself. Apart from checks to make sure the initialize method has been called, each static method simply calls through to a static instance of a different class, BridgeDelegate:

The Bridge class is just a wrapper for a static instance of BridgeDelegate.

BridgeDelegate is where all the action actually takes places and this will be the focus of the discussion below. It is also important to emphasize that I’ll be presenting a somewhat simplified version of that code in order to focus on the core functionality.

A Look Inside

In order to avoid a TransactionTooLargeException when sending too much data across processes after onSaveInstanceState, Bridge must do a few key things:

  • Bypass sending the data to system_process and store it “locally” (i.e. solely within reach of your app’s own process).
  • Save the data both in memory (for quick use across configuration changes) and to disk (for use after process death and recreation).
  • Effectively link a given instance of a class to its own stored data (even after process death and restoration).

Let’s take a high-level look at the two main methods in BridgeDelegate involved in actually saving and restoring data and then discuss all the key points below:

The primary exposed methods of BridgeDelegate for saving and restoring data.

Bridge generates keys that are stored in the usual Bundle

The first action taken when attempting to save data is to create a unique key that can be used to find that data later. This is done using a UUID returned from a call to getOrGenerateUuid:

A UUID is either generated or retrieved from an internal map.

You’ll note that once we have generated a UUID, we will associate that key with the Object itself in a Map; this allows us to avoid generating multiple keys for the same object, which prevents the same data from being unnecessarily written to disk multiple times later on. It is also very important that the Map used is a WeakHashMap: because a single instance of BridgeDelegate is held as a static variable in the Bridge class, holding strong references to the targets themselves would result in memory leaks (and of Activity and Fragment instances no less!).

Once we have a UUID for the target, that value is then stored in the original Bundle sent in with the target:

state.putString(getKeyForUuid(target), uuid);

This UUID is the only piece of data Bridge will store in the original Bundle for a given target and this fact is what prevents TransactionTooException from ever occurring: rather than placing an arbitrary amount of data in a Bundle we are now only storing a single String. In this way, we leverage Android’s existing saved state framework to hold the unique locator we will need for “locally” managing the actual saved state ourselves.

Without Bridge, generic Bundle data will be sent directly to system_process via IPC, where it will be subject to a potential TransactionTooLargeException.
With Bridge, only a UUID will be sent to system_process for a given target. The generic Bundle data will be intercepted and stored safely within reach of an application’s own process.

Before we move on to the discussion of where the actual saved data goes, let’s mention one final point. Note from above that the key used is based on the name of the target class:

When storing the UUID in the original Bundle, the keys used are based on the name of the target class.

This allows Bridge to manage the state of other objects (like presenters or viewmodels) in addition to Activity and Fragment instances if necessary. Multiple keys can then be safely tracked in the same Bundle of a given Activity / Fragment (assuming, of course, that an attempt is not made to save the state of different instances of the same class in the same Bundle, which would not typically be done anyway with or without Bridge).

Now let’s take a look at where the actual data goes and how the UUID is used to retrieve it.

Bridge places the actual data in a completely separate Bundle

Next, using the SavedStateHandler instance passed to the Bridge.initialize call, Bridge populates a completely new Bundle with the data a caller is actually interested in saving:

Bundle bundle = new Bundle(); mSavedStateHandler.saveInstanceState(target, bundle);

Recall that this SavedStateHandler is just an abstraction around libraries like Icepick, StateSaver, etc. that pull data from the annotated fields / properties of the target object and place them in the supplied Bundle. The usage here follows their typical behavior, but rather than passing the original Bundle supplied by the OS in onSaveInstanceState we have supplied our own.

Bridge saves these new Bundles to memory

Now that we have a Bundle of our own and a UUID we can use to uniquely match it to the source object, we can begin storing that Bundle for later reuse.

Data is saved to both memory and disk.

The first thing we’ll do is save it to memory. This is the easy part: we’ll simply associate each UUID and Bundle pair in a Map:

private Map<String, Bundle> mUuidBundleMap = new HashMap<>(); 

Now, before discussing how we further persist this data to disk, let’s review the steps we can use to pull this saved data from memory and restore it to our target object. When we call restoreInstanceState, retrieval of this in-memory Bundle is simple:

  • As a complement to the getOrGenerateUuid method discussed above for getting a new or cached UUID for a given target, we can call getSavedUuid to pull the UUID out of the external, OS-provided Bundle (or directly from the in-memory map where available).
The UUID for a given target may be cached in memory or saved to disk.
  • If we find an associated UUID, we then call getSavedBundle to pull the matching Bundle from the in-memory mUuidBundleMap, only falling back to the disk-persisted data if it is not already present here.
The Bundle for a given target may be cached in memory or saved to disk.
  • Finally, if we successfully retrieve a Bundle we can restore the saved state in question using our SaveStateHandler once again:
mSavedStateHandler.restoreInstanceState(target, bundle);

We have now saved and restored a target’s data without ever having to send it to system_process for safe keeping! Let’s now look at the last few tricks used to do the final, key piece of the puzzle: persisting the data to disk.

Bridge can persist generic Bundles to disk

When it comes to persisting data to disk in Java, the primary means of achieving this generically is via the Serializable interface. A quick look at the Bundle class — the form in which all our data is now stored — reveals, however, that it does not implement Serializable. That is pretty bad news for a quick, easy solution. A Bundle is Parcelable, but that is a format meant for transferring data across processes via a Parcel, not for writing it to disk. A Bundle also does not exclusively contain simple data types that are themselves Serializable or otherwise easily-persistable: in addition to simple types like String and Float there can also be Parcelable data, IBinder references, and even other Bundle instances!

Fortunately, with a few good assumptions and a lucky bit of documentation, we can in fact find a way to make this work. Let’s go back to the earlier statement that the Bundle class implements Parcelable. Is there anything there that can help us? Well the Parcelable interface itself really only allows us to do two things: read from and write to Parcel objects.

So what about the Parcel class? This is where we start to find our way forward: there is a method called Parcel.marshall() that returns the content of the Parcel as a byte array (and a corresponding Parcel.unmarshall(byte[], int, int) to read those same bytes back to a Parcel). This is now beginning to look very much like Serializable: generic objects go in and bytes come out. If we can use this to get bytes from a generic Bundle we should be able to write that Bundle to disk however we choose.

Under certain conditions, a Bundle may be safely written out as bytes and then back again.

Before proceeding, let’s first take a look at the documentation for Parcel.marshal():

The documentation for the Parcel.marshall() method

OK, so that first sentence is pretty bad news:

The data you retrieve here must not be placed in any kind of persistent storage (on local disk, across a network, etc).

That would seem to put an end to this particular strategy of attempting to save a generic Bundle to disk. What ends up saving us, though, is the last sentence:

The Parcel marshalled representation is highly optimized for local IPC, and as such does not attempt to maintain compatibility with data created in different versions of the platform.

This is the “lucky bit of documentation” I previously mentioned. They could have just left it at “don’t do this,” but this additional explanation for why you shouldn’t attempt to persist these bytes to disk is just enough information to allow us to brazenly disregard the warning and do just that. The reason we can do so brings us to our assumptions:

  • Even though Bridge will persist these bytes to disk (in direct violation of the warning) they will only ever be used in process-death-and-restoration scenarios. This means that any fresh launch of an app using Bridge will not read this data from disk, let alone pass it to Parcel.unmarshall(byte[], int, int). In particular, updates to the Android version of a user’s device will require a device restart, which means that the next time an app using Bridge is launched, there will be no associated saved state andBridge will therefore not attempt to read this data in this scenario either. So even though we are persisting the data, if we can assume the documentation’s explanation for the warning is both accurate and complete then we know we will not encounter any issues because we will never try to read that data back to an incompatibleParcel implementation.
  • The next assumption brings us to something the Parcel.marshall() documentation leaves out: that this method will actually crash if it contains references to objects that can not be written out as bytes. This includes obscure things like IBinder references, some rendering-specific things like Surface, and some more common but very-highly optimized classes like Bitmap (which delegate their writeToParcel calls to special native code). Bridge makes the assumption that these kinds of classes are simply not the kind of data that would be manually placed in aBundle in onSaveInstanceState (although Bridge does actually make an exception for Bitmap by “un-optimizing” it when writing to a Bundle because hey, you never know).

With these two key assumptions, we can take our generic Bundle data, write it to disk, and then read it back later if required for process-death-and-restoration scenarios.

Let’s now take a quick look at what that looks like in our code:

The process used by Bridge to write a generic Bundle to disk.

After getting our byte[] from the Parcel, we Base64 encode them as a String. This allows for quick and easy storage in a private SharedPreferences instance. Reading the data back is simply the inverse process:

The process used by Bridge to read a Bundle back from disk.

We’ve gone from Bundle to disk and back again!

Bridge leverages SharedPreferences

One final word on the use of SharedPreferences: one usually expects SharedPreferences to be used for small amounts of data, not entire objects written to bytes and converted to a String. This is typically very important due to the synchronous nature of accessing data stored via SharedPreferences. It is worth noting, however, that a given SharedPreferences is really just a fancy wrapper around an XML file containing simple key-value pairs and that this wrapper has a number of nice properties for our particular use case:

  • SharedPreferences only ever reads its disk content a single time: as soon as a particular instance is retrieved it loads its contents on a background thread and places it into a Map in memory. Calls to retrieve data from SharedPreferences only block if the data is attempted to be accessed while this background loading is underway; otherwise the data can be accessed immediately from the Map. If Bridge.initialize is called as one of the very first steps in a custom Application.onCreate, it is then possible that the stored data is already in memory by the time it is attempted to be retrieved in the first Activity of an application. And if there is still some amount of time the application needs to block while the SharedPreferences is loading, this takes place entirely at the startup of the application where small delays are already expected (rather than when navigating between screens, which could otherwise be jarring).
  • Different SharedPreferences instances point to different files, which allows all the Bridge data to be completely isolated from an application-specific data developers may store in other SharedPreferences instances.
  • SharedPreferences completely abstracts file handling, which allows the Bridge library to focus on the content it is trying to store and less on the details of the disk implementation itself.
  • When using SharedPreferences.Editor.apply(), changes can be easily written to disk on a background thread while also guaranteeing that an application won’t move to its next lifecycle state before this process is complete. This is very useful for Bridge, which must ensure that saved state data is completely written to disk before the application is fully in the “stopped” state when going into the background. Failure to do so might result in the application being killed before all the necessary data has been written to disk.
The complete process from Bundle to SharedPreferences involves Base64 encoding the bytes coming from Parcel.marshall.

Final Thoughts

We’ve now reviewed the primary functionality of Bridge: saving and restoring arbitrary Bundle data without triggering TransactionTooLargeException. While TransactionTooLargeException may be a seemingly small and isolated problem, I hope to have impressed upon you that it requires more than just a little trickery and technical machinery to beat it.

Brian works at Livefront, where it’s Bundles all the way down…

--

--