Android-разработчиков часто спрашивают на технических собеседованиях, как запускать фрагменты, как передавать туда данные, почему нельзя класть много в аргументы, а много — это сколько, а что может пойти не так и т.д. Мы в Dodo тоже иногда такие вопросы задаём. Я думал, что понимал всё это, но оказалось, что довольно поверхностно. Всё изменилось, когда я столкнулся с частыми крашами TransactionTooLargeException в приложении Дринкит.
TransactionTooLargeException — это исключение из области IPC-вызовов (interprocess communication) и Android Binder. Но его можно получить в безобидной ситуации, когда, казалось бы, мы ничего такого не делали и не пользовались IPC.
В этой статье предлагаю разобраться с этим крашем и поговорить про IPC-вызовы и Binder.
Причём тут IPC и Binder
Раньше, когда я слышал слова IPC и Binder, я понимал, что это межпроцессное сообщение, какие-то особые случаи, когда мы часть приложения выносим в отдельный процесс и начинаем с ним взаимодействовать. Например, биндить Activity из одного процесса к Service другого процесса и обмениваться данными. И эти слова (IPC и Binder) меня не касаются, пока я не начну делать что-то в этом духе. Но на самом деле это не так. Хорошо, если вы уже об этом знаете. А если у вас примерно такое же понимание, какое было у меня раньше, то предлагаю всё-таки разобраться, почему IPC и Binder с нами повсюду и каждый день.
Сначала давайте посмотрим, что пишут в официальной документации Android о TransactionTooLargeException. В статье Parcelables and Bundles кратко рассказывается о том, что в Binder-транзакциях данные передаются через Parcelables и Bundle, на каждый процесс есть буфер размером 1 Мб на все текущие исполняемые транзакции. Если в какой-то момент превысить 1 Мб, то мы получим TransactionTooLargeException.
Но всё равно непонятно, почему мы получаем TransactionTooLargeException, если у нас простое приложение, один процесс и мы не биндим свои активити к сервисам и не обмениваемся данными.
Чтобы разобраться, нужно немного углубиться в теорию.
Насколько часто используется Binder
Мы как разработчики не управляем компонентами Android-фреймворка (Activity, Service, Broadcast Reviever, Content Provider), ими управляет система. Как она это делает?
В качестве примера посмотрим, как работает одно из самых базовых действий в Android — запуск Activity.
Реализация знакомого всем метода startActivity
происходит в классах ContextImpl
и Instrumentation
.
//ContextImpl.java
@Override
public void startActivity(Intent intent, Bundle options) {
...
mMainThread.getInstrumentation().execStartActivity(
getOuterContext(), mMainThread.getApplicationThread(), null,
(Activity) null, intent, -1, options);
}
//Instrumentation.java
public ActivityResult execStartActivity(
Context who, IBinder contextThread, IBinder token, Activity target,
Intent intent, int requestCode, Bundle options) {
...
try {
...
// Вызываем 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;
}
С помощью класса Instrumentation
, который многим больше знаком по работе с инструментальными тестами, делается вызов startActivity
у объекта ActivityManagerNative.getDefault()
. А это не что иное, как ActivityManager
сервис. Мы его получаем через синглтон gDefault
.
//ActivityManagerNative.java
private static final Singleton<IActivityManager> gDefault = new Singleton<IActivityManager>() {
protected IActivityManager create() {
IBinder b = ServiceManager.getService("activity");
...
// то есть gDefault - это и есть 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();
// много всего записывается в data
...
// Вызываем transact биндера (mRemote - тут и есть IBinder)
mRemote.transact(START_ACTIVITY_TRANSACTION, data, reply, 0);
reply.readException();
int result = reply.readInt();
reply.recycle();
data.recycle();
return result;
}
...
}
В итоге делаем вызов transact
у объекта mRemote
, который, в свою очередь, и есть IBinder
.
Схематично это можно изобразить так:
Зелёными цветами я выделил всё, что происходит в нашем процессе. Синим — сам Binder как связующее звено. Activity Manager Service — это отдельный процесс, с которым общается наш процесс.
Получается, что открытие новой Activity — это Binder-транзакция.
А какие действия ещё являются Binder-транзакциями? Да много каких. Например, общение с сервисом через Messenger, работа с Content Provider и все системные сервисы: ActivityManagerService, WindowManagerService, AlarmManagerService, NotificationManagerService, ConnectivityManagerService и остальные (я просто перечислил те, с которыми каждый из нас сталкивался много раз). Это всё сервисы, которые мы можем получить через Context::getSystemService
.
Эти сервисы — отдельные процессы, и наше приложение может с ними взаимодействовать через Binder-транзакции.
Binder — что это конкретно?
Binder — это инструмент на платформе Android, который специально создали для простого и быстрого общения между процессами. В официальной документации есть классная картинка на этот счёт.
Binder находится на уровне Kernel Linux и всё взаимодействие происходит там. Если кратко, то Binder работает через транзакции, он эффективно упаковывает данные и передаёт их между процессами через ioctl. Как раз про эти транзакции и шла речь, когда мы говорили про ограничения буфера в 1 Мб.
Выходит, что мы используем Binder очень часто, но не напрямую. Обычно это скрыто от нас через API Android Framework. Значит, мы и получили наш TransactionTooLargeException от неявного использования Binder-транзакций.
Раскроем загадку TransactionTooLargeException
Попробуем повторить баг в приложении Дринкит. В нём главный экран — это меню кофейни. Меню — это Pager с фрагментами. Каждый фрагмент — это категория в меню с кучей своих элементов. Мы листаем меню туда-сюда, сворачиваем и… краш.
Краш возникает преимущественно в бэкграунде.
Дело в том, что при сворачивании происходит сохранение состояния Activity, и присходит оно через Binder-транзакции.
Давайте посмотрим внимательнее. Всё начинается при остановке Activity:
//ActivityThread.java
@Override
public void handleStopActivity(ActivityClientRecord r, int configChanges,
PendingTransactionActions pendingActions, boolean finalStateRequest, String reason) {
...
final StopInfo stopInfo = new StopInfo();
// Здесь начинается логика остановки активити
performStopActivityInner(r, stopInfo, true /* saveState */, finalStateRequest,
reason);
...
stopInfo.setActivity(r);
stopInfo.setState(r.state);
stopInfo.setPersistentState(r.persistentState);
// Здесь сохраняем собранные данные
pendingActions.setStopInfo(stopInfo);
mSomeActivitiesChanged = true;
}
private void performStopActivityInner(...) {
...
callActivityOnStop(r, saveState, reason);
}
private void callActivityOnStop(ActivityClientRecord r, boolean saveState, String reason) {
...
// вызываем onSaveInstanceState в зависимости от версии API (isPreP)
if (shouldSaveState && isPreP) {
callActivityOnSaveInstanceState(r);
}
...
r.activity.performStop(r.mPreserveWindow, reason);
...
if (shouldSaveState && !isPreP) {
callActivityOnSaveInstanceState(r);
}
}
Через ActivityThread
происходит вызов handleStopActivity
и там важно смотреть на два места: performStopActivityInner
и pendingActions.setStopInfo
.
performStopActivityInner
— здесь происходит вызовonSaveInstanceState
, который нам хорошо знаком и который мы можем переопределить. Кстати, обратите внимание, именно здесь определяется, когда он вызовется: до или послеonStop()
, в зависимости от версии Android.pendingActions.setStopInfo
— здесь сохраняем в отложенные действия объектstopInfo
.stopInfo
— это объект, куда мы насобирали всё, что хотим сохранить. Помимо прочего,StopInfo
— этоRunnable
, и кто-то его должен выполнить.
Сохранение pendingActions
происходит позже, когда ActivityThread
вызовет reportStop
, и уже там мы отправим выполняться StopInfo
как Runnable
через Handler
.
// ActivityThread.java
/**
* 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());
}
При сохранении StopInfo
выполняет следующее:
// PendingTransactionActions.java
public class PendingTransactionActions {
...
public static class StopInfo implements Runnable {
@Override
public void run() {
...
try {
...
// Это и есть вызов Binder транзакции
// через сервис 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;
}
}
}
}
ActivityClient.getInstance().activityStopped
— это вызов Binder
транзакции через сервис ActivityManagerService
, аналогичный запуску Activity. Здесь есть любопытный момент, что для версии ниже N исключения TransactionTooLargeException
игнорировались (эх, беззаботная была пора).
Вернёмся к нашему приложению. Представим, что в нём ничего не сохраняется в onSaveInstanceState
. Тогда откуда там переполнение буфера?
Нужно смотреть, что именно сохраняется.
//Activity.java
protected void onSaveInstanceState(@NonNull Bundle outState) {
outState.putBundle(WINDOW_HIERARCHY_TAG, mWindow.saveHierarchyState());
// Обратим внимание на это - сохранение состояний всех фрагментов
Parcelable p = mFragments.saveAllState();
if (p != null) {
outState.putParcelable(FRAGMENTS_TAG, p);
}
getAutofillClientController().onSaveInstanceState(outState);
dispatchActivitySaveInstanceState(outState);
}
Сохраняются все состояния всех фрагментов. Объект mFragments
— это FragmentController
, и в данном случае проксирует вызов на FragmentManager
. Посмотрим, что такое saveAllState
:
//FragmentManager.java
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;
}
Мы проходимся по всем активным фрагментам и сохраняем их состояние в массив состояний active
. А каждое состояние, в свою очередь, включает в себя аргументы.
//FragmentState.java
final class FragmentState implements Parcelable {
...
many parameters
...
final Bundle mArguments;
...
}
Вот и получается, что у нас был Pager с фрагментами. Мы листали Pager, сворачивали приложение, и все аргументы всех фрагментов пытались сохраниться, используя Binder-транзакию.
Есть удобный инструмент, чтобы трекать то, что в Binder-транзакциях происходит:
Мы подключили его, чтобы понять, что там сохраняется. И увидели такой лог:
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
И здесь видим, что 500Кб съедают только фрагменты.
Теперь понятно, почему нужно передавать не большие объекты в аргументы, а только минимально необходимое, обычно id-шники. И дальше каждый фрагмент сам берёт данные, которые ему нужны.
Вот и ответ на вопрос в заголовке статьи. Обычно мы, разработчики, знаем про этот подход, уверенно отвечаем на вопрос на собеседованиях, но далеко не всегда знаем настоящую причину, почему так происходит.
После быстрого рефакторинга мы замерили ещё раз, сколько памяти занимают данные транзакции:
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
Размер уменьшился до 67.8 KB.
Заключение
Иногда, когда мы торопимся, создаём себе техдолг, забываем его потом вовремя отдать.
Так у нас было в приложении Дринкит с довольно банальной ошибкой. Для отображения меню мы передавали слишком большие объекты в аргументы фрагментов. Я захотел поделиться этим опытом, потому что, с одной стороны, этот баг относится к простым, но с другой стороны, имеет более глубокие и интересные в изучении корни. Наткнувшись на него, можно погрузиться в мир IPC-взаимодействия и Binder транзакций.
Поделитесь в комментариях, случались ли у вас внезапные TransactionTooLargeException краши и с чем они были связаны?
Если понравилась статья и хочешь больше узнать о разработке приложений Dodo Brands, подписывайся на наш канал Dodo Mobile.