Почти любое приложение хранит что-то локально: кеш ленты, черновики, настройки. На собесе спрашивают Room (устройство, Flow, транзакции, миграции), DataStore вместо SharedPreferences и подход offline-first.
Room
Room это ORM-обёртка над SQLite из Jetpack. Запросы проверяются при компиляции: опечатка в имени колонки ломает сборку, а не приложение у пользователя.
@Entity(tableName = "articles")
data class ArticleEntity(
@PrimaryKey val id: String,
val title: String,
@ColumnInfo(name = "published_at") val publishedAt: Long,
val isFavorite: Boolean = false,
)
@Dao
interface ArticleDao {
@Query("SELECT * FROM articles ORDER BY published_at DESC")
fun observeAll(): Flow<List<ArticleEntity>>
@Query("SELECT * FROM articles WHERE id = :id")
suspend fun byId(id: String): ArticleEntity?
@Upsert
suspend fun upsertAll(items: List<ArticleEntity>)
@Query("DELETE FROM articles")
suspend fun clear()
@Transaction
suspend fun replaceAll(items: List<ArticleEntity>) { clear(); upsertAll(items) }
}
@Database(entities = [ArticleEntity::class], version = 2)
abstract class AppDatabase : RoomDatabase() {
abstract fun articleDao(): ArticleDao
}
Аннотации обрабатывает KSP (kapt устарел для Room). Room 2.7 и новее поддерживает Kotlin Multiplatform.
Важное:
suspend-функции DAO Room сам выполняет вне главного потока. Синхронный запрос с главного потока по умолчанию броситIllegalStateException.- Запрос, возвращающий
Flow, наблюдаемый: при любом изменении таблиц, участвующих в запросе, Room заново выполнит запрос и отправит новый список. Это основа единого источника правды. @Transactionделает несколько операций атомарными; нужен и для запросов с@Relation, чтобы связанные данные читались согласованно.- Связи:
@Relationс отдельным классом-результатом,@Embedded, внешние ключиForeignKeyсonDelete = CASCADE. Списки и сложные типы в колонке через@TypeConverter(или отдельная таблица). - Базу создают один раз на приложение (синглтон через DI):
Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build().
Миграции
При изменении схемы увеличивают version и описывают миграцию:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE articles ADD COLUMN isFavorite INTEGER NOT NULL DEFAULT 0")
}
}
Для простых изменений есть автомиграции: @Database(version = 2, autoMigrations = [AutoMigration(from = 1, to = 2)]) (нужен exportSchema = true и папка со схемами в git). Если миграции нет, Room упадёт с IllegalStateException. fallbackToDestructiveMigration() удаляет и пересоздаёт базу: допустимо для чистого кеша, недопустимо для пользовательских данных. Миграции тестируют MigrationTestHelper.
DataStore вместо SharedPreferences
SharedPreferences: синхронный API, apply() пишет на диск асинхронно, но может блокировать главный поток при остановке Activity и давать ANR; нет обработки ошибок и типобезопасности.
DataStore работает на корутинах и Flow:
val Context.settings by preferencesDataStore("settings")
val DARK = booleanPreferencesKey("dark_theme")
val darkTheme: Flow<Boolean> = context.settings.data.map { it[DARK] ?: false }
suspend fun setDark(v: Boolean) = context.settings.edit { it[DARK] = v }
- Preferences DataStore: ключ-значение без схемы.
- Proto DataStore (или с kotlinx.serialization): типизированный объект.
Правило: один экземпляр DataStore на файл в процессе, иначе исключение. DataStore для небольших настроек, не для больших или реляционных данных (это Room).
Offline-first
- UI читает только из базы (
Flowиз Room). - Репозиторий при открытии экрана или по таймеру идёт в сеть и записывает результат в базу.
- Room сам уведомляет UI.
- Изменения пользователя пишутся в базу сразу (оптимистично) и синхронизируются с сервером через WorkManager, с пометкой «не синхронизировано».
Отдельные вопросы senior-уровня: разрешение конфликтов (кто прав, сервер или клиент, по версии или времени), пагинация (Paging 3 с RemoteMediator), очистка устаревшего кеша.
Что ещё бывает
Файлы во внутреннем хранилище (filesDir, cacheDir), шифрование (EncryptedSharedPreferences объявлен устаревшим, вместо него ключи в Android Keystore и шифрование своими средствами или Tink), SQLDelight как альтернатива Room в KMP-проектах.
Частые ошибки
- Синхронные запросы к базе на главном потоке.
- Несколько экземпляров базы или DataStore.
fallbackToDestructiveMigrationна пользовательских данных.- Отсутствие
@Transactionу составных операций. - Хранение больших списков в SharedPreferences или DataStore.
- Разные источники правды: UI показывает ответ сети, а база живёт отдельно.
Что спросят на собесе
- Как устроен Room и чем он лучше голого SQLite?
- Как работает
Flowиз DAO? - Как делаются миграции и что будет без них?
- Чем DataStore лучше SharedPreferences?
- Как построить offline-first?