Приложение с интернетом, который то есть, то нет, обречено держать что-то на устройстве: корзину, черновики, прочитанные статьи. Раньше для этого писали обёртки над SQLite руками, и каждый забытый close курсора превращался в утечку, каждая опечатка в тексте запроса в падение у части пользователей. Библиотека Room из набора Jetpack закрывает проблему типизацией: таблицы описываются классами, запросы проверяются на этапе сборки, а работа с корутинами встроена в саму механику. Ветка 2.x давно зрелая, а с версии 2.7 Room вышла за пределы Android и поддерживает Kotlin Multiplatform. Разберём устройство от сущности до миграции, с рабочим кодом и практическими выводами. По пути разберёмся, где Room экономит целые классы обвязки и за что платит.

Компилятор Room проверяет запросы на этапе сборки приложения

Room работает как процессор аннотаций поверх KSP: по помеченным классам и интерфейсам он генерирует классы реализации, которые и делают всю грязную работу с SQLite. Текст запроса из аннотации Query разбирается и сверяется со схемой при сборке, поэтому опечатка в имени столбца валит компиляцию, а не пользователя в поле.

Три роли раскладывают ответственность аккуратно. Сущность описывает таблицу, объект доступа собирает операции, класс базы держит точку входа и версию схемы. Держит всё вместе аннотация Database с перечнем сущностей и объектов доступа:

@Database(
    entities = [Product::class, CartItem::class],
    version = 2,
    exportSchema = true,
)
abstract class ShopDatabase : RoomDatabase() {
    abstract fun productDao(): ProductDao
    abstract fun cartDao(): CartDao
}

Сборка экземпляра делается один раз на приложение, обычно в синглтоне графа зависимостей, а сборки для тестов получают базу в памяти. Флаг exportSchema сохраняет историю схем в JSON, и без него нормальные миграции и их тесты невозможны, поэтому его включают всегда, даже в пет-проектах.

Сущности описывают таблицы аннотациями и связями

Сущность это обычный класс Kotlin, каждое поле которого становится столбцом. Ключ помечается отдельно, индексы выписываются рядом, а вложенные объекты разворачиваются через Embedded с префиксом, чтобы избегать коллизий имён.

@Entity(
    tableName = "products",
    indices = [Index(value = ["sku"], unique = true)],
)
data class Product(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    @ColumnInfo(name = "sku") val sku: String,
    val title: String,
    val price: Long,
    @Embedded(prefix = "img_") val image: ImageRef,
)

data class ImageRef(
    val url: String,
    val width: Int,
    val height: Int,
)

Индексы назначаются осознанно: уникальный индекс на артикул защищает от дублей на уровне базы, а составной индекс на связку категории и даты ускоряет выборки витрины. Поля, не обязанные лежать в базе, помечают аннотацией Ignore, а конвертеры типов переводят даты и перечисления в скалярные столбцы:

class Converters {
    @TypeConverter
    fun fromInstant(value: Instant): Long = value.toEpochMilli()

    @TypeConverter
    fun toInstant(value: Long): Instant =
        Instant.ofEpochMilli(value)
}

@TypeConverters(Converters::class)
@Database(entities = [Product::class], version = 2)
abstract class ShopDatabase : RoomDatabase()

Конвертер регистрируется на уровне базы и работает во всех DAO, поэтому код приложения продолжает видеть привычные типы, а слой хранения остаётся совместимым с SQLite.

Объекты доступа объединяют запросы и типизированные методы

Объект доступа к базе это интерфейс, где методы размечены под конкретные операции. Текст SQL пишется только для чтения, остальное генерируется по смыслу аннотации. Стандартный набор аннотаций DAO выглядит так:

  1. @Insert добавляет строки и возвращает вставленные ключи;
  2. @Update переписывает строки по ключу сущности;
  3. @Delete убирает строки по ключу сущности;
  4. @Upsert вставляет новую строку или обновляет существующую;
  5. @Query выполняет любой SELECT с проверкой текста компилятором;
  6. @RawQuery принимает динамический SQL для гибких фильтров.
