Article
TransactionTooLargeException and a Bridge to Safety — Part 2
December 18, 2019
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
:
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:
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
:
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.
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:
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.
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 callgetSavedUuid
to pull the UUID out of the external, OS-providedBundle
(or directly from the in-memory map where available).
- If we find an associated UUID, we then call
getSavedBundle
to pull the matchingBundle
from the in-memorymUuidBundleMap
, only falling back to the disk-persisted data if it is not already present here.
- Finally, if we successfully retrieve a
Bundle
we can restore the saved state in question using ourSaveStateHandler
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.
Before proceeding, let’s first take a look at the documentation for Parcel.marshal()
:
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 usingBridge
will not read this data from disk, let alone pass it toParcel.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 usingBridge
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 likeIBinder
references, some rendering-specific things likeSurface
, and some more common but very-highly optimized classes likeBitmap
(which delegate theirwriteToParcel
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
inonSaveInstanceState
(althoughBridge
does actually make an exception forBitmap
by “un-optimizing” it when writing to aBundle
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:
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:
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 aMap
in memory. Calls to retrieve data fromSharedPreferences
only block if the data is attempted to be accessed while this background loading is underway; otherwise the data can be accessed immediately from theMap
. IfBridge.initialize
is called as one of the very first steps in a customApplication.onCreate
, it is then possible that the stored data is already in memory by the time it is attempted to be retrieved in the firstActivity
of an application. And if there is still some amount of time the application needs to block while theSharedPreferences
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 theBridge
data to be completely isolated from an application-specific data developers may store in otherSharedPreferences
instances. SharedPreferences
completely abstracts file handling, which allows theBridge
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 forBridge
, 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.
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…