Урок по треку Android 7 мин

Room и хранение данных за 7 минут

Room, DAO, миграции, Flow из базы, DataStore и offline-first.

Почти любое приложение хранит что-то локально: кеш ленты, черновики, настройки. На собесе спрашивают 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

  1. UI читает только из базы (Flow из Room).
  2. Репозиторий при открытии экрана или по таймеру идёт в сеть и записывает результат в базу.
  3. Room сам уведомляет UI.
  4. Изменения пользователя пишутся в базу сразу (оптимистично) и синхронизируются с сервером через 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?

Закрепите за 5 минут

После урока в тренажёре 3-5 проверочных вопросов, дальше модуль уходит в повторения.