@Dao
interface ProductDao {

    @Query("SELECT * FROM products WHERE categoryId = :cat")
    fun observeByCategory(cat: Long): Flow<List<Product>>

    @Query("SELECT * FROM products WHERE id = :id")
    suspend fun findById(id: Long): Product?

    @Upsert
    suspend fun upsert(products: List<Product>)

    @Query("DELETE FROM products WHERE price = 0")
    suspend fun purgeFree(): Int
}

Один и тот же запрос спокойно живёт в трёх обличиях: приостановляемая функция для разового чтения, Flow для живой подписки и LiveData для наследственного кода. Room сам выбирает нужный поток и сам уведомляет подписчиков об изменениях в таблице, поэтому экран, показавший список, обновится после любого вставленного товара без единой строчки ручной перезагрузки.

Для экрана с гибким фильтром, где набор условий собирается на лету, пригоден RawQuery. Функция принимает готовый объект запроса, а параметр observedEntities сохраняет живую подписку на изменения:

@Dao
interface ProductDao {
    @RawQuery(observedEntities = [Product::class])
    fun observeFiltered(q: SupportSQLiteQuery): Flow<List<Product>>
}

val query = SimpleSQLiteQuery(
    "SELECT * FROM products " +
    "WHERE price BETWEEN ? AND ? ORDER BY price",
    arrayOf(minPrice, maxPrice),
)

Условия добавляются в текст по веткам логики, аргументы уходят параметрами, и никакая сборка строк не мешает подписке обновляться.

Корутины Flow и Paging связывают базу с живым интерфейсом

Взаимодействие с базой в современном Android-проекте держится на корутинах, и Room понимает их нативно. Приостановляемые методы выполняются на своём диспетчере ввода-вывода, вызывающий код не оборачивает ничего в withContext, а отмена viewModelScope отменяет и запрос.

Поток Flow от DAO это не разовая выгрузка, а подписка на изменения. Дальше работает обычная механика интерфейса:

class CatalogViewModel(
    private val dao: ProductDao,
) : ViewModel() {

    val products = dao.observeByCategory(cat = 1)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList(),
        )
}

Пока экран жив, поток активен, после ухода подписка гаснет через таймаут, и при возврате обновится заново. Для длинных списков подключается библиотека Paging: DAO возвращает PagingSource, интерфейс получает бесконечную ленту с подгрузкой по мере прокрутки, а LIMIT и OFFSET генерируются сами. Экран запрашивает страницу у объекта Pager с размером, скажем, тридцать позиций, кэш держит несколько страниц, и лента из десяти тысяч позиций стартует мгновенно, подгружая хвост в фоне.

Потоки исходят и в обратную сторону: интерфейс пишет в базу, а подписки получают изменения мгновенно. Практический шаблон такой: репозиторий принимает команды, пишет их в Room, а все экраны читают только потоки. Прямых связей между экранами не остаётся, и приложение не рассыпается при добавлении второго источника значений.

Миграции переводят схему между версиями без потери записей

Схема живёт и меняется, иRoom просит явного маршрута от каждой версии к следующей. Объект миграции описывает SQL преобразования, а сборка базы принимает список маршрутов:

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL(
            "ALTER TABLE products " +
            "ADD COLUMN discount INTEGER NOT NULL DEFAULT 0"
        )
    }
}

Room.databaseBuilder(ctx, ShopDatabase::class.java, "shop.db")
    .addMigrations(MIGRATION_1_2)
    .build()

Ронять таблицу и пересоздавать можно только там, где хранимое не жалко: параметр fallbackToDestructiveMigration удобен в разработке и недопустим в бою, потому что он стирает пользовательские значения при каждом изменении схемы. Для сложных преобразований SQL допустимо копировать во временную таблицу, перестраивать основную и переносить строки назад.

