Многие люди спрашивают меня, почему я ненавижу SharedPreferences, а на моей работе некоторые даже спорят со мной, что SharedPreferences - это хорошая штука и что они вообще не приводят ни к каким проблемам. Но из моего шестилетнего опыта разработки и более чем 15 проектов я знаю, что SharedPreferences буквально являются причиной номер один ANR во многих популярных приложениях и сторонних фреймворках.
У вас всегда будут ANR из-за них, что бы вы ни делали (нет, edit не помогает!). И в этом посте я раскрою, почему вы должны убрать SharedPreferences из вашего проекта как можно скорее.
Проблема №1: SharedPreferences фундаментально ущербны, и вы не можете это исправить никаким своим кодом
Проблема не в том, как SharedPreferences реализованы внутри - проблема в самой парадигме синхронного доступа к данным на диске. Априори, это всегда будет приводить к проблемам, потому что по определению синхронного доступа вы не можете избежать чтения ресурса на том же потоке, на котором его вызвали. SharedPreferences создают фальшивый фасад асинхронности. Они дают вам API типа apply и commit, но под капотом они всё равно читают с диска на главном потоке.
Почему, спросите вы? Потому что иначе невозможно. Вы должны прочитать файл, а чтение файла занимает произвольное количество времени, иногда больше 5 секунд. (Я поговорю о том, почему, ниже). Итак, вы пытаетесь прочитать файл. Откуда вы возьмете эти 5 секунд? Если вы пытаетесь читать из SharedPreferences (или создавать их объект) прямо во время запуска приложения, или в composable-коде, или пока приложение инициализирует граф dependency injection, вы вызовете ANR. Если файловая система находится под нагрузкой и вам нужно подождать, то угадайте, какой поток будет ждать этого чтения файла? Конечно, главный поток, потому что вы вызываете конструктор/геттеры SharedPreferences на главном потоке.
Если вы хотите этого не делать, попробуйте избавиться от всех ссылок на SharedPreferences на главном потоке. Вы столкнетесь с огромной проблемой, потому что API SharedPreferences исходит из того, что вы можете, по какой-то причине, создавать и вызывать их безопасно на любом потоке, на котором работаете. И API SharedPreferences никак не показывает, что он будет выполнять синхронное создание файла и чтение на любом потоке, на котором вы попытаетесь получить к ним доступ. Это, очевидно, огромный недостаток дизайна, и этого никогда не должно было быть. С SharedPreferences слишком легко допустить эту глупую ошибку. Нет модификатора suspend ни на каких вызовах, нет нормального flow-based, coroutine-based API для наблюдения за изменениями, нет реактивного способа получить параметры, а их слушатели - недопиленное нечто из доисторических времен. Это не баг AOSP, а недостаток дизайна. И спойлер, чтобы вы не пытались вынести вызовы на фоновый поток: это не поможет.
Причина, по которой это так спроектировано, в том, что мы не знали лучшего в 2011 году. SharedPreferences были там с первой версии Android, и это было время, когда мы использовали Java. Нас не волновал главный поток, мы не знали, какие проблемы может вызвать чтение файлов на главном потоке. Никого не волновали ANR, потому что пользователям было всё равно, и они привыкли ко всем видам ошибок, и у нас не было корутин, которые позволяют нам писать параллельный код процедурно. Но теперь у нас есть все эти вещи, и у нас гораздо более высокие стандарты для наших приложений. Зачем нам всё ещё использовать SharedPreferences в наши дни?
Если хотите доказательств, вот исходный код буквально Android SDK:
@Override
@Nullable
public String getString(String key, @Nullable String defValue) {
synchronized (mLock) {
awaitLoadedLocked();
String v = (String)mMap.get(key);
return v != null ? v : defValue;
}
}
private void awaitLoadedLocked() {
if (!mLoaded) {
// Raise an explicit StrictMode onReadFromDisk for this
// thread, since the real read will be in a different
// thread and otherwise ignored by StrictMode.
BlockGuard.getThreadPolicy().onReadFromDisk();
}
while (!mLoaded) {
try {
mLock.wait();
} catch (InterruptedException unused) {
}
}
if (mThrowable != null) {
throw new IllegalStateException(mThrowable);
}
}Нарушение StrictMode там даже явное! Этот код ожидает полной “загрузки” SharedPreferences на любом потоке, с которого вы пытаетесь получить значение, если он ещё не успел полностью загрузить файл (awaitLoadedLocked).
Так что это означает, что когда вы радостно вызываете sharedPreferences.getBoolean("isDarkMode") в вашем composable-коде, вы по сути добавляете ANR и фриз в ваше приложение в ситуации нагрузки на файловую систему.
Единственная причина, по которой вы не столкнулись с этим фризом, в том, что вам повезло. Вы создали SharedPreferences достаточно рано и так далеко от ваших реальных чтений, что система успела прочитать из файла вовремя на вашем конкретном наборе устройств, или что ваше приложение не зависло ровно на пять секунд. Например, оно зависло на две с половиной секунды. Но если вы когда-нибудь задавались вопросом, откуда эти загадочные жалобы на фризы и лаги в ваших негативных отзывах в Play Store от пользователей на дешёвых устройствах, может быть, это то самое место?
Проблема №2: Хак QueuedWork, который прячет от вас ANR
Давайте поговорим о другом месте, где SharedPreferences вызывают ANR. Я понимаю, почему многие люди не думают, что SharedPreferences вызывают их ANR: это потому, что они настолько запутаны, что я тоже не верил в это сначала. Основная причина, по которой происходят ANR, в том, что когда вы вызываете apply на SharedPreferences, они дают вам фальшивое обещание асинхронной работы. Причина, по которой они это делают, в том, что авторы SharedPreferences хотели сделать эти операции быстрыми для главного потока, а затем перенести работу в какое-то другое место, где было бы “безопаснее” блокироваться. Из-за этого они создали так называемый класс QueuedWork:
/**
* Trigger queued work to be processed immediately. The queued work is processed on a separate
* thread asynchronous. While doing that run and process all finishers on this thread. The
* finishers can be implemented in a way to check weather the queued work is finished.
*
* Is called from the Activity base class's onPause(), after BroadcastReceiver's onReceive,
* after Service command handling, etc. (so async work is never lost)
*/
public static void waitToFinish() {
long startTime = System.currentTimeMillis();
boolean hadMessages = false;
synchronized (sLock) {
if (DEBUG) {
hadMessages = getHandler().hasMessages(QueuedWorkHandler.MSG_RUN);
}
handlerRemoveMessages(QueuedWorkHandler.MSG_RUN);
if (DEBUG && hadMessages) {
Log.d(LOG_TAG, "waiting");
}
// We should not delay any work as this might delay the finishers
sCanDelay = false;
}
// author's note - [1]
StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
try {
processPendingWork();
} finally {
StrictMode.setThreadPolicy(oldPolicy);
}
try {
while (true) {
Runnable finisher;
synchronized (sLock) {
finisher = sFinishers.poll();
}
if (finisher == null) {
break;
}
finisher.run();
}
} finally {
sCanDelay = true;
}
synchronized (sLock) {
long waitTime = System.currentTimeMillis() - startTime;
if (waitTime > 0 || hadMessages) {
mWaitTimes.add(Long.valueOf(waitTime).intValue());
mNumWaits++;
if (DEBUG || mNumWaits % 1024 == 0 || waitTime > MAX_WAIT_TIME_MILLIS) {
mWaitTimes.log(LOG_TAG, "waited: ");
}
}
}
}Посмотрите на [1]! Это буквально хак, который прячет от вас проблему записи на диск. Создатели SharedPreferences врут вам - они отключают StrictMode, который вы включили тяжёлым трудом, временно, просто потому что не хотят, чтобы вы видели, как они используют этот хак.
Этот класс конкретно создан для того, чтобы прятать асинхронные чтения файлов и коммитить их в определённых lifecycle-колбэках. Когда SharedPreferences выполняют commit или apply, вместо того чтобы записать в файл немедленно, они ставят работу в очередь, используя этот класс. Затем фреймворк Android вызывает конкретные методы этого класса, чтобы закоммитить или “финализировать” ожидающую работу (на главном потоке) на диск. В исходниках обоснование этого в том, что “работа никогда не теряется”. Но, как мы уже установили, это устаревшая и ложная предпосылка. Это было создано, чтобы дать потребителям ложное чувство безопасности, что их записи файлов никогда не будут потеряны, где-то в районе 2010-х или около того. Это ущербный подход, потому что вместо того чтобы правильно полагаться на потребителя, чтобы он реально завершил и дождался вызова, который должен закоммитить в файл, или использовать что-то вроде eager-записи файлов с write-ahead logging, или даже просто алгоритм copy-on-write, SharedPreferences просто откладывают негативные последствия задержки асинхронной работы на какой-то другой метод, где они предположили, что “будет безопасно заблокировать” главный поток, потому что юзер не заметит. Но это ЛОЖЬ, поскольку жизненный цикл приложения и активити значительно изменился за эти годы, и мы больше не используем методы жизненного цикла активити так же, как в старые времена.
Это также срывает ваши попытки отследить эти ANR, потому что стек-трейс теперь никогда не будет указывать на реальный вызов SharedPreferences, о чём я предупреждал в моём предыдущем посте про ANR.
Вот конкретные примеры стек-трейсов, которые вы получите в жизненном цикле приложения (AOSP ActivityThread.java):
- Activity pause (старые pre-HC приложения): после
performPauseActivity(...)вызываетсяQueuedWork.waitToFinish(). Ожидайте стеки типаActivityThread.handlePauseActivity → QueuedWork.waitToFinish. - Activity stop (современные приложения): после
performStopActivityInner(...)фреймворк запускаетQueuedWork.waitToFinish(). Ожидайте стеки типаActivityThread.handleStopActivity → QueuedWork.waitToFinish. - Sleeping:
handleSleepingтакже вызываетQueuedWork.waitToFinish(). - Service start args:
handleServiceArgsсливает работу из очереди перед сообщениемserviceDoneExecuting(...). Ожидайте стеки типаActivityThread.handleServiceArgs → QueuedWork.waitToFinish. - Service stop:
handleStopServiceтоже сливает. Ожидайте стеки типаActivityThread.handleStopService → QueuedWork.waitToFinish.
Бонус: commit() всё ещё может писать на вызывающем (UI) потоке
Для commit() (sync), enqueueDiskWrite(..., /*postWriteRunnable=*/ null) трактует это как синхронный коммит и может запустить writeToDiskRunnable.run() на текущем потоке (смотрите быстрый путь с isFromSyncCommit и wasEmpty). Если вы когда-нибудь вызываете commit() с главного потока, это немедленный I/O на UI-потоке.
”Но это всего 2 миллисекунды!”
Но вот последний момент, который обычно приводят люди, которые спорят со мной:
“О, но это всего 2-миллисекундное чтение. Это ничто. Это просто атомарная операция с файлом на главном потоке… Я видел нарушение StrictMode, и оно говорит, что длительность была 5 миллисекунд. Никто никогда этого не заметит! Мы не собираемся это исправлять, потому что это просто небольшая задержка. Ты просто раздуваешь из мухи слона!”
Как мы уже установили, первый момент в том, что apply на самом деле не асинхронный. Он просто ставит работу в очередь, но затем регистрирует финализатор, который будет запущен в различных lifecycle-колбэках. Так что если этот вызов когда-нибудь окажется медленным, то вы получите ANR в одном из этих колбэков или при старте приложения. Должно быть очевидно, что это может проявиться в негативных последствиях сейчас.
Второй момент в том, что реальный flush на диск - это блокирующий fsync/sync, и исходники даже Android отслеживают его tail latency. Путь записи заканчивается в FileUtils.sync(out) → out.getFD().sync(), т.е. блокирующим flush. SharedPreferencesImpl даже логирует/записывает длительность fsync, когда она превышает порог, который существует потому, что fsync может быть медленным.
Простыми словами, это означает, что на дешёвых устройствах, медленной памяти, внешней памяти типа SD-карт, и в различные моменты системного жизненного цикла, где есть высокие объёмы нагрузки (например, пользователь выполняет какие-то тяжёлые операции доступа к файлам, такие как перекодирование медиа или воспроизведение видео высокого качества), есть очень высокая вероятность, что системные вызовы, которые производят запись на диск, войдут в конкуренцию за ресурсы. Это не воспроизводится в стерильных средах QA-тестирования или debug-сборках, особенно если команда использует эмулятор или какое-то дорогое устройство, у которого достаточно пропускной способности, чтобы обработать все записи файлов. И особенно потому что данные, которые обычно хранятся в SharedPreferences на debug-сборках, гораздо меньше по размеру, чем они могут быть на продакшн-сборках. (Вы же не пишете неограниченные списки в один XML-файл, правда?)
Когда системный вызов ОС входит в конкуренцию и приостанавливает поток для выполнения записи/чтения и операции синхронизации с файловой системой, он может ждать неопределённое количество времени для завершения операций журналирования или для того, чтобы служба безопасности системы дала приложению доступ к его директориям данных или внешнему хранилищу, что может привести к тому, что в редких случаях операции файлов операционной системы будут занимать значительно больше времени, чем обычно. Это очень трудно воспроизвести, потому что вы обычно не запускаете стресс-тесты или нагрузочное тестирование вашего приложения на бюджетном Xiaomi за 100 долларов, правда? Но именно так работают реальные устройства. Например, если вы попытаетесь выполнить чтение/запись файла в broadcast BOOT_COMPLETED, как я упоминал в моём предыдущем посте, у вас есть наибольший шанс поймать это состояние конкуренции, потому что сотни других приложений также запускают broadcast завершения загрузки в то же время, и, как вы знаете, broadcasts выполняются на главном потоке. Так что если вы когда-нибудь задавались вопросом, почему ваши стек-трейсы broadcast появляются в отчётах ANR в Crashlytics, то вот ваш ответ.
Даже хотя прямая конкуренция файловой системы не такая большая проблема, как раньше, это улучшение сильно компенсируется введением scoped storage и новых политик SAF и FUSE на стороне Android, которые гораздо медленнее стандартных чтений. Так что когда устройство пользователя записывает гигабайты данных, и ваше приложение оказывается открытым, угадайте, кто будет расхлёбывать огромный flush журнала, вызванный fsync? Для скептиков, вот статья, которая измерила реальные задержки в файловых системах ext*.
И, ребята, я пишу этот пост не для того чтобы присвоить себе заслуги. Если вы думаете, что это какая-то новая информация, о которой никто раньше не знал, я честно просто немного расширяю официальную документацию Android developers, так что даже для тех, кто говорит “О, мы будем делать только то, что рекомендует Google, а не какой-то рандомный додик с интернета” - пожалуйста, не вопрос, слушайте Гугл, и реализуйте нормальную архитектуру I/O.
Фризы, которые не появляются в Crashlytics
Моя проблема при обсуждении этого в том, что я буду честен с вами, ребята, я не могу дать вам графики, показывающие страшные линии задержек I/O. Нет публичных данных - поверьте мне, я искал - для такого рода метрик, потому что это внутренняя аналитика Android, и это совершенно не в интересах Google раскрывать, насколько у них плохи операции чтения файлов. Я могу только указать вам на сотню различных стек-трейсов из моих собственных приложений и на обсуждения тысяч других людей, жалующихся на точно такую же проблему, но я не могу дать вам окончательного вывода о том, сколько именно пользователей сталкиваются с этой проблемой.
Если вы таргетируете более дешёвые устройства, которые всё ещё очень популярны в мире, вы обязательно будете иметь ANR из-за этого. Или что, по моему мнению, хуже, у вас, скорее всего, не будет настоящих ANR, которые репортятся в Crashlytics. То, от чего вы, скорее всего, страдаете - это значительные задержки и фризы в приложении, которые пользователи замечают и воспринимают, но они недостаточно длинные, чтобы вызвать реальный ANR. Это портит пользовательский опыт вашего приложения, но не всплывает ни в одном из сервисов Crashlytics или багрепортов, потому что это не так очевидно. Всё, что вы увидите, это какой-то странный пользователь, жалующийся на то, что приложение медленное или тормозит или постоянно зависает, и вы отмахнётесь от его отзыва как “исключения”. Но потом вы будете задаваться вопросом, почему ваши пользователи так от вас уходят - запомните этот твит.
Так что не относитесь к этому легкомысленно. Если вы верите в официальную документацию и реальные проблемы и это объяснение, попробуйте использовать асинхронный и реактивный подход. Это не так сложно, обещаю. Например, DataStore даёт вам безопасный suspending API с алгоритмом copy-on-write под капотом. Я знаю, что некоторым людям не нравится, говорят “это не так удобно использовать”, потому что он не даёт вам синхронный API для вызова на главном потоке. Но вы знаете, может быть, дизайн DataStore такой не просто так? И надо подумать, почему мы сталкиваемся с ограничениями в API, и соблюдать правила, вместо того чтобы впиливать самое простое решение.
Правильный асинхронный API для дисковых операций и сильная разработческая этика не только решат вашу проблему с ANR, но и научат вас работать с данными быстро и безопасно.