Sometimes on technical interviews, Android developers are asked questions like how to launch fragments, how to pass data to fragments, why you can’t put a lot in arguments, and how much is “a lot”, what can go wrong, etc. By the way, we at Dodo Engineering sometimes ask those questions too. To be honest, I didn’t realize how much more there is to the topic until I stumbled upon the TransactionTooLargeException crash in our Drinkit application. And now I can answer those questions with a good example from practice.
My example is the notorious TransactionTooLargeException — an exception from IPC (interprocess communication) calls and Android Binder areas. But we can get it in an innocuous situation when it seems that we didn’t do anything wrong and didn’t use IPC.
In this article, we’ll resolve this crash and talk about IPC calls and Binder.
What IPC and Binder have to do with it?
When I used to hear the words IPC and Binder in the past, I thought that it was an interprocess communication — some special cases where we put a part of the application into a separate process and start interacting with it. For example, to bind the Activity from one process to the Service of another process and to exchange data. And these words (IPC and Binder) wouldn’t concern me until I would start doing something like that. But the reality is different. If you know about it, you’re doing great, keep it up. And if your knowledge is similar to the one I used to have, then it’s time for you to discover why IPC and Binder are with us everywhere, every day.
I’ll start by referring to the official Android documentation about TransactionTooLargeException. The article Parcelables and Bundles briefly explains that in Binder transactions data is transferred via Parcelables and Bundle, each process has a buffer of 1 MB for all currently executable transactions. If at any point we exceed 1 MB, we get a TransactionTooLargeException.
But it’s still not clear why we get TransactionTooLargeException if we have a simple application, one process, while not binding our activities to services or exchanging data.
To answer this question, we need to understand the theory.
How often do we use Binder
As developers, we don’t manage the Android framework components (Activity, Service, Broadcast Receiver, Content Provider) — the system does. How does it do that?
As an example, let’s see how one of the most basic actions in Android works: starting an Activity.
The well-known startActivity method is implemented in the ContextImpl.
@Override | |
public void startActivity(Intent intent, Bundle options) { | |
... | |
mMainThread.getInstrumentation().execStartActivity( | |
getOuterContext(), mMainThread.getApplicationThread(), null, | |
(Activity) null, intent, -1, options); | |
} |
A snippet from the ContextImpl.java file
And here is the Instrumentation class, which many are more familiar with by working with instrumental tests.
public ActivityResult execStartActivity( | |
Context who, IBinder contextThread, IBinder token, Activity target, | |
Intent intent, int requestCode, Bundle options) { | |
... | |
try { | |
... | |
// Call startActivity | |
int result = ActivityManagerNative.getDefault() | |
.startActivity(whoThread, who.getBasePackageName(), intent, | |
intent.resolveTypeIfNeeded(who.getContentResolver()), | |
token, target != null ? target.mEmbeddedID : null, | |
requestCode, 0, null, null, options); | |
checkStartActivityResult(result, intent); | |
} catch (RemoteException e) { | |
} | |
return null; | |
} |
A snippet from the Instrumentation.java file
Instrumentation is used to call startActivity from the ActivityManagerNative.getDefault() object. And this is nothing less than the ActivityManager service. We get it through the gDefault singleton.
private static final Singleton<IActivityManager> gDefault = new Singleton<IActivityManager>() { | |
protected IActivityManager create() { | |
IBinder b = ServiceManager.getService("activity"); | |
... | |
// that is, gDefault is the IActivityManager | |
IActivityManager am = asInterface(b); | |
... | |
return am; | |
} | |
}; | |
static public IActivityManager asInterface(IBinder obj) { | |
... | |
return new ActivityManagerProxy(obj); | |
} | |
class ActivityManagerProxy implements IActivityManager | |
{ | |
... | |
public int startActivity(...) throws RemoteException { | |
Parcel data = Parcel.obtain(); | |
Parcel reply = Parcel.obtain(); | |
// put a lot of things into data | |
... | |
// Call transact binder’s method (mRemote - is the IBinder in this case) | |
mRemote.transact(START_ACTIVITY_TRANSACTION, data, reply, 0); | |
reply.readException(); | |
int result = reply.readInt(); | |
reply.recycle(); | |
data.recycle(); | |
return result; | |
} | |
... | |
} |
A snippet from the ActivityManagerNative.java file
As a result, we call transact on the mRemote object, which, in turn, is IBinder.
Schematically it can be represented as follows:
Job Offers
I have highlighted everything that happens in our process in green. Binder — in blue (it’s like a connecting link here). The Activity Manager Service — in purple, since it is a separate process that our process communicates with.
This way, opening a new Activity is a Binder transaction.
And what other actions are Binder transactions? Well, there are many examples — communicating with Messenger, working with Content Provider, all system services: ActivityManagerService, WindowManagerService, AlarmManagerService, NotificationManagerService, ConnectivityManagerService, and others (I have just listed those that each of us has encountered many times). These are all services that we can get through Context::getSystemService.
These services are separate processes, and our application can interact with them through Binder transactions.
Binder — what exactly is that?
Binder is a tool on the Android platform that was specially created for easy and fast communication between processes. There is a cool picture in the official documentation about it.
This is where Binder is located in Android architecture. Source: https://developer.android.com/guide/platform
Binder stays at the Kernel Linux level and all the communication happens there. In brief, Binder works through transactions, and efficiently packages data and transfers them between processes via ioctl. These transactions are what we were talking about earlier when we mentioned the 1 MB buffer limitation.
It turns out that we use Binder very often, but not explicitly. It’s usually hidden from us through the Android Framework API. That’s how we get our TransactionTooLargeException — from the implicit use of Binder transactions.
Let’s solve the TransactionTooLargeException mystery
Let’s try to reproduce the bug in the Drinkit app. The main screen features a coffee shop menu. The menu is a Pager with fragments. Each fragment is a category in the menu with a bunch of its own items. We flip the menu back and forth, back and forth, collapse, and… crash.
Pager from the main screen of Drinkit app
The crash occurs mostly in the background:
The point is that when you minimize the app, the state of the Activity is saved, and this is done through Binder transactions.
Let’s take a closer look. It all starts when the Activity is stopped:
@Override | |
public void handleStopActivity(ActivityClientRecord r, int configChanges, | |
PendingTransactionActions pendingActions, boolean finalStateRequest, String reason) { | |
... | |
final StopInfo stopInfo = new StopInfo(); | |
// This is where the logic of stopping activity begins | |
performStopActivityInner(r, stopInfo, true /* saveState */, finalStateRequest, | |
reason); | |
... | |
stopInfo.setActivity(r); | |
stopInfo.setState(r.state); | |
stopInfo.setPersistentState(r.persistentState); | |
// Here we save the collected data | |
pendingActions.setStopInfo(stopInfo); | |
mSomeActivitiesChanged = true; | |
} | |
private void performStopActivityInner(...) { | |
... | |
callActivityOnStop(r, saveState, reason); | |
} | |
private void callActivityOnStop(ActivityClientRecord r, boolean saveState, String reason) { | |
... | |
// call onSaveInstanceState depending on the API version (isPreP) | |
if (shouldSaveState && isPreP) { | |
callActivityOnSaveInstanceState(r); | |
} | |
... | |
r.activity.performStop(r.mPreserveWindow, reason); | |
... | |
if (shouldSaveState && !isPreP) { | |
callActivityOnSaveInstanceState(r); | |
} | |
} |
A snippet from the ActivityThread.java file
The handleStopActivity call is made through ActivityThread, and there are two important places to look: performStopActivityInner and pendingActions.setStopInfo.
- performStopActivityInner — this is where onSaveInstanceState is called, which we know well and which we can override. By the way, pay attention, for this is the place to determine when it is called: before or after onStop(), depending on the Android version.
- pendingActions.setStopInfo — here we save the stopInfo object to pending actions. stopInfo is the object where we put everything we want to save. Among other things, stopInfo is a Runnable, and someone has to execute it.
Saving pendingActions happens later when ActivityThread calls reportStop, and already at that point we send StopInfo to be executed as a Runnable via Handler.
/** | |
* Schedule the call to tell the activity manager we have stopped. We don't do this | |
* immediately, because we want to have a chance for any other pending work (in particular | |
* memory trim requests) to complete before you tell the activity manager to proceed and allow | |
* us to go fully into the background. | |
*/ | |
@Override | |
public void reportStop(PendingTransactionActions pendingActions) { | |
mH.post(pendingActions.getStopInfo()); | |
} |
A snippet from the ActivityThread.java file
When saving StopInfo, it does the following:
public class PendingTransactionActions { | |
... | |
public static class StopInfo implements Runnable { | |
@Override | |
public void run() { | |
... | |
try { | |
... | |
// This is the call of the Binder transaction | |
// through the ActivityManagerService | |
ActivityClient.getInstance().activityStopped( | |
mActivity.token, mState, mPersistentState, mDescription); | |
} catch (RuntimeException ex) { | |
... | |
if (ex.getCause() instanceof TransactionTooLargeException | |
&& mActivity.packageInfo.getTargetSdkVersion() < Build.VERSION_CODES.N) { | |
Log.e(TAG, "App sent too much data in instance state, so it was ignored", ex); | |
return; | |
} | |
throw ex; | |
} | |
} | |
} | |
} |
A snippet from the PendingTransactionActions.java file
ActivityClient.getInstance().activityStopped is a Binder transaction call through the ActivityManagerService, similar to how we parsed running Activity before. The curious thing here is that for the version below N, TransactionTooLargeException exceptions were ignored (carefree times).
Let’s go back to our application. Let’s imagine that it doesn’t save anything in onSaveInstanceState. Then why are there buffer overflows?
We need to see what exactly is stored.
protected void onSaveInstanceState(@NonNull Bundle outState) { | |
outState.putBundle(WINDOW_HIERARCHY_TAG, mWindow.saveHierarchyState()); | |
// Pay attention to this! | |
// Saving the states of all fragments | |
Parcelable p = mFragments.saveAllState(); | |
if (p != null) { | |
outState.putParcelable(FRAGMENTS_TAG, p); | |
} | |
getAutofillClientController().onSaveInstanceState(outState); | |
dispatchActivitySaveInstanceState(outState); | |
} |
A snippet from the Activity.java file
All states of all fragments are saved. The mFragments object is a FragmentController, and in this case, it proxies the call to the FragmentManager. Let’s see what saveAllState is:
Parcelable saveAllState() { | |
... | |
int N = mActive.size(); | |
FragmentState[] active = new FragmentState[N]; | |
for (int i=0; i<N; i++) { | |
... | |
//saving all states in active array | |
... | |
} | |
... | |
FragmentManagerState fms = new FragmentManagerState(); | |
fms.mActive = active; | |
... | |
return fms; | |
} |
A snippet from the FragmentManager.java file
We go through all active fragments and save their states to the active array. And each state includes arguments.
final class FragmentState implements Parcelable { | |
... | |
many parameters | |
... | |
final Bundle mArguments; | |
... | |
} |
A snippet from the FragmentState.java file
So we had a Pager with fragments. We swiped the Pager, minimized the application, and all the arguments of all the fragments tried to save using the Binder transaction.
There’s a handy tool to track what’s going on in Binder transactions: toolargetool
We added it to see what was stored there. And we saw the following log:
D/TooLargeTool: NavHostFragment.onSaveInstanceState wrote: Bundle211562248 contains 7 keys and measures 510.5 KB when serialized as a Parcel * android:support:fragments = 508.7 KB * androidx.lifecycle.BundlableSavedStateRegistry.key = 0.1 KB * android-support-nav:fragment:defaultHost = 0.1 KB * android:view_registry_state = 0.2 KB * android-support-nav:fragment:graphId = 0.1 KB * android-support-nav:fragment:navController
And here we see that fragments occupied more than 500Kb.
Now we see why instead of passing large objects as arguments it’s highly recommended to go for the minimum necessary, usually ids. In that case, each fragment itself takes the data it needs.
That’s the answer to the question in the title of the article. Usually we, developers, know about this approach and confidently answer the question in interviews, while typically being far from knowing the real reason for why it happens.
After a quick refactoring we measured again how much memory these transactions occupy:
D/TooLargeTool: NavHostFragment.onSaveInstanceState wrote: Bundle78254359 contains 7 keys and measures 67.8 KB when serialized as a Parcel * android:support:fragments = 65.9 KB * androidx.lifecycle.BundlableSavedStateRegistry.key = 0.1 KB * android-support-nav:fragment:defaultHost = 0.1 KB * android:view_registry_state = 0.2 KB * android-support-nav:fragment:graphId = 0.1 KB * android-support-nav:fragment:navControllerState = 1.2 KB * android:view_state = 0.1 KB
The size has decreased to 67.8 KB.
To sum up
Sometimes when we are in a hurry, we create technical debt and then forget to pay it back in time.
This was the case with a rather trivial error in the Drinkit application. We were passing excessively large objects to the fragment arguments for displaying the menu. I wanted to share this experience because this bug is simple at a first glance, but if you dig a little, you’ll find that its roots go much deeper. Stumbling upon it can take you through the rabbit hole to the world of IPC interaction and Binder transactions.
Please share in the comments if you have ever faced TransactionTooLargeException crashes and what they were related to.
If you have enjoyed this article, subscribe and follow me on Twitter: https://twitter.com/makzimi
This article was originally published on proandroiddev.com on November 03, 2022