Перейти к основному содержимому

Room, ViewModel и Compose — список заметок

Разработчику Архитектору

Room, ViewModel и Compose — список заметок

Compose — первый экран хранил счётчик в памяти: при убийстве процесса или переустановке данные пропадают. Заметки, настройки, корзина обычно сохраняют в локальной базе на телефоне.

Стек из этой статьи — рекомендуемый для новых Android-экранов:

СлойБиблиотекаЗадача
UIComposeПоказать список, поле ввода, кнопки
Логика экранаViewModelПережить поворот, запустить корутины
ДанныеRoomSQLite без ручного SQL на каждый запрос

Перед стартом: первая программа, Compose, корутины. Маршрут раздела: intro.

Практическое задание

Добавьте три заметки через UI, закройте приложение, откройте снова — список должен сохраниться.


Словарь терминов

ТерминПростыми словами
SQLiteВстроенная база данных в файле на устройстве.
RoomОбёртка над SQLite: аннотации вместо длинного SQL в коде.
EntityКласс = одна таблица (NoteEntity).
DAOИнтерфейс с методами "вставить", "прочитать всё", "удалить".
@QuerySQL-запрос, который 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.launchinsert выполняется в корутине, не на 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 threadinsert вызван синхронно с Main
KSP не генерирует кодНе применён plugin ksp
Сброс при поворотеСписок только в remember, без ViewModel

Что попробовать

  1. Метод @Delete в DAO и удаление по свайпу.
  2. Navigation Compose — экран деталей заметки.
  3. Синхронизация с сервером: Ktor Client + запись в Room.

Дальше

Jetpack Compose · Flow · мобильный раздел