The mysterious case of the Bundle and the Map
Because putting Maps in a Bundle is harder than it looks.
[This post was written with the help of Eugenio Marletti]
⚠️ Warning — this is a long post.
When edge cases just aren’t covered
Assume that you need to pass a map of values as an extra in an Intent. This might not be a common scenario, admittedly, but it can happen. It definitely happened to Eugenio.
If you are using a HashMap
, the most common type of Map
, and you didn’t create a custom class that contains “extra” information, you’re lucky. You can put in:
intent.putExtra("map", myHashMap);
And in your receiving Activity
, you’ll get your nice map back out of the Intent
’s extras:
HashMap map = (HashMap) getIntent().getSerializableExtra("map");
But what if you need to pass another kind of Map
in the intent extras — say, a TreeMap
(or any custom implementation)? Well, when you retrieve it:
TreeMap map = (TreeMap) getIntent().getSerializableExtra("map");
Then you get this:
java.lang.ClassCastException: java.util.HashMap cannot be cast to java.util.TreeMap
Yep, a nice ClassCastException
, because your map has turned into… a HashMap
.
We’ll see why we’re using
getSerializableExtra()
later on, suffice for now saying it’s because all the defaultMap
implementations areSerializable
and there’s no narrower scope ofputExtra()
/get*Extra()
that can accept them.
Before we move on, let’s get to know the actors involved in this drama.
[tl:dr; skip to “Workaround” at the end if you just want a solution!]
On Parcels
Many of you will know (but maybe some don’t) that all the IPC communication in the Android framework is based upon the concept of Binder
s. And hopefully many of you know that the main mechanism to allow data marshalling between those processes is based on Parcel
s.
A Parcel
is an optimised, non-general purpose serialisation mechanism that Android employs for IPC. Contrary to Serializable
objects, you should never use Parcel
s for any kind of persistence, as it does not provision for handling different versions of the data. Whenever you see a Bundle
, you’re dealing with a Parcel
under the hood.
Adding extras to an Intent? Parcel.
Setting arguments on a Fragment? Parcel.
And so on.
Parcel
s know how to handle a bunch of types out of the box, including native types, strings, arrays, maps, sparse arrays, parcelables and serializables. Parcelable
s are the mechanism that you (should) use to write and read arbitrary data to a Parcel
, unless you really, really need to use Serializable
.
The advantages of Parcelable
over Serializable
are mostly about performances, and that should be enough of a reason to prefer the former in most cases, as Serializable
comes with a certain overhead.
Down into the rabbit hole
So, let’s try to understand what makes us get a ClassCastException
. Starting from the code we are using, we can see that our call to Intent#putExtras()
resolves to the overload that takes a String
and a Serializable
. As we’ve said before, this is expected, as Map
implementations are Serializable
, and they aren’t Parcelable
. There also isn’t a putExtras()
that explicitly takes a Map
.
Step one: finding the first weak link
Let’s look at what happens in Intent.putExtra(String, Serializable)
:
See the
Intent.java
code
public Intent putExtra(String name, Serializable value) {
// ...
mExtras.putSerializable(name, value);
return this;
}
In here, mExtras
is clearly a Bundle
. So ok, Intent
delegates all the extras to a bundle, just as we expected, and calls Bundle#putSerializable()
. Let’s see what that method does:
See the
Bundle.java
code
@Override
public void putSerializable(String key, Serializable value) {
super.putSerializable(key, value);
}
As it turns out, this just in turn delegates to the super
implementation, which is:
See the
BaseBundle.java
code
void putSerializable(String key, Serializable value) {
unparcel();
mMap.put(key, value);
}
Good, we got to some meat, at last.
First of all, let’s ignore unparcel()
. We can notice that mMap
is an ArrayMap<String, Object>
. This tells us we’re losing any kind of type information we might have had before — i.e., at this point, everything ends up in one big map that contains Object
s as values, no matter how strongly typed the method we used to put the value in the Bundle
was.
Our spider sense starts to tingle…
Step two: writing the map
The really interesting stuff starts to happen when we get to actually writing the contents of the Bundle
to a Parcel
. Until then, if we check the type of our extra, we’re still getting the correct type:
Intent intent = new Intent(this, ReceiverActivity.class);
intent.putExtra("map", treeMap);
Serializable map = intent.getSerializableExtra("map");
Log.i("MAP TYPE", map.getClass().getSimpleName());
That prints, as we’d expect, TreeMap
to the LogCat. So the transformation must happen between the time the Bundle
gets written into the Parcel
, and when it’s read again.
If we look at how writing to a Parcel
happens, we see that the nitty gritty goes down in BaseBundle#writeToParcelInner
:
See the
BaseBundle.java
code
void writeToParcelInner(Parcel parcel, int flags) {
if (mParcelledData != null) {
// ...
} else {
// ...
int startPos = parcel.dataPosition();
parcel.writeArrayMapInternal(mMap);
int endPos = parcel.dataPosition();
// ...
}
}
Skipping all the code that is irrelevant for us, we see that the bulk of the work is performed by Parcel#writeArrayMapInternal()
(remember thatmMap
is an ArrayMap
):
See the
Parcel.java
code
/* package */ void writeArrayMapInternal(
ArrayMap<String, Object> val) {
// ...
int startPos;
for (int i=0; i<N; i++) {
// ...
writeString(val.keyAt(i));
writeValue(val.valueAt(i));
// ...
}
}
What this basically does is it writes every key-value pair in the BaseBundle
’s mMap
sequentially as a String
(the keys are all strings here) followed by the value. The latter seems not to be considering the value type so far.
Let’s go one level deeper!
Step three: writing maps’ values
So how does Parcel#writeValue()
look like, you ask? Here it is, in its if-elseif-else glory:
See the
Parcel.java
code
public final void writeValue(Object v) {
if (v == null) {
writeInt(VAL_NULL);
} else if (v instanceof String) {
writeInt(VAL_STRING);
writeString((String) v);
} else if (v instanceof Integer) {
writeInt(VAL_INTEGER);
writeInt((Integer) v);
} else if (v instanceof Map) {
writeInt(VAL_MAP);
writeMap((Map) v);
} else if (/* you get the idea, this goes on and on */) {
// ...
} else {
Class<?> clazz = v.getClass();
if (clazz.isArray() &&
clazz.getComponentType() == Object.class) {
// Only pure Object[] are written here, Other arrays of non-primitive types are
// handled by serialization as this does not record the component type.
writeInt(VAL_OBJECTARRAY);
writeArray((Object[]) v);
} else if (v instanceof Serializable) {
// Must be last
writeInt(VAL_SERIALIZABLE);
writeSerializable((Serializable) v);
} else {
throw new RuntimeException("Parcel: unable to marshal value "+ v);
}
}
}
Aha! Gotcha! Even though we put our TreeMap
in the bundle as a Serializable
, the writeValue()
method does in fact catch it in its v instanceOf Map
branch, which (for obvious reasons) comes before the else … if (v instanceOf Serializable)
branch.
At this point, the smell is getting really strong.
I now wonder, are they using some totally undocumented shortcut for Map
s, that somehow turns them into HashMap
s?
Step four: writing a Map
to the Parcel
Well, as it turns out, writeMap()
doesn’t do an awful lot in and by itself, apart from enforcing the type of Map
we’ll be handling later on:
See the
Parcel.java
code
public final void writeMap(Map val) {
writeMapInternal((Map<String, Object>) val);
}
The JavaDoc for this method is pretty clear:
“The
Map
keys must be String objects.”
Type erasure makes sure we’ll actually never have a runtime error here, though, even if we might be passing a Map
with keys that aren’t Strings
(again, this is totally undocumented at higher level…).
In fact, as soon as we take a look at writeMapInternal()
, this hits us:
See the
Parcel.java
code
/* package */ void writeMapInternal(Map<String,Object> val) {
// ...
Set<Map.Entry<String,Object>> entries = val.entrySet();
writeInt(entries.size());
for (Map.Entry<String,Object> e : entries) {
writeValue(e.getKey());
writeValue(e.getValue());
}
}
Again, type erasure here makes all those casts pretty much worthless at runtime. The fact is that we’re relying on our old type-checking friend writeValue()
for both the keys and the values as we “unpack” the map and just dump everything in the Parcel
. And as we’ve seen, writeValue()
is perfectly able to handle non-String
keys.
Maybe the documentation got out of sync with the code at some point here, but as a matter of fact, putting and retrieving a TreeMap<Integer, Object>
in a Bundle
works perfectly.
Well, with the exception of the TreeMap
becoming an HashMap
, of course.
Ok, the picture here is pretty clear by now. Map
s completely lose their type when they’re written to a Parcel
, so there’s no way to recover that information when they get read back.
Step five: reading back the Map
As a last quick check of our theory, let’s go and check readValue()
, which is writeValue()
’s counterpart:
See the
Parcel.java
code
public final Object readValue(ClassLoader loader) {
int type = readInt(); switch (type) {
case VAL_NULL:
return null; case VAL_STRING:
return readString(); case VAL_INTEGER:
return readInt(); case VAL_MAP:
return readHashMap(loader); // ...
}
}
The way Parcel
works when writing data is, for each item it contains:
- it writes an
int
that defines the data type (one of theVAL_*
constants) - dumps the data itself (optionally including other metadata such as the data length for non-fixed size types, e.g.
String
). - recursively repeat for nested (non-primitive) data types
Here we see that readValue()
reads that data type int
, that for our TreeMap
was set to VAL_MAP
by writeValue()
, and then the corresponding switch
case simply calls readHashMap()
to retrieve the data itself:
See the
Parcel.java
code
public final HashMap readHashMap(ClassLoader loader)
{
int N = readInt();
if (N < 0) {
return null;
}
HashMap m = new HashMap(N);
readMapInternal(m, N, loader);
return m;
}
(the C#-style opening curly brace is actually in AOSP, it’s not my fault)
You can pretty much imagine that readMapInternal()
simply repacks all map items it reads from the Parcel
into the map that we pass to it.
And yes. This is the reason why you get always a HashMap
back from a Bundle
. The same goes if you create a custom Map
that implements Parcelable
. Definitely not what we’d expect!
It’s hard to say if this is an intended effect or simply an oversight. It’s admittedly an edge case, since you have really few valid reasons to pass a Map
into an Intent
, and you should have just as little good reasons to pass Serializable
s instead of Parcelable
s. But the lack of documentation makes me think it might actually be an oversight rather than a design decision.
Workaround (aka tl;dr)
Ok, we’ve understood our issue in depth, and now we’ve identified the critical path that messes with us. We need to make sure our TreeMap
doesn’t get caught into the v instanceOf Map
check in writeValue()
.
The first solution that came to my mind when talking to Eugenio was ugly but effective: wrap the map into a Serializable
container. Eugenio quickly whipped up this generic wrapper and confirmed it solves the issue.
Please note the gist is using the Android’s @NonNull
annotation to enforce its contracts. If you want to use this in pure Java modules, you can replace it with JetBrains’ @NotNull
, or you could strip those annotations altogether.
Another possible workaround
Another solution could be to pre-serialize the Map
yourself into a byte array before putting it as an Intent
extra, and then retrieving it with getByteArrayExtra()
, but you’d then have to handle serialisation and deserialisation manually.
In case you masochistically wanted to opt for this other solution instead, Eugenio has provided a separate Gist with the code.
When you don’t control upstream Intents
Lastly, maybe for some reason you can’t control the Bundle
creation code — e.g., because it’s in some third-party library.
In that case, remember that many Map
implementations have a constructor that takes a Map
as input, like new TreeMap(Map)
. You can use that constructor, if needed, to “change back” the HashMap
you retrieve from the Bundle
into your preferred Map
type.
Keep in mind that in this case any “extra” properties on that map will be lost and only the key/value pairs will be preserved.
Conclusion
Being an Android developer means juggling your way around pretty much everything, especially the small, seemingly insignificant things.
What can we learn from this?
When things don’t work as you’d expect them,
don’t just stare at the JavaDoc.
Because that might be outdated.
Or because the authors of the JavaDoc
didn’t know about your specific case.
The answer might be in the AOSP code.
We have the huge luxury (and curse) of having access to the AOSP code. That’s something almost unique in the mobile landscape. We can know to a certain extent exactly what goes on. And we should.
Because even though it might look like it’s WTF-land sometimes, you can only become a better developer when you get to know the inner workings of the platform you work on.
And remember: what doesn’t kill you makes you stronger. Or crazier.