Room + Flow: когда база данных сама обновляет интерфейс

Классический подход к работе с базой данных в Android выглядит так: отправил запрос, получил результат, обновил UI. Понадобились свежие данные — отправил ещё один запрос. Добавил запись — снова запросил весь список. Удалил — запросил. Изменил — запросил. Схема «запрос-ответ» работает, но заставляет разработчика вручную синхронизировать данные с интерфейсом.

Room предлагает другой подход. Вместо разовых запросов — потоки. DAO-метод возвращает Flow, и Room автоматически эмиттит новое значение каждый раз, когда данные в таблице меняются. Интерфейс подписывается на этот поток и обновляется сам, без повторных запросов.

Два способа работы с Room

В Room есть чёткое разделение: suspend-функции для разовых операций и Flow для наблюдения.

@Dao
interface TaskDao {
    // Разовые операции: suspend
    @Insert
    suspend fun insert(task: TaskEntity)

    @Delete
    suspend fun delete(task: TaskEntity)

    @Query("UPDATE tasks SET isDone = :isDone WHERE id = :id")
    suspend fun setDone(id: Int, isDone: Boolean)

    // Наблюдение: Flow
    @Query("SELECT * FROM tasks ORDER BY createdAt DESC")
    fun observeAll(): Flow<List<TaskEntity>>

    @Query("SELECT COUNT(*) FROM tasks WHERE isDone = 1")
    fun observeDoneCount(): Flow<Int>
}

Методы insert(), delete(), setDone() выполняются один раз и завершаются. Их можно вызвать из корутины — например, из viewModelScope.launch {}.

Методы observeAll() и observeDoneCount() не завершаются. Они возвращают холодный Flow, который при каждом collect() начинает наблюдение за таблицей. Изменилась таблица tasks — Room выполняет запрос заново и эмиттит новый результат. Удалили задачу — поток автоматически выдаст обновлённый список. Добавили — тоже.

Обратите внимание: методы, возвращающие Flow, не помечены как suspend. Они не приостанавливают выполнение — они создают поток, на который кто-то потом подпишется.

Entity: простая таблица

@Entity(tableName = "tasks")
data class TaskEntity(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val title: String,
    val isDone: Boolean = false,
    val createdAt: Long = System.currentTimeMillis()
)

ViewModel: подогреваем поток

Знакомая схема: репозиторий (или DAO напрямую, для простоты) возвращает холодный Flow, ViewModel превращает его в горячий StateFlow.

Заметьте: методы addTask(), toggleDone(), removeTask() ничего не возвращают. Они просто изменяют данные в базе. А за обновление интерфейса отвечают потоки tasks и doneCount — Room сам уведомит их об изменениях.

Это принципиальный сдвиг мышления. В императивном подходе вы бы написали:

// Императивно: вставил → перезагрузил
fun addTask(title: String) {
    viewModelScope.launch {
        dao.insert(TaskEntity(title = title))
        _tasks.value = dao.getAll()  // ручное обновление
    }
}

В реактивном подходе цепочка короче и надёжнее: вставил → Room автоматически оповестил Flow → UI обновился. Ручная синхронизация не нужна.

Composable: подписка и отображение

@Composable
fun TaskScreen(viewModel: TaskViewModel) {
    val tasks by viewModel.tasks.collectAsStateWithLifecycle()
    val doneCount by viewModel.doneCount.collectAsStateWithLifecycle()
    var newTitle by remember { mutableStateOf("") }

    Column(Modifier.fillMaxSize().padding(16.dp)) {
        Text("Выполнено: $doneCount из ${tasks.size}", fontSize = 18.sp)
        Spacer(Modifier.height(12.dp))

        Row(verticalAlignment = Alignment.CenterVertically) {
            OutlinedTextField(
                value = newTitle,
                onValueChange = { newTitle = it },
                modifier = Modifier.weight(1f),
                placeholder = { Text("Новая задача") },
                singleLine = true
            )
            Spacer(Modifier.width(8.dp))
            Button(onClick = {
                if (newTitle.isNotBlank()) {
                    viewModel.addTask(newTitle.trim())
                    newTitle = ""
                }
            }) {
                Text("Добавить")
            }
        }

        Spacer(Modifier.height(12.dp))

        LazyColumn {
            items(tasks, key = { it.id }) { task ->
                TaskRow(
                    task = task,
                    onToggle = { viewModel.toggleDone(task) },
                    onDelete = { viewModel.removeTask(task) }
                )
            }
        }
    }
}

Ни одной строчки кода для перезагрузки данных. Добавил задачу — она появилась. Удалил — исчезла. Переключил статус — счётчик обновился. Всё работает через поток.

Когда Flow, а когда suspend

Правило простое:

Если результат нужен один раз (вставка, удаление, обновление, разовая выборка по id) — используйте suspend.

Если результат нужен постоянно (список на экране, счётчик, статистика) — используйте Flow.

Не стоит оборачивать в Flow всё подряд. Метод getById(id), который вызывается один раз при открытии экрана, прекрасно работает как suspend. Но список задач, который должен обновляться в реальном времени, — это Flow.


Room + Flow — мощная комбинация. База данных становится источником истины: все изменения проходят через неё, а интерфейс реагирует автоматически. Это основа реактивной архитектуры Android-приложения, которая избавляет от ручной синхронизации и снижает количество багов.