Тесты миграций делаются по сохранённым схемам: помощник создаёт базу версии один, тест доводит её до текущей и проверяет целостность.

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    private val helper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        ShopDatabase::class.java,
    )

    @Test
    fun migrate1To2() {
        helper.createDatabase("shop.db", 1).apply {
            execSQL(
                "INSERT INTO products " +
                "(sku, title, price) VALUES ('A-1', 'Чашка', 200)")
            close()
        }

        val db = helper.runMigrationsAndValidate(
            "shop.db", 2, true, MIGRATION_1_2,
        )
        db.query("SELECT discount FROM products").use { }
        db.close()
    }
}

Помощник сверяет итог со схемой автоматически, поэтому забытая колонка в миграции ловится до релиза. Двадцать минут настройки окупаются в первую же ночь, когда обновление с новой колонкой уезжает к десяти тысячам пользователей без потерь.

Связи и вложенные структуры читаются одним запросом

Связи один-ко-многим и многие-ко-многим описываются вложенными классами с аннотацией Relation, а таблица-соединение для второго случая задаётся параметром junction. Категория со списком товаров собирается одной выборкой:

data class CategoryWithProducts(
    @Embedded val category: Category,
    @Relation(
        parentColumn = "id",
        entityColumn = "categoryId",
    )
    val products: List<Product>,
)

@Dao
interface CategoryDao {
    @Transaction
    @Query("SELECT * FROM categories")
    suspend fun categoriesWithProducts(): List<CategoryWithProducts>
}

Многие-ко-многим описываются таблицей-соединением и параметром junction:

@Entity(primaryKeys = ["orderId", "productId"])
data class OrderProductCrossRef(
    val orderId: Long,
    val productId: Long,
    val qty: Int,
)

data class OrderWithProducts(
    @Embedded val order: Order,
    @Relation(
        parentColumn = "id",
        entityColumn = "id",
        associateBy = Junction(
            parentColumn = "orderId",
            entityColumn = "productId",
        ),
    )
    val products: List<Product>,
)

Аннотация Transaction оборачивает несколько внутренних запросов в одну транзакцию, без неё Room отказывается компилировать выборку со связями, защищая от рассинхрона. Для сложной логики Views базы описываются аннотацией DatabaseView и читаются как обычные сущности, а представления базы описываются аннотацией DatabaseView и читаются как обычные сущности, скрывая тяжёлые соединения в слое схемы.

Тестирование слоя базы и практическая сборка модуля

База для тестов поднимается в памяти одной строкой: inMemoryDatabaseBuilder не пишет на диск, каждый прогон получает чистые таблицы, а полный набор тестов проходит за секунды. Инструментальные прогоны гоняются на устройстве или эмуляторе, модульные на JVM через отдельную сборку Room для JVM, и оба мира покрывают разные вещи: модульные проверяют логику запросов, инструментальные поведение на реальном SQLite. Многоплатформенная поддержка из версии 2.7 расширила третий мир: одна база описывается и для Android, и для iOS, и для десктопа, что упрощает приложения на Kotlin Multiplatform.

@Test
fun `upsert заменяет существующий товар`() = runTest {
    val db = Room.inMemoryDatabaseBuilder(
        context, ShopDatabase::class.java
    ).build()
    val dao = db.productDao()

    dao.upsert(listOf(Product(sku = "A-1", title = "Кружка", price = 300)))
    dao.upsert(listOf(Product(sku = "A-1", title = "Кружка v2", price = 350)))

    val loaded = dao.findById(1)!!
    assertEquals("Кружка v2", loaded.title)
    db.close()
}

Сборка модуля держит привычную гигиену: сущности и DAO живут рядом, класс базы единственный, репозиторий прячет DAO за интерфейсом, а тестовая фикстура подменяет реализацию в памяти. При таком раскладе интерфейс зависит от типов, а не от базы, и переход части хранилища в сетевой сервис не рушит экраны.

Итог для практики. Room закрывает локальное хранение типизированно и предсказуемо: запросы проверены компилятором, подписки на изменения встроены, миграции тестируемы, а корутины экономят целые слои обвязки. Голый SQLite остаётся для экзотики вроде гигантских выборов с ручным управлением курсорами, но для девяти приложений из десяти Room это та точка равновесия, где скорость разработки не ломает надёжность, и многолетняя эволюция библиотеки только укрепляет это равновесие.