Приложение с интернетом, который то есть, то нет, обречено держать что-то на устройстве: корзину, черновики, прочитанные статьи. Раньше для этого писали обёртки над 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 выглядит так:
- @Insert добавляет строки и возвращает вставленные ключи;
- @Update переписывает строки по ключу сущности;
- @Delete убирает строки по ключу сущности;
- @Upsert вставляет новую строку или обновляет существующую;
- @Query выполняет любой SELECT с проверкой текста компилятором;
- @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 это та точка равновесия, где скорость разработки не ломает надёжность, и многолетняя эволюция библиотеки только укрепляет это равновесие.