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

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")
}

room-ktx добавляет suspend-функции и Flow в DAO.


Сущность и DAO

@Entity(tableName = "notes")
data class NoteEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "text") val text: String
)

@Dao
interface NoteDao {
@Query("SELECT * FROM notes ORDER BY id DESC")
fun observeAll(): Flow<List<NoteEntity>>

@Insert
suspend fun insert(note: NoteEntity)
}

Разбор:

АннотацияСмысл
@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
}

Создание (учебный вариант в Activity):

val db = Room.databaseBuilder(
applicationContext,
AppDatabase::class.java,
"notes.db"
).build()

В продакшене базу обычно создают один раз (Application, Hilt) — см. экосистему.


ViewModel

class NotesViewModel(private val dao: NoteDao) : ViewModel() {

val notes: StateFlow<List<NoteEntity>> =
dao.observeAll()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)

fun addNote(text: String) {
if (text.isBlank()) return
viewModelScope.launch {
dao.insert(NoteEntity(text = text.trim()))
}
}
}

Разбор:

СтрокаСмысл
dao.observeAll()Cold Flow из Room — обновления при изменении таблицы.
stateIn(..., WhileSubscribed(5000))Горячий StateFlow для UI; отписка через 5 с после ухода с экрана.
initialValue = emptyList()До первой эмиссии UI видит пустой список.
viewModelScope.launchinsert выполняется в корутине, не на Main.

Подробнее про Flow: 226.md.


Compose-экран

@Composable
fun NotesScreen(vm: NotesViewModel = viewModel(
factory = /* фабрика с dao — см. ниже */
)) {
val notes by vm.notes.collectAsState()
var input by remember { mutableStateOf("") }

Column(Modifier.padding(16.dp)) {
OutlinedTextField(
value = input,
onValueChange = { input = it },
label = { Text("Заметка") },
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = {
vm.addNote(input)
input = ""
},
modifier = Modifier.padding(vertical = 8.dp)
) { Text("Добавить") }

LazyColumn {
items(notes, key = { it.id }) { note ->
Text(note.text, modifier = Modifier.padding(8.dp))
}
}
}
}
  • 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) }
}

Частые ошибки

СимптомПричина
Список пустой после добавленияНет 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 · мобильный раздел


См. также

Другие статьи этого же раздела в боковом меню (как на странице «О разделе»).