Выбор зависит от формы данных: DataStore подходит для небольших настроек, Room для структурированных данных с запросами, приватные файлы для бинарных данных и документов, MediaStore или системный пикер для файлов, видимых пользователю, а Keystore для ключей, которыми шифруется остальное.
DataStore (Preferences или Proto): флаги и настройки. Чтение идёт через Flow, запись выполняется suspend-транзакциями. Современная замена SharedPreferences.
SharedPreferences: устаревшее key-value хранилище в XML-файле, которое всё ещё есть в большинстве проектов. Синхронное первое чтение и никаких сообщений об ошибках; переносится через SharedPreferencesMigration.
Room: SQLite с проверкой запросов при компиляции, результатами в виде Flow и миграциями. Всё, где есть строки, связи, поиск или пагинация.
Приватные файлы приложения: filesDir для данных, которые нужно сохранить, cacheDir для данных, которые можно получить заново.
MediaStore и Storage Access Framework: файлы, которые должны появиться в галерее или файловом менеджере.
Android Keystore: это не хранилище данных: в нём лежат ключи, которые не покидают защищённое железо, и ими шифруют токены, хранящиеся в одном из хранилищ выше.
Два правила, которые стоит проговорить: растущий список никогда не кладут в key-value хранилище, потому что каждая запись переписывает весь файл; токен нигде не хранят открытым текстом.
AndroidMiddle
Хранилище Android
Room, DataStore, SharedPreferences, EncryptedSharedPreferences, файлы
Закрыто вопросов
0 / 24
- 0Повторить
- 6Часто спрашивают
Вопросы
filesDir, cacheDir и noBackupFilesDir относятся к приватному внутреннему хранилищу; getExternalFilesDir() принадлежит приложению, но лежит на общем томе. Они различаются тем, может ли система их удалить и копирует ли их Auto Backup.
filesDir живёт до удаления приложения и бэкапится; рядом лежат базы данных и SharedPreferences.
cacheDir: система или пользователь могут его очистить; не бэкапится. Только восстановимые данные, например миниатюры.
noBackupFilesDir сохраняется, но никогда не бэкапится. Состояние, привязанное к устройству: файлы, зашифрованные ключом Keystore, маркеры установки.
getExternalFilesDir() и externalCacheDir: без разрешений, удаляются вместе с приложением, директория файлов бэкапится. Читается по USB, поэтому секретам там не место.
«Внутреннее» означает песочницу приложения, «внешнее» обозначает общий том, а для всего, что пользователь должен видеть, сегодня это MediaStore или системный пикер.
commit() синхронно пишет на диск в вызывающем потоке и возвращает true или false; apply() сразу обновляет map в памяти, пишет на диск в фоне и ничего не сообщает. Внутри одного процесса оба потокобезопасны.
**Подвох apply()**: отложенные записи встают в очередь QueuedWork, а главный поток ждёт её при остановке Activity или Service. Пачка вызовов apply() всё равно может привести к ANR.
**commit() на главном потоке: запись на диск в UI-потоке: пропущенные кадры и нарушение StrictMode.
Перезапись всего файла и нет поддержки нескольких процессов**: любое изменение переписывает весь XML-файл, а каждый процесс держит свою копию.
Новый код пишут на DataStore: он асинхронный, транзакционный и сообщает об ошибках. SharedPreferencesMigration один раз переносит существующие значения.
Room представляет собой слой Jetpack поверх SQLite: таблицы описываются через @Entity, запросы через @Dao, база через @Database, а Room генерирует код и проверяет SQL при компиляции.
**@Entity**: класс, отображаемый в таблицу; свойства становятся колонками, @PrimaryKey обязателен.
**@Dao**: интерфейс с запросами: @Query с SQL, а также @Insert, @Update, @Delete и @Upsert.
**@Database**: абстрактный RoomDatabase, который перечисляет сущности и version схемы и отдаёт DAO. Создаётся один раз через Room.databaseBuilder и живёт как синглтон.
По сравнению с SQLiteOpenHelper и Cursor: не нужен ручной маппинг, неверное имя колонки ломает сборку, а не приложение у пользователя, методы DAO могут быть suspend или возвращать Flow, который переэмитится при изменениях, а миграции структурированы и тестируемы. Room намеренно не полноценная ORM: SQL вы пишете сами и ленивой загрузки связанных объектов нет, поэтому каждый запрос на виду. По сравнению с Realm: Room работает на обычном SQLite со стандартными инструментами, а MongoDB в 2024 году объявила device SDK Realm устаревшими. Компилятор Room работает через KSP.
getAll() делает одноразовое чтение: он выполняется один раз в init, и больше никто не спрашивает, поэтому вставка попадает в базу, но не на экран. Верните из DAO Flow<List<TaskEntity>>, и Room будет перезапускать запрос после каждой записи в task.
Одноразовый vs наблюдаемый запрос: suspend fun getAll(): List<T> отвечает один раз; fun observeAll(): Flow<List<T>> (не suspend) продолжает эмитить. Возвращаемые типы LiveData и PagingSource используют тот же механизм инвалидации.
Отдавайте как состояние: stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()); тогда add() только вставляет.
Не латайте ручным обновлением: повторный getAll() после каждой вставки работает, пока другой писатель, например воркер синхронизации, не забудет его вызвать.
Один экземпляр базы: Flow видит только записи, сделанные через тот же экземпляр Room. Второй databaseBuilder над тем же файлом считается багом; для второго процесса нужен enableMultiInstanceInvalidation().
Аналог для SharedPreferences: обернуть OnSharedPreferenceChangeListener в callbackFlow; у DataStore data уже является Flow.
DataStore исправляет то, что сломано в SharedPreferences: чтение идёт через Flow, запись выполняется атомарными suspend-транзакциями, а ошибки приходят исключениями, а не теряются. Preferences DataStore даёт key-value без схемы; Proto DataStore хранит один типизированный объект.
Что не так с SharedPreferences: синхронные геттеры, которые могут блокировать UI-поток, apply(), который глотает ошибки и может вызвать ANR при переходах жизненного цикла, нет транзакции read-modify-write и нет потока изменений без слушателя.
Preferences DataStore: типизированные ключи (stringPreferencesKey) поверх нетипизированного файла, поэтому опечатка в имени ключа молча возвращает значение по умолчанию. Самый простой переход с SharedPreferences через SharedPreferencesMigration.
Proto DataStore: один объект со схемой: protobuf-сообщение или любой тип с Serializer. Подходит @Serializable data class с kotlinx.serialization, без protoc. Типизированные значения по умолчанию и безопасная эволюция; лучше всего для настроек, которые связаны между собой.
Общие правила: ровно один экземпляр DataStore на файл в процессе (второй бросит исключение), edit {} и updateData {} выполняют атомарный read-modify-write, и каждая запись заменяет весь файл через временный файл и переименование.
Это не база данных: нет запросов и частичных обновлений; растущий список должен жить в Room.
runBlocking заставляет главный поток ждать чтения с диска и парсинга во время старта. Это ровно та блокировка, которую DataStore должен был убрать, и источник подвисаний или ANR при запуске. Считайте тему состоянием, которое пока неизвестно, и держите сплэш-экран, пока оно не придёт.
Второй баг: значение читается один раз. Тема, изменённая позже в настройках, применится только при следующем запуске; data нужно собирать как Flow.
Исправление: ViewModel отдаёт StateFlow<String?>, который стартует с null, installSplashScreen().setKeepOnScreenCondition { ... } держит первый кадр, пока значение не станет не-null, а UI собирает его через collectAsStateWithLifecycle().
Обрабатывайте ошибки во Flow: catch { if (it is IOException) emit(emptyPreferences()) else throw it }; иначе один нечитаемый файл роняет всех коллекторов.
**Когда first() уместен**: в корутине вне главного потока, например в Worker, которому текущее значение нужно один раз.
Каждый SettingsRepository создаёт свой DataStore поверх settings.preferences_pb. Первый экземпляр никогда не закрывается, поэтому, когда экземпляр следующей ViewModel читает тот же файл, DataStore бросает IllegalStateException при первом чтении или записи (не при создании): «There are multiple DataStores active for the same file».
Почему первый экземпляр остаётся жив: PreferenceDataStoreFactory.create по умолчанию работает в собственном CoroutineScope(Dispatchers.IO + SupervisorJob()). Очистка ViewModel убирает ссылку, но этот скоуп никто не отменяет, и файл остаётся помеченным как активный до конца жизни процесса.
Почему DataStore отказывается: каждый экземпляр кеширует файл в памяти и упорядочивает только свои записи. Два экземпляра над одним файлом затирали бы изменения друг друга и отдавали устаревшие значения, поэтому проверка превращает тихую потерю данных в падение.
Как работает исправление: один экземпляр на файл на процесс: top-level делегат preferencesDataStore кеширует его и использует applicationContext. С Hilt репозитории вместо этого получают DataStore<Preferences> из @Singleton-провайдера.
Несколько процессов: синглтон живёт в рамках процесса; файлу, общему с процессом :sync, нужен MultiProcessDataStoreFactory.
Как поймать в следующий раз: создавать DataStore только в DI-модуле или top-level делегате и на ревью отмечать PreferenceDataStoreFactory.create в любом другом месте; UI-тест, который открывает экран дважды, воспроизводит падение.
Запрос выполняет дисковый I/O на десятки миллисекунд, поэтому для блокирующих вызовов DAO Room бросает IllegalStateException: Cannot access database on the main thread. suspend- и Flow-методы DAO безопасно вызывать с главного потока: Room сам переносит работу на свои executor'ы.
Блокирующие методы DAO: не-suspend функция, возвращающая List<T>, выполняется в потоке вызывающего, и именно от этого защищает проверка. allowMainThreadQueries() отключает её: допустимо в тестах, никогда в продакшене.
**suspend-методы**: Room выполняет их на своём query- или transaction-executor'е и затем возобновляет вашу корутину, поэтому оборачивать их в withContext(Dispatchers.IO) избыточно.
**Flow-запросы**: не suspend; запрос выполняется на executor'е Room при сборе и снова после каждой инвалидации, поэтому flowOn(Dispatchers.IO) ничего не добавляет.
**withTransaction { }: берёт один поток из transaction-executor'а и привязывает к нему блок, поэтому suspend-вызовы DAO внутри попадают в ту же транзакцию. Коммит происходит при выходе из блока, а откат выполняется при исключении или отмене.
Классическое зависание**: runBlocking внутри withTransaction блокирует поток транзакции, который нужен вложенному вызову DAO, а вызов DAO из корутины, запущенной вне блока (в другом scope), ждёт блокировку записи, которую держит транзакция. Внутри блока вызывайте функции DAO напрямую, а сетевые запросы делайте до него.
SQLite хранит только целые числа, вещественные, текст и блобы, поэтому для других типов, например Instant или списка, пишется пара @TypeConverter, которая переводит значение в поддерживаемый тип и обратно, и регистрируется через @TypeConverters.
Две функции: по одной на направление, в классе или object. Если конвертеру нужна зависимость, используйте @ProvidedTypeConverter и addTypeConverter(...).
Область действия: регистрация на @Database покрывает всё, а регистрация на сущности, DAO или поле сужает область.
Enum: начиная с Room 2.3 Room сам хранит enum по name без конвертера. Если пишете свой, никогда не храните ordinal: перестановка констант молча переназначит все существующие строки.
Главное ограничение: сконвертированная колонка непрозрачна для SQL. Нельзя фильтровать, сортировать или индексировать по элементу списка, сериализованного в JSON, поэтому всё, по чему нужны запросы, должно быть отдельной колонкой, @Embedded-классом или дочерней таблицей.
Стоимость: конвертер выполняется для каждой прочитанной строки, поэтому большой объект в одной колонке целиком парсится при каждом запросе.
Форма описывается через @Embedded для родителя и @Relation для детей, а Room выполняет по запросу на каждую сторону и склеивает результаты в памяти. Раз запросов несколько, методу DAO нужен @Transaction, иначе запись между ними может дать граф, которого никогда не существовало.
Один-ко-многим: обычный data class (не @Entity) с @Embedded val author и @Relation(parentColumn = "id", entityColumn = "authorId") val books: List<BookEntity>.
Многие-ко-многим: связующая сущность с составным первичным ключом, на которую ссылаются через associateBy = Junction(...).
Целостность: @ForeignKey(onDelete = CASCADE) не даёт копиться сиротам, а дочерней колонке нужен @Index, иначе каждый поиск по родителю сканирует таблицу; Room предупреждает об обоих случаях.
Загружается всё: связь подтягивает всех детей. Автор с десятью тысячами книг даёт десять тысяч строк, поэтому большим спискам детей нужна пагинация или отдельный запрос.
По связи нельзя фильтровать: дочерний запрос генерирует Room. «Авторы хотя бы с одной непрочитанной книгой» означает JOIN, написанный вручную, или @Query, возвращающий multimap вроде Map<AuthorEntity, List<BookEntity>>.
**@Transaction не только для связей**: он нужен любому методу DAO, выполняющему несколько выражений, чтобы частичная запись не пережила сбой.
REPLACE удаляет конфликтующую строку и вставляет новую; это не обновление. Поэтому срабатывают каскадные удаления, дочерние строки исчезают, а колонки, которые вы не задали, возвращаются к значениям по умолчанию. @Upsert пытается вставить и при конфликте обновляет существующую строку на месте.
**ABORT: по умолчанию. Вставка бросает исключение нарушения ограничения, и транзакция откатывается.
IGNORE**: конфликтующая строка молча пропускается, а @Insert возвращает для неё -1. Источник ситуации «синхронизация прошла, но ничего не изменилось».
**REPLACE**: DELETE, затем INSERT: дети с ON DELETE CASCADE уничтожаются, ключ с autoGenerate получает новое значение, а все колонки, которых нет в новом объекте, сбрасываются.
**@Update: меняет существующую строку по первичному ключу и ничего не делает, если строки нет.
@Upsert (Room 2.5+) вставляет или обновляет на месте: ничего не удаляется, дети остаются.
Возвращаемые значения**: @Insert возвращает новый rowid (или -1 для каждой пропущенной строки), @Update и @Delete возвращают число затронутых строк, что позволяет отличить «создано» от «уже было» без второго запроса.
Для синхронизируемых данных сделайте серверный id первичным ключом (или найдите локальный id по remoteId перед upsert): @Upsert разрешает конфликт только по первичному ключу, а уникальный индекс на другой колонке даёт ошибку вставки, и последующее обновление по id ничего не находит. Локальные колонки вроде флага «избранное» требуют явного UPDATE серверных полей, потому что upsert нового объекта перезапишет и их.
Room хранит в файле базы version и хеш схемы. При открытии он прогоняет миграции до текущей версии и сравнивает хеш со скомпилированной схемой; это падение значит, что они расходятся: схему изменили без повышения версии или миграция создала немного другую таблицу.
Любое изменение схемы: повысить version и дать Room путь: Migration(from, to) с SQL или @AutoMigration(from, to), которая сравнивает экспортированные схемы.
Другое падение: «A migration from 4 to 5 was required but not found» означает, что версию повысили, а путь не дали.
Экспорт схем: exportSchema = true плюс room { schemaDirectory("$projectDir/schemas") } из Gradle-плагина Room; JSON коммитится. Без этих файлов не работают ни автомиграции, ни тесты миграций.
Неоднозначные изменения: переименованная или удалённая колонка ломает сборку, пока автомиграции не дадут spec с @RenameColumn или @DeleteColumn.
Ручной SQL: новой колонке NOT NULL нужен DEFAULT; смена типа или ограничения означает: создать новую таблицу, скопировать строки, удалить старую, переименовать. Каждая колонка, значение по умолчанию и индекс должны точно совпадать с сущностью.
**fallbackToDestructiveMigration(dropAllTables = true): удаляет данные пользователя. Только для чистого кеша.
Никогда не правьте выпущенную миграцию**: пользователь, пропустивший релизы, пройдёт 3 → 4 → 5, поэтому каждое звено должно работать всегда; цепочку проверяют через MigrationTestHelper.
DAO тестируют на настоящей in-memory базе, а не на моке, потому что ценность Room заключается в SQL, который он генерирует. Миграцию тестируют через MigrationTestHelper: он строит старую схему по экспортированному JSON, прогоняет вашу миграцию и проверяет результат.
Тесты DAO
Room.inMemoryDatabaseBuilder(...) даёт свежую базу на каждый тест, которая не трогает диск; закрывайте её в teardown.
Нужен настоящий SQLite, поэтому это инструментальные тесты или Robolectric.
Проверяйте поведение, а не вызовы: фильтр фильтрует, стратегия конфликтов делает то, что вы думали, Flow переэмитится после записи (помогает Turbine), каскад удаляет детей.
allowMainThreadQueries() допустим здесь и больше нигде.
Тесты миграций
createDatabase(name, oldVersion) строит старую схему; строки вставляются сырым SQL, потому что старых классов сущностей в коде уже нет.
runMigrationsAndValidate(name, newVersion, true, MIGRATION_2_3) сверяет схему с экспортированным JSON.
Затем проверьте, что данные сохранились. Миграция, которая пересоздаёт таблицу и забывает скопировать строки, проходит проверку схемы.
Прогоните и миграцию с версии 1 до последней: так обновляется пользователь, пропустивший релизы.
Тесты репозитория над DAO держите на фейке; тесты базы существуют, чтобы проверять SQL.
Каждый upsert вне транзакции выполняется отдельной транзакцией, а инвалидация в Room идёт по таблицам: каждый коммит заставляет всех наблюдателей task заново выполнить весь запрос. Триста строк дают до трёхсот перезапросов и серию эмиссий.
Что делает трекер
Room добавляет на наблюдаемые таблицы триггеры, которые записывают «эта таблица изменилась» во внутреннюю таблицу-журнал.
После каждой транзакции он уведомляет наблюдателей изменённых таблиц. Какие именно строки изменились, он не знает, поэтому запрос выполняется целиком.
Запрос по нескольким таблицам или @DatabaseView инвалидируется записью в любую из них.
Исправление
Оберните пачку в db.withTransaction { } или @Transaction-метод DAO: один коммит, одно уведомление, один перезапрос, и ни один наблюдатель не увидит наполовину применённую синхронизацию.
Передайте весь список в один вызов @Upsert: он тоже выполняется в одной транзакции.
distinctUntilChanged() отбрасывает одинаковые эмиссии, но запрос уже выполнен; он экономит рекомпозицию, а не I/O.
Наблюдайте узкие запросы. Запрос с LIMIT 50 инвалидируется так же часто, как SELECT *, но перезапускается гораздо дешевле.
addOne() читает количество и пишет current + 1 двумя отдельными запросами, поэтому два нажатия могут оба прочитать 3 и оба записать 4: одно увеличение теряется (lost update). Выполняйте чтение и запись в одной транзакции или пусть SQL сам увеличивает значение колонки.
Почему это случается даже на главном потоке: каждый suspend-вызов DAO уходит на executor Room и приостанавливает вызывающую корутину, поэтому корутина, запущенная следующим нажатием, выполняет свой find() раньше, чем первая дойдёт до upsert(). Sync-воркер, пишущий ту же строку, делает ситуацию хуже.
Как работает исправление: withTransaction выполняет блок как одну пишущую транзакцию на единственном пишущем соединении Room; второй addOne() ждёт, пока первый закоммитится, и уже потом читает новое значение.
Проще, где это возможно: UPDATE cart_item SET quantity = quantity + 1 WHERE productId = :id атомарен сам по себе. Чтение с последующей записью требует транзакции, только когда новое значение вычисляется в Kotlin.
Более слабые варианты: Mutex в репозитории упорядочивает только вызовы этого репозитория, но не sync-воркер и другие пути DAO, пишущие в таблицу; один @Upsert ничего не чинит, потому что пишет значение, уже вычисленное из устаревшего чтения.
Как поймать в следующий раз: тест, который конкурентно запускает 100 вызовов addOne() на in-memory базе и ожидает 100; на ревью стоит отмечать любое чтение через DAO, результат которого идёт в запись через DAO вне транзакции.
Сгенерируйте AES-ключ в Android Keystore, зашифруйте им токен и храните только шифротекст в DataStore, файле или Room. EncryptedSharedPreferences делал эту обёртку за вас, но androidx.security:security-crypto объявлена устаревшей.
Android Keystore: ключи генерируются внутри TEE или StrongBox, не экспортируются, могут требовать аутентификации пользователя и могут инвалидироваться при добавлении новой биометрии.
**После security-crypto: существующие данные продолжают работать, но новый код использует свою небольшую обёртку над Keystore (ниже) или Tink. Запланируйте миграцию: один раз прочитать старым API, записать новым.
Вся база**: SQLCipher (net.zetetic:sqlcipher-android с SupportOpenHelperFactory) шифрует файл Room; его пароль храните обёрнутым ключом из Keystore. За это платят нативной библиотекой в APK и некоторым замедлением запросов.
Хорошие практики: access-токен держите в памяти, а на диске храните только refresh-токен; никогда не храните пароли; чувствительные действия закрывайте BiometricPrompt с CryptoObject, чтобы аутентификация действительно разблокировала ключ; исключайте зашифрованные файлы из бэкапа.
Начиная с Android 10 и 11 приложения не могут писать по произвольным путям в общем хранилище; им доступны приватные директории, коллекции MediaStore и системный пикер файлов. Создание файла через MediaStore или пикер не требует разрешения на хранилище.
Приватные директории: filesDir, cacheDir, getExternalFilesDir(). Без разрешений, закрыты для других приложений на Android 11+ (getExternalFilesDir() по-прежнему виден по USB), удаляются вместе с приложением.
MediaStore: общие коллекции изображений, видео, аудио и загрузок. Вставляете запись, получаете content URI, пишете в его поток. Добавление файлов и чтение своих разрешений не требуют; для чтения чужих медиа нужны медиа-разрешения или Photo Picker.
Storage Access Framework: ActivityResultContracts.CreateDocument и OpenDocument. Пользователь выбирает место, вы получаете URI ровно на этот файл, работают и облачные провайдеры.
Для экспорта: обычно правильнее пикер, потому что файл окажется там, где выбрал пользователь; MediaStore Downloads подходит, когда файл нужно положить автоматически, без диалога.
Детали: помечайте запись MediaStore как IS_PENDING на время записи; вызывайте takePersistableUriPermission, чтобы выбранный URI работал после перезапуска; пишите потоки вне главного потока.
**MANAGE_EXTERNAL_STORAGE**: политика Play разрешает его только файловым менеджерам и похожим приложениям; это не ответ на этот вопрос.
FileOutputStream(target) обрезает файл до записи первого байта, поэтому падение посреди записи оставляет половину файла без старой копии. Пишите во временный файл в той же директории, делайте sync() и переименовывайте поверх целевого или используйте AtomicFile: он хранит бэкап до конца записи.
Если делаете вручную: временный файл в той же директории: переименование атомарно только в пределах одной файловой системы, поэтому читатель увидит либо старый файл, либо новый, но не половину.
**Если делаете вручную: fd.sync() перед переименованием: переименование упорядочивает метаданные, а не ваши байты; без sync отключение питания может оставить пустой файл под правильным именем.
AtomicFile**: startWrite() переносит старый файл в бэкап, finishWrite(stream) делает sync и удаляет его, failWrite(stream) возвращает его на место; readFully() также восстанавливается из оставшегося бэкапа.
Правильная директория: данные, которые нужно сохранить, кладутся в filesDir, а не в cacheDir, который система может очистить в любой момент.
Потоки: файловый I/O выполняется на Dispatchers.IO, и у файла должен быть один писатель; параллельные писатели всё равно конфликтуют.
DataStore внутри использует тот же подход с временным файлом и переименованием, поэтому его файлы не рвутся.
UI наблюдает только Room: сеть пишет ответы в Room, а Flow из Room доставляет их на экран, поэтому данные видны сразу, работают офлайн и приходят одним путём.
Путь чтения: репозиторий возвращает dao.observe...() как Flow; отдельный refresh() вызывает API и записывает результат в одной транзакции.
Когда обновлять: при открытии экрана, по pull-to-refresh или когда данные устарели: храните fetchedAt для каждого ключа и пропускайте запрос, пока данные свежие. HTTP-кеш OkHttp может работать уровнем ниже, но он не заменяет локальную копию, по которой можно делать запросы.
Ошибки: неудачное обновление оставляет на экране закешированные строки плюс неблокирующую ошибку и никогда не очищает экран.
Применение ответа: удалите строки, которых нет в полном списке, и сделайте upsert остальных внутри withTransaction, чтобы наблюдатели получили одно согласованное обновление. Локальные колонки не перезаписывайте.
Большие списки: Paging 3 с PagingSource из Room и RemoteMediator применяет ту же идею постранично.
Локальные правки: сначала пишутся в Room и помечаются как ожидающие; позже их отправляет задача WorkManager. Правила разрешения конфликтов требуют отдельного проектного решения.
Удаление токена лишь перестаёт авторизовывать новые запросы; все локальные копии данных предыдущего пользователя остаются на устройстве, и их увидит следующий, кто войдёт. Сначала остановите писателей, затем очистите все пользовательские хранилища, затем переходите на экран входа.
Фоновая работа: сначала отмените воркеры синхронизации, иначе один из них проснётся и отправит изменения старого пользователя с токеном нового.
Регистрация пушей: отвяжите device token на сервере, пока сессия ещё есть.
Учётные данные: на диске и в памяти; интерсептор, хранящий старый токен в поле, продолжит его отправлять.
База данных: clearAllTables(); удаление файла при открытых соединениях приведёт к падению.
DataStore и SharedPreferences: только пользовательские ключи. Сохранение ключей устройства, вроде темы или пройденного онбординга, должно быть осознанным решением.
Кеши: кеш OkHttp, память и диск загрузчика изображений, экспортированные файлы в cacheDir.
Сведите всё это в один компонент очистки сессии, в котором регистрируется каждое хранилище, чтобы фича, добавленная в следующем квартале, не стала утечкой. Отдельный файл базы на каждый аккаунт делает изоляцию структурной. Проверяйте так: войти одним пользователем, выйти, войти другим, а затем то же самое офлайн.
В режиме WAL SQLite дописывает изменения в отдельный файл -wal, а не переписывает страницы базы на месте, поэтому читатели видят последнее закоммиченное состояние, пока писатель пишет. Room включает WAL по умолчанию, и чтение с записью из разных потоков не блокируют друг друга.
Как это работает
Коммит дописывает страницы в app.db-wal; app.db-shm хранит индекс в разделяемой памяти для этого журнала.
Checkpoint переносит страницы из WAL обратно в основной файл (автоматически, когда журнал превышает пороговый размер).
Читатели не блокируют писателя, а писатель не блокирует читателей. Писатели по-прежнему идут последовательно: по одному.
Что это значит в Room
JournalMode.AUTOMATIC, режим по умолчанию, выбирает WAL на большинстве устройств (кроме low-RAM); setJournalMode(JournalMode.TRUNCATE) отключает его.
Room держит несколько соединений на чтение и одно на запись, поэтому долгая пишущая транзакция не замораживает наблюдаемые запросы, но ставит в очередь все остальные записи.
Три файла составляют одну базу. Копирование, отправка или бэкап только app.db может потерять закоммиченные данные; перед копированием сделайте checkpoint (PRAGMA wal_checkpoint(FULL)) или закройте базу.
WAL работает между процессами, но уведомлениям Room об изменениях всё равно нужен enableMultiInstanceInvalidation().
Компромиссы
Два дополнительных файла и WAL, который может сильно вырасти, если долгое чтение мешает checkpoint.
Немного более медленное чтение, пока журнал большой; взамен вы получаете гораздо лучшую конкурентность и меньше fsync на коммит.
Индекс представляет собой отдельное B-дерево, отсортированное по индексируемым колонкам, поэтому SQLite находит нужные строки за логарифмическое время, а не читает всю таблицу. Запрос, который замедлился с ростом данных, почти всегда делает полный проход по таблице, и EXPLAIN QUERY PLAN это показывает.
Как найти
Прогоните запрос через EXPLAIN QUERY PLAN из @RawQuery в тесте или в Database Inspector. SCAN означает, что прочитана каждая строка; SEARCH ... USING INDEX означает, что использовался индекс.
Измеряйте на устройстве с реалистичными данными: на сотне строк быстро всё, поэтому проблема и попала в релиз.
Как исправить
Индексируйте колонки, по которым фильтруете, джойните и сортируете. Составной индекс работает для запросов, использующих его колонки слева направо: ["chatId", "createdAt"] покрывает «этот чат, сначала новые».
Не индексируйте всё подряд; каждый индекс стоит времени записи и размера файла.
Выбирайте только колонки, которые рисует экран, в небольшой data class, а не грузите тяжёлые тела для каждой строки.
Считайте, фильтруйте и сортируйте в SQL, а не в Kotlin после загрузки строк.
Никогда не фильтруйте по колонке с @TypeConverter; вынесите поле в настоящую колонку.
Предпочитайте keyset-пагинацию по (createdAt, id) (WHERE createdAt < :ts OR (createdAt = :ts AND id < :id), LIMIT n) большому OFFSET, который проходит все пропущенные строки; tie-break по id сохраняет строки с одинаковым createdAt.
LIKE '%term%' не может использовать индекс; используйте FTS-таблицу (@Fts4).
Объединяйте записи в одну транзакцию вместо коммита на каждую строку.
Затем снова посмотрите план запроса и повторите замер на тех же данных.
Auto Backup включён по умолчанию: он копирует filesDir, базы данных и SharedPreferences в Google-аккаунт и восстанавливает их при переустановке или на новом телефоне. Ключи Keystore не бэкапятся, поэтому всё, что вы зашифровали, возвращается шифротекстом без ключа.
Что попадает в бэкап
При android:allowBackup="true" по умолчанию: filesDir, databases/, shared_prefs/ и getExternalFilesDir(), в пределах квоты на приложение. cacheDir и noBackupFilesDir не попадают никогда.
С API 31 это настраивается через android:dataExtractionRules с отдельными секциями <cloud-backup> и <device-transfer>; для старых версий служит android:fullBackupContent.
Для приложений с target Android 12+ allowBackup="false" отключает облачный бэкап, но не перенос с устройства на устройство; для него файлы исключают в правилах.
Что ломается
Данные, зашифрованные ключом из Keystore: байты возвращаются, а ключ не возвращается, и первая расшифровка бросает исключение вместо пустого результата.
Флаги, восстановленные с другого устройства, например onboardingSeen, не соответствуют состоянию нового устройства.
Восстановленная установка может показать закешированные строки со старого устройства ещё до входа в аккаунт.
Что выпустить
Храните зашифрованные Keystore-ключом файлы в noBackupFilesDir или исключите их в правилах.
Всё равно ловите ошибку расшифровки, очищайте это хранилище и отправляйте пользователя на вход, потому что старые бэкапы уже существуют.
Выпустите маркер на релиз раньше, чем начнёте на него реагировать, иначе каждая существующая установка выглядит как восстановленная.
Определяйте восстановление по файлу-маркеру в noBackupFilesDir: если база есть, а маркера нет, значит, данные пришли с другого устройства.