(Не)стандартные способы передачи больших данных в Intent
Askhar AydarovИнтро
Думаю не секрет, что запуская Activity или Service через Intent нам не следует передавать много данных. Если попытаемся и превысим некоторый лимит на размер данных, который обычно от 800Кб до 1000Кб, то упадем с ошибкой TransactionTooLargeException.
Эта ошибка тянется из Binder - механизма межпроцессного взаимодействия -, c которым наше приложение и система взаимодействуют постоянно с помощью Binder-транзакций. Именно у этих транзакции есть ограничение на размер, о чем говорится в ошибке TransactionTooLargeException. Как думаете почему?
Рассказ про устройство этого всего займет немало времени и не является основной целью этого поста. Поэтому перед продолжением рекомендую ознакомиться со следующими материалами.
- Про ошибку: Откуда возникает TransactionTooLargeException, если я ничего такого не делал?
- Про
Binder: Binder - как устроена работа с несколькими процессами в Android
Но когда нам все таки нужно передать большие данные у нас есть разные варианты для это. И в зависимости от того, куда передаются данные, у нас есть несколько вариантов. Если данные передаются в рамках одного нашего процесса приложения, то все довольно просто, потому что уже есть различные библиотеки для этого и в рамках приложения нам ничего не стоит реализовать какие-нибудь кэши для временных файлов. Интересное начинается, когда у нашего приложения несколько процессов или нам нужно реализовать передачу данных в другое приложение. А как известно, каждое приложение запускается в своем процессе, и в каждом приложении мы можем дополнительно определять процессы под компоненты. В такой ситуации есть несколько вариантов:
ContentProvider(FileProvider)- IPC Service
SharedMemoryFileDescriptor, который прокидывается черезContentProvider, IPC Service или создается по uri к файлу.
Да, варианты есть, но их надо реализовать. И единственное, что мы можем отправить в Intent это uri к файлу.
Меня такой расклад устраивал, как и многих, думаю. Пока, изучая однажды исходники AOSP, не наткнулся на класс ParceledListSlice.
Слайсы

Довольно интересное описание, а еще он реализует интерфейс Parcelable и его можно прокинуть через Bundle. Поэтому решил попробовать и создал большой список с данными размером около 4Мб. Сначала прокинул в Intent этот список при запуске активности и ожидаемо получил крэш. Ну а потом захотел попробовать через слайсы, но доступа к этому классу из Android Studio нету...
Не беда. Такие штуки обходятся довольно просто с учетом того, что класс не находится в restricted api списке и является публичным. Для этого
- Копирую классы в виде стабов в отдельный модуль
- Подключаю этот модуль как
compileOnly


В классах я отставил только самое нужное: конструкторы и метод для чтения списка. Главное, чтобы в рантайме на устройстве эти классы были.
В итоге прокинул большой список, обернув в слайсы, но вот прочитать, к сожалению, не вышло.

Доступ к методу для чтения списка ограничен. И обойти это не так уж просто.
Я полез изучать код ParceledListSlice, чтобы получше понять как он работает, а там оказалось все просто.
Начнем с записи. Если убрать всю мишуру, то все сходится к тому, что:
- записывается размер списка
- создается и записывается объект
Binder - В
onTransactвозвращаются данные, размер которых ограничиваетсяMAX_IPC_SIZEи которые складываются вParcelс названиемreply - Если размер
Parcelуже большой, то запись останавливается и начинается ожидание следующей транзакции

Чтение еще проще:
- читаем размер списка
- читаем данные пока можем и пока они есть в
Parcel - если
Parcelизрасходовал себя, то создаем новый и начинаем новую транзакцию для продолжения чтения

Это, наверное, самая простая реализация IPC взаимодействия, что я видел. По простоте с ним может посоревноваться ResultReceiver, но там есть AIDL, а тут просто наследование от класса Binder. Я о таком не догадался бы. Что интересно, в документации к Binder так не советуют делать, но все таки на данный момент так сделать можно и очень много кода в таком духе написано в AOSP.
Кроме всего прочего, интересно то, что мы можем этот Binder записать через writeStrongBinder и так же считать, используя readStrongBinder. И об этом тоже можно было догадаться, если почаще смотрел бы в код, который генерируется по AIDL.
Учитывая эту простоту, довольно несложно было просто взять и скопировать эти классы к себе в проект (не знаю, почему я так изначально не сделал). Для удобства использования еще написал свою обертку над всем этим. Обертка позволяет разбить большой объект в chunks какого-то размера (по умолчанию 20Кб) в виде списка байтов, а потом клиент сможет их собрать и десериализовать.

Ура, такой вариант наконец сработал, но он был очень медленный. Иногда даже ловил ANR. Но если делать в отдельном потоке, то может быть и норм. И все же начал копаться дальше и обнаружил в Parcel методы для записи и чтения FileDescriptor.
Дескрипторы
readFileDescriptor и writeFileDescriptor, соотвественно, которые возвращают PacelFileDescriptor. В отличие от простого FileDescriptor, этот является Parcelable и его обычно используют в IPC, чтобы передать возможность для чтения какого-либо файла.
Кроме этого, мы можем создать трубу из дескрипторов, через один будем записывать данные, а через другой читать. Создается труба через ParcelFileDescriptor.createPipe.
В итоге, новая идея заключалась в том, чтобы создать такую трубу, один конец отправить в другой процесс через Parcel, а в другой записывать данные. Возможность отправки дескриптора в другой процесс подтверждается тем, что такой код уже используется в IPC для различных задач.
Не стал ничего выдумать дальше и отправил дескриптор на чтение через Intent, положив в Bundle, и получил незамедлительный удар по рукам, что так делать нельзя.

До этой статьи я наткнулся чуть позже, но тут автор так же пытается передать большие данные и ровно так же не смог отправить дескриптор через Intent. Отправил он в итоге дескриптор через свою реализацию сервиса для межпроцессного взаимодействия. Это был всего лишь эксперимент. Ведь есть еще один вариант: написать свой аналог ParceledListSlice, который будет прокидывать не список с данными, а дескриптор. Написал, снова сделал свои удобные обертки. Получилось следующее.

Похожим образом работает TransferPipe, но там все restricted api.
Такой вариант работал намного быстрее, но, к сожалению, без боли не обошлось. Дело в том, что запись блокируется до тех пор, пока нет читателя, а чтение блокируется пока нету записывающего. А до момента или в момент, когда записывающий и читающий сойдутся, может произойти, что угодно. И я не раз ловил в данной реализации крэши. Обычно это были крэши, что один из процессов умер, но некоторые уходили в Linux Kernel. Например, один из крэшей.

Да, в угоду быстроте была потеряна стабильность, но в поисках варианта получше начал смотреть, кто как использует ParcelFileDescriptor в боевых примерах, и наткнулся на довольно интересное взаимодействие с использованием SharedMemory.
SharedMemory
В Android у нас есть возможность создать общую память между процессами, и маппинг адресов в таком участке памяти будет возлагаться на ядро. Это действительно довольно быстрый вариант обмена данными.
В реализациях, которые я видел, его использовали как буффер. В общую память записывали большие данные из файла, пока не найдется потенциальный читатель, а потом читатель мог без труда и очень быстро прочитать файл. Я думаю это довольно хороший вариант для решения моей задачи, так как для создания такой области в памяти у меня даже есть размер отправляемых данных.
По SharedMemory очень рекомендую: "Shared Memory" : the fastest way to share data between Android applications.
В Android из Java кода общую память можно создать через MemoryFile, который является простой оберткой над SharedMemory. Отличия минимальные. Разве что в обертке можно включить автоматическую подчистку данных, если памяти не хватает в процессе, но это уже deprecated.
Создав такой файл и записав туда все данные, с небольшой щепоткой рефлексии можно вытащить дескриптор (есть еще один вариант, но он на будущее, если простой вариант с рефлексией прикроют).

Тут я нового ничего не писал, а использовал обертки для дескрипторов, написанные для реализации трубы, и получилось так.

Этот вариант работает быстрее предыдущего и довольно стабильный.
Итог
И на этом я пока остановился. Мне кажется, что получилось довольно познавательно с практической точки зрения. Ведь одно дело, когда мы просто читаем про то, что творится под капотом разных инструментов, чтобы отвечать на каверзные вопросы на собесах, а другое когда мы можем подчерпнуть из этих знаний кое-что интересное для себя, а еще лучше, если это удается применить.
Демо-приложения для тестов и библиотеку со всеми обертками опубликовал на в GitHub. Используйте на свой страх и риск :)
Опубликовано в Полуночные Зарисовки