Room, ViewModel и Compose — список заметок
Room, ViewModel и Compose — список заметок
Compose — первый экран хранил счётчик в памяти: при убийстве процесса или переустановке данные пропадают. Заметки, настройки, корзина обычно сохраняют в локальной базе на телефоне.
Стек из этой статьи — рекомендуемый для новых Android-экранов:
| Слой | Библиотека | Задача |
|---|---|---|
| UI | Compose | Показать список, поле ввода, кнопки |
| Логика экрана | ViewModel | Пережить поворот, запустить корутины |
| Данные | Room | SQLite без ручного SQL на каждый запрос |
Перед стартом: первая программа, Compose, корутины. Маршрут раздела: intro.
Добавьте три заметки через UI, закройте приложение, откройте снова — список должен сохраниться.
Словарь терминов
| Термин | Простыми словами |
|---|---|
| SQLite | Встроенная база данных в файле на устройстве. |
| Room | Обёртка над SQLite: аннотации вместо длинного SQL в коде. |
| Entity | Класс = одна таблица (NoteEntity). |
| DAO | Интерфейс с методами "вставить", "прочитать всё", "удалить". |
@Query | SQL-запрос, который Room проверяет на этапе компиляции. |
| KSP | Генератор кода для Room (наследник kapt). |
Слои (как думать о коде)
Compose (NotesScreen)
↓ collectAsState
ViewModel (NotesViewModel)
↓ Flow<List<Note>>
DAO (NoteDao) → Room → SQLite (файл notes.db)
Пользователь нажимает "Добавить" → ViewModel вызывает dao.insert в корутине → Room обновляет таблицу → observeAll() эмитит новый список → Compose перерисовывает LazyColumn.
Что получится
Экран — поле ввода, кнопка "Добавить", список заметок. Данные остаются после закрытия приложения.
Зависимости (app/build.gradle.kts)
plugins {
id("com.google.devtools.ksp") version "2.0.0-1.0.24" // версию подставьте под Kotlin
}
dependencies {
val room = "2.6.1"
implementation("androidx.room:room-runtime:$room")
implementation("androidx.room:room-ktx:$room")
ksp("androidx.room:room-compiler:$room")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0")
implementation("androidx.compose.material3:material3")
}
Разбор:
- Плагин
kspзапускает генерацию кода для аннотированных классов Room. room-runtimeдобавляет базовые компоненты Room и интеграцию со SQLite.room-ktxрасширяет API поддержкойFlowиsuspendдля корутин.room-compilerпроверяет SQL и генерирует реализации DAO на этапе компиляции.- Compose и ViewModel зависимости связывают слой хранения с реактивным UI.
room-ktx добавляет suspend-функции и Flow в DAO.
Сущность и DAO
Код ITЗагрузка примера кода…
Разбор:
| Аннотация | Смысл |
|---|---|
@Entity | Эта data class — таблица notes. |
@PrimaryKey(autoGenerate = true) | id задаёт SQLite при вставке. |
observeAll(): Flow<...> | При любом изменении таблицы подписчики получают новый список. |
suspend fun insert | Вставка в фоне, без блокировки UI-потока. |
База данных
@Database(entities = [NoteEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
}
Разбор:
@Databaseобъявляет схему БД: список сущностей и текущую версию.- Наследование от
RoomDatabaseдаёт инфраструктуру транзакций и управление DAO. noteDao()— фабричный метод для доступа к операциям таблицыnotes.- Реальную реализацию этого класса Room генерирует автоматически через KSP.
Создание (учебный вариант в Activity):
val db = Room.databaseBuilder(
applicationContext,
AppDatabase::class.java,
"notes.db"
).build()
Разбор:
databaseBuilder(...)создаёт конфигурацию локальной SQLite-базы через Room.applicationContextподходит для долгоживущих объектов, не привязанных к UI-экрану.AppDatabase::class.javaзадаёт тип схемы, который Room должен инициализировать.- Имя
notes.dbопределяет файл, в котором физически хранятся данные пользователя.
В продакшене базу обычно создают один раз (Application, Hilt) — см. экосистему.
ViewModel
Код ITЗагрузка примера кода…
Разбор:
| Строка | Смысл |
|---|---|
dao.observeAll() | Cold Flow из Room — обновления при изменении таблицы. |
stateIn(..., WhileSubscribed(5000)) | Горячий StateFlow для UI; отписка через 5 с после ухода с экрана. |
initialValue = emptyList() | До первой эмиссии UI видит пустой список. |
viewModelScope.launch | insert выполняется в корутине, не на Main. |
Подробнее про Flow: Flow в Kotlin.
Compose-экран
Код ITЗагрузка примера кода…
inputвremember— текст поля только на UI-слое.notesиз ViewModel — источник правды из БД.items(..., key = { it.id })— стабильные ключи для анимаций и производительности.
Фабрика ViewModel
setContent {
val vm: NotesViewModel = viewModel(factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return NotesViewModel(db.noteDao()) as T
}
})
MaterialTheme { NotesScreen(vm) }
}
Разбор:
viewModel(factory = ...)нужен, когда ViewModel требует параметры конструктора.ViewModelProvider.Factoryцентрализует создание VM и привязывает её к lifecycle.@Suppress("UNCHECKED_CAST")закрывает ограничение обобщённого API фабрики.- Передача
vmвNotesScreenделает зависимость экрана явной и тестопригодной.
Репозиторий между DAO и ViewModel
Для роста проекта полезно добавить промежуточный слой:
class NotesRepository(private val dao: NoteDao) {
fun observeNotes(): Flow<List<NoteEntity>> = dao.observeAll()
suspend fun add(text: String) {
dao.insert(NoteEntity(text = text))
}
}
Разбор:
- Репозиторий отделяет бизнес-операции от конкретной реализации хранения данных.
observeNotes()отдаёт поток для UI и скрывает детали DAO-запросов.add(text)централизует созданиеNoteEntity, где удобно добавить валидации.- Такой слой облегчает переход к offline-first и синхронизации с сервером.
ViewModel работает с NotesRepository, а не напрямую с DAO. Это упрощает тесты и позволяет добавить синхронизацию с сервером через Ktor Client без переписывания UI.
Миграции Room
Когда схема таблицы меняется, увеличивайте version и добавляйте миграцию:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE notes ADD COLUMN createdAt INTEGER NOT NULL DEFAULT 0")
}
}
Разбор:
Migration(1, 2)описывает шаг обновления схемы с версии 1 до версии 2.execSQL(...)вносит структурное изменение таблицы на уже установленных приложениях.NOT NULL DEFAULT 0гарантирует валидные значения у существующих записей.- Миграция хранится в коде и документирует эволюцию схемы БД.
Room.databaseBuilder(context, AppDatabase::class.java, "notes.db")
.addMigrations(MIGRATION_1_2)
.build()
Разбор:
addMigrations(...)регистрирует допустимые переходы версий базы.- Room применяет миграции до открытия DAO и работы UI-слоя.
- Отсутствие нужной миграции приводит к ошибке валидации схемы при запуске.
- Цепочки миграций позволяют безопасно обновлять пользователей с разных версий.
Миграции лучше проверять отдельным тестом, чтобы обновление приложения не теряло пользовательские данные.
UI-состояние загрузки и ошибок
Для реального экрана одного List<NoteEntity> мало: нужен статус загрузки и текст ошибки.
data class NotesUiState(
val isLoading: Boolean = true,
val notes: List<NoteEntity> = emptyList(),
val error: String? = null
)
Разбор:
NotesUiStateобъединяет данные и статус экрана в один предсказуемый контракт.isLoadingдаёт явный сигнал для индикатора загрузки в UI.notesхранит отображаемый список и не требует дополнительных глобальных переменных.errorобеспечивает единый канал для пользовательского сообщения об ошибке.
Эта модель хорошо сочетается с Compose — экран явно показывает три режима "загрузка", "данные", "ошибка", а не пустой список без объяснения.
Дополнительный сниппет — удаление заметки
@Dao
interface NoteDao {
@Query("DELETE FROM notes WHERE id = :id")
suspend fun deleteById(id: Long)
}
fun NotesViewModel.deleteNote(id: Long) {
viewModelScope.launch {
dao.deleteById(id)
}
}
Разбор:
@Query("DELETE ...")задаёт явную SQL-операцию удаления по первичному ключу.- Параметр
:idбезопасно подставляется Room через биндинг, без ручной конкатенации SQL. suspend fun deleteByIdвыполняется в корутине и не блокирует главный поток.- В
ViewModelоперация обёрнута вviewModelScope.launch, чтобы учитывать жизненный цикл экрана. - После удаления
Flowиз Room эмитит новый список, и Compose автоматически обновляет UI.
Частые ошибки
| Симптом | Причина |
|---|---|
| Список пустой после добавления | Нет Flow в DAO или insert не в корутине |
Cannot access database on the main thread | insert вызван синхронно с Main |
| KSP не генерирует код | Не применён plugin ksp |
| Сброс при повороте | Список только в remember, без ViewModel |
Что попробовать
- Метод
@Deleteв DAO и удаление по свайпу. - Navigation Compose — экран деталей заметки.
- Синхронизация с сервером: Ktor Client + запись в Room.
Дальше
Jetpack Compose · Flow · мобильный раздел