В этой статье я расскажу то, о чём не спрашивают на собесeдованиях и не рассказывают на курсах по Android-разработке — о неявной особенности Android, которая влияет на деградацию производительности и приводит к невоспроизводимым ANR в вашем приложении.
Поделюсь исследованием производительности SharedPreferences, расскажу об их работе в главном потоке и как это связано с потерями кадров при открытии новой Activity, а также детально рассмотрю одну из самых известных альтернатив Datastore.
Проблематика SharedPreferences
SharedPreferences — встроенный механизм для хранения простых данных в формате «ключ-значение» в Android. Подходит для хранения небольших объёмов информации: настройки приложения, флаги состояний и т.п. Однако у SharedPreferences (использую SP далее по тексту) есть одна большая проблема, которую можно описать несколькими строками из Firebase Crashlytics:
... java.lang.Object.wait (Object.java:543) android.app.SharedPreferencesImpl.awaitLoadedLocked (SharedPreferencesImpl.java:279) android.app.SharedPreferencesImpl.contains (SharedPreferencesImpl.java:353) ...
Здесь «говорится», что SP блокирует главный поток. С этой проблемой мы в Альфе и столкнулись, что побудило меня разобраться в причинах проблемы.
Возьмем операцию Commit для иллюстрации. Посмотрим, на код из метода commit исходников Android.
... try { mcr.writtenToDiskLatch.await(); } catch (InterruptedException e) { ...
После того, как Editor собрал все нужные для изменения данные, мы можем зафиксировать транзакцию и дождаться её выполнения. Как видите, метод блокирует главный поток, пока данные не будут сохранены.
Официальные рекомендации настаивают использовать apply() вместо commit(), но это не решает проблему полностью. Для подтверждения посмотрим, что происходит внутри apply метода.
А там вызывается QueuedWork.addFinisher(awaitCommit);. Однако awaitCommit, добавленнный в список sFinishers внутри класса QueuedWork вызывается только внутри waitToFinish , который в свою очередь выполняет задачи, сложенные ранее в sFinishers , через метод run , а следовательно в главном потоке (Thread.start vs Thread.run).
А теперь обратимся к методу waitToFinish. Данный метод является важной деталью, посколько показывает, а когда все-таки происходит отложенная задержка главного потока при использовании apply .
Как видно из исходников…

...метод apply хоть и отложено, но все-таки выполняет операции в главном потоке. Основные уязвимые места, когда неожиданно для пользователя и нас может возникнуть фриз:
При работе сервиса, когда сервис останавливается или получает новые аргументы.
В жизненных событиях активности таких как Pause и Stop.
При бекапе SP, которые работает при
allowBackup="true".
А теперь давайте посмотрим на графики производительности.
Производительность SP
Я взял Galaxy S23 FE и провел несколько тестов: нагрузка на GPU производилась через Burnout Benchmark, нагрузка на диск производилась через Disk Speed.
Вот такой график у меня получился при записи в SP через commit() раз в полсекунды при работе с пустым файлом SP:

Использовал в измерениях commit с той целью, чтобы понять общую продолжительности блокировки главного потока вне зависимости от того, будет ли она использована полностью в единицу времени, или распределена по callback жизненного цикла, что обеспечивает apply. Пока помимо первой загрузки SP мы даже не теряем кадр на 120Гц.

Однако при росте размера файла скорость работы с ним значительно уменьшается.
Пусть примеры и искусственные, однако они иллюстрируют проблематику. И ситуация будет только ухудшаться при росте размера файла.
В ходе моего исследования было обнаружено, что подключенные к Альфе библиотеки так же работают с SP, что может неявно влиять на ухудшение перформанса.
Если вы обнаружили у себя нечто похожее, рекомендую добавить логирование того, какие SP у вас в системе есть и какой размер данных файлов. Думаю мы скоро внедрим данную фичу, а если у вас есть результаты по вашему проекту, то буду рад почитать об этом в комментариях.
Метод apply благодаря своей особенности хоть и позволяет немного оптимизировать работу с SP в моменте, но в общем размазывает задержку по методам жизненного цикла и в конечном итоге блокиру��т главный поток. Таким образом, например, замедляются переходы между экранами, построенными на Activity, и это только один пример.
График производительности активной записи в SP при синтетической нагрузке на диск через стороннее приложение выглядит так:

При активной работе с диском и при пустом файле SP мы видим рост задержек, что говорит о том, что проблематика работы c SP будет только усугубляться боевыми условиями и слабыми разновидностями девайсов потенциальных пользователей. Пример хоть и лабораторный, но подтвердил лично мою уверенность в корреляции между нагрузкой на систему и скоростью работы SP.
Если работа и производительность SP нас не устраивают, мы можем рассмотреть другие средства:
Сохранять в ДБ данные, если идет активная работа с ними или данные в большом объёме.
Вынести хранение данных на сервер.
Писать самому безопасную обертку над файловой системой.
Использовать готовое решение — Jetpack Datastore — более современный и асинхронный подход.
В качестве альтернативы я изучал Datastore, потому что он позволяет работать с данными асинхронно через Flow, но самое важное — Datastore может работать в фоновом потоке. Пока мы в Альфе не внедрили данное решение, а моё исследование — это попытка найти все за и против внедрения новой технологии. Поэтому я начал с изучения того, как Datastore ведёт себя под нагрузкой
Datastore под нагрузкой
Помимо нагрузки на compile time проекта при использовании Proto, стоит учитывать накладные расходы производительности. Для наглядности рассмотрим пример достаточно активной работы с Datastore:

Вначале идет загрузка Datastore, что впрочем идентично работе SharedPreferences за тем отличием, что Datastore выполняет работу в фоне.
Если я сохраню активность работы, но повышу нагрузку на диск:

На графике выше показаны значения при нагрузке на диск устройства. Показатели возрастают в разы, однако накладные расходы с учетом возможной работы Datastore в фоновом потоке допустимы. Пример так же является лабораторным, с одной лишь целью — проследить зависимость между переменными.
Благодаря чему Datastore так себя ведёт?
Обзор Datastore
Datastore разделен на два решения: Proto Datastore и Preferences Datastore.
Proto Datastore обеспечивает типобезопасность и хранит схему вашего объекта, благодаря чему и достигается типобезопасность, а библиотека знает, как восстановить ранее сохраненный объект. Если вы внесете изменения в схему, вам потребуется пересобрать проект, только после этого Proto Datastore сможет сгенерировать новую схему для вашего класса.
Proto DataStore использует Protocol Buffers для сериализации, представляющий собой бинарный формат сериализации, который более компактен и эффективен, чем другие методы сериализации, такие как JSON.
Пример работы с Proto Datastore
syntax = "proto3"; message News { string title = 1; string content = 2; int64 timestamp = 3; }
val newsDatastore: DataStore<News> = context.createDataStore( fileName = "news.pb", serializer = NewsSerializer ) suspend fun saveNews(news: News) { newsDatastore.updateData { currentData -> currentData.toBuilder() .mergeFrom(news) .build() } }
Хранилище данных Preferences Datastore хранит данные в парах ключ-значение. Если у вас простые данные, которые можно хранить таким образом, то вам лучше подойдет Preferences Datastore.
Пример работы с Preferences Datastore
class DataStoreManager(val context: Context) { private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = NEWS_DATASTORE) companion object { val NAME = stringPreferencesKey("NAME") } suspend fun saveToDatastore(news: News) { context.dataStore.edit { it[NAME] = news.name } } suspend fun getFromDatastore() = context.dataStore.data.map { News( name = it[NAME] ?: "", ) } }
Затраты миграции на Datastore
Так как опыта миграции на Datastore у меня не было, я решил изучить интернет и документацию на предмет подходов и лучших практик. Далее опишу выводы, которые я сделал на основании изученного материала.
Если ваш проект содержит готовые абстракции над SP, повсеместно используемые, то миграция не займет много времени и потребует лишь адаптации базовых сущностей не затрагивая кода потребителей API работы с файловой системой. Это возможно, если в вашем проекте вся работа с SP закрыта интерфейсом, за которым какой-нибудь DI подставляет реальный зависимости по работе с хранилищем.
При отсутствии каких-либо абстракций вам придется либо внедрять Datastore точечно, заменяя каждое конкретное использование SP, что не является масштабируемым подходом. Либо придется немного усложнить архитектуру проекта, добавив абстракцию в ваш core модуль и поставляя во все фичи работу с Datastore через DI.
Отдельно стоит отметить возможную необходимость внедрения Proto Datastore с новым алгоритмом сериализации, новым языком Proto и дополнительным новым кодом, который надо будет написать для адаптации.
Для миграции в Datastore есть полный комплект инструментов:
Автоматическая миграция через produceMigrations и SharedPreferencesMigration (после миграции старые данные могут быть безвозвратно удалены после вызова метода cleanUp. Учитывайте это на случай желания все же их оставить).
Создать глобальный DataStore для общий зависимостей. И feature DataStore для локальных данных.
Больше не нужны
synchronized(lock)в обертках поверх SharedPreferences, потому что Datastore поддерживаем атомарность и потокобезопасность
Таким образом в небольшом проекте или в большом, но с хорошей абстракцией использование Preferences Datastore не вызовет особых трудностей, однако внедрение Proto или выстраивание архитектуры по работе с файловой системой с нуля может вылиться в значительные затраты и поставить под вопрос целесообразность рефакторинга в данный момент.
Я придерживаюсь подхода, что все изменения должны быть оправданы и обоснованы, а значит если у вас минимальное использование SP, или вы знаете всех пользователей и у них точно нет Android со слабой производительностью, то вы можете остаться на старом решении. Однако если у вас непредсказуемые девайсы пользователей, сложные экраны с куче запросов анимаций, и все это еще дополняется ANR, ссылающийся на SP в вашей системе логирования — это уже сильный аргумент к рефакторинга даже в долгосрочной перспективе, если в данный момент ресурсов разработки нет.
На основании изученной мной информации я могу сделать вывод, что время перехода на Datastore технически зависит прямо пропорционально от количества инструкций в коде по работе с SP плюс возможный рефакторинг абстракции проекта. Однако блокером перехода может оказаться не технические сложности, а тестирование. Ведь если будут затронуты классы десятилетней давности без ответственных, то их поиск может затянуть процесс внедрения.
На основании всего изученного материала я решил построить максимально полную таблицу, сравнивающую SP и Datastore, рассмотрев в ней все важные для меня детали.
Полное сравнение SharedPreferences и Datastore:
Критерии | Shared Preferences | Data Store |
В каком потоке работает | Главный поток | Любой поток |
Типы операции | Возможна синхронная или асинхронная (отложенная) запись и синхронное чтение | Операции асинхронные благодаря работе с Flow |
Обработка ошибок (CorruptionException) | Нет маханизма из коробки | Обработка ошибок из коробки у Flow. Так же есть возможность ложить |
Работа со сложными объектами | Через костыли, например, json, но без типобезопасности | Из коробки через Proto, но требует больше кода и нагружает compile time |
Шифрование | Есть | Нет из коробки, но есть feature request Можно переиспользовать реализацию из github Так же возможно задать кастомное автоматическое шифрование через |
Поддержка мультиплатформы | Нет | Да |
Транзакционность | Нет | Есть |
Поддержка безопасной работы с данными в разных процессах | Нет | Есть поддержка через MultiProcessDataStore |
Стоит ли переписывать проект с SharedPreferences на Datastore?
Главный вопрос, на который я искал ответ.
Несмотря на недостатки, я считаю возможным оставить SP в проекте, если он используется в нескольких местах приложения, не используется в Compose, лишние данные очищаются, нет работы с большими объектами, а так же проект сам по себе не перегружен другими операциями. Как только одно из условий нарушается, работа с SP может хоть и не испортить ситуацию самостоятельно, но сыграть роль накопителя нагрузки. Если вы видите у себя в проекте странные
ANR, связанные с SP, а так же ваш проект подходит под описание выше, то данные видимые проблемы становятся аргументами для переходу на Datastore.
Библиотека поддерживается больше 5 лет и судя по логам поддерживается приемлемо активно, что дает некую уверенность в принятии решения о переходе. Однако от себя добавлю, что даже такое красивое и поддерживаемое решение стоит заливать постепенно и с использованием Feature toggle